diff --git a/src/components/panels/super-admin-panel.tsx b/src/components/panels/super-admin-panel.tsx
index 0851a71..81ae747 100644
--- a/src/components/panels/super-admin-panel.tsx
+++ b/src/components/panels/super-admin-panel.tsx
@@ -461,6 +461,9 @@ export function SuperAdminPanel() {
{createExpanded && (
+
+ Add a new workspace/client instance here. Fill the form below and click Create + Queue.
+
{gatewayLoadError && (
Gateway list unavailable: {gatewayLoadError}. Using fallback owner value.
diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts
index c25b97f..0a67b63 100644
--- a/src/lib/__tests__/auth.test.ts
+++ b/src/lib/__tests__/auth.test.ts
@@ -105,4 +105,23 @@ describe('requireRole', () => {
)
expect(result.user).toBeDefined()
})
+
+ it('accepts Authorization Bearer API key', () => {
+ const result = requireRole(
+ makeRequest({ authorization: 'Bearer test-api-key-secret' }),
+ 'admin',
+ )
+ expect(result.user).toBeDefined()
+ expect(result.user!.username).toBe('api')
+ })
+
+ it('rejects API key auth when API_KEY is not configured', () => {
+ process.env = { ...originalEnv, API_KEY: '' }
+ const result = requireRole(
+ makeRequest({ 'x-api-key': 'test-api-key-secret' }),
+ 'viewer',
+ )
+ expect(result.status).toBe(401)
+ expect(result.user).toBeUndefined()
+ })
})
diff --git a/src/lib/__tests__/cron-occurrences.test.ts b/src/lib/__tests__/cron-occurrences.test.ts
new file mode 100644
index 0000000..08cf2cd
--- /dev/null
+++ b/src/lib/__tests__/cron-occurrences.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences'
+
+describe('buildDayKey', () => {
+ it('formats YYYY-MM-DD in local time', () => {
+ const date = new Date(2026, 2, 4, 9, 15, 0, 0)
+ expect(buildDayKey(date)).toBe('2026-03-04')
+ })
+})
+
+describe('getCronOccurrences', () => {
+ it('expands daily schedule across range', () => {
+ const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
+ const end = new Date(2026, 2, 5, 0, 0, 0, 0).getTime()
+ const rows = getCronOccurrences('0 0 * * *', start, end)
+ expect(rows).toHaveLength(4)
+ expect(rows.map((r) => r.dayKey)).toEqual([
+ '2026-03-01',
+ '2026-03-02',
+ '2026-03-03',
+ '2026-03-04',
+ ])
+ })
+
+ it('supports step values', () => {
+ const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
+ const end = new Date(2026, 2, 1, 3, 0, 0, 0).getTime()
+ const rows = getCronOccurrences('*/30 * * * *', start, end)
+ expect(rows).toHaveLength(6)
+ })
+
+ it('ignores OpenClaw timezone suffix in display schedule', () => {
+ const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
+ const end = new Date(2026, 2, 2, 0, 0, 0, 0).getTime()
+ const rows = getCronOccurrences('0 6 * * * (UTC)', start, end)
+ expect(rows).toHaveLength(1)
+ })
+
+ it('returns empty list for invalid cron', () => {
+ const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
+ const end = new Date(2026, 2, 2, 0, 0, 0, 0).getTime()
+ expect(getCronOccurrences('invalid', start, end)).toEqual([])
+ })
+})
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 04f853e..e1517e7 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -277,8 +277,9 @@ export function getUserFromRequest(request: Request): User | null {
}
// Check API key - return synthetic user
- const apiKey = request.headers.get('x-api-key')
- if (apiKey && safeCompare(apiKey, process.env.API_KEY || '')) {
+ const configuredApiKey = (process.env.API_KEY || '').trim()
+ const apiKey = extractApiKeyFromHeaders(request.headers)
+ if (configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) {
return {
id: 0,
username: 'api',
@@ -294,6 +295,24 @@ export function getUserFromRequest(request: Request): User | null {
return null
}
+function extractApiKeyFromHeaders(headers: Headers): string | null {
+ const direct = (headers.get('x-api-key') || '').trim()
+ if (direct) return direct
+
+ const authorization = (headers.get('authorization') || '').trim()
+ if (!authorization) return null
+
+ const [scheme, ...rest] = authorization.split(/\s+/)
+ if (!scheme || rest.length === 0) return null
+
+ const normalized = scheme.toLowerCase()
+ if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') {
+ return rest.join(' ').trim() || null
+ }
+
+ return null
+}
+
/**
* Role hierarchy levels for access control.
* viewer < operator < admin
diff --git a/src/lib/cron-occurrences.ts b/src/lib/cron-occurrences.ts
new file mode 100644
index 0000000..88472fc
--- /dev/null
+++ b/src/lib/cron-occurrences.ts
@@ -0,0 +1,139 @@
+export interface CronOccurrence {
+ atMs: number
+ dayKey: string
+}
+
+interface ParsedField {
+ any: boolean
+ matches: (value: number) => boolean
+}
+
+interface ParsedCron {
+ minute: ParsedField
+ hour: ParsedField
+ dayOfMonth: ParsedField
+ month: ParsedField
+ dayOfWeek: ParsedField
+}
+
+function normalizeCronExpression(raw: string): string {
+ const trimmed = raw.trim()
+ const tzSuffixMatch = trimmed.match(/^(.*)\s+\([^)]+\)$/)
+ return (tzSuffixMatch?.[1] || trimmed).trim()
+}
+
+function parseToken(token: string, min: number, max: number): { any: boolean; values: Set } {
+ const valueSet = new Set()
+ const trimmed = token.trim()
+ if (trimmed === '*') {
+ for (let i = min; i <= max; i += 1) valueSet.add(i)
+ return { any: true, values: valueSet }
+ }
+
+ for (const part of trimmed.split(',')) {
+ const section = part.trim()
+ if (!section) continue
+
+ const [rangePart, stepPart] = section.split('/')
+ const step = stepPart ? Number(stepPart) : 1
+ if (!Number.isFinite(step) || step <= 0) continue
+
+ if (rangePart === '*') {
+ for (let i = min; i <= max; i += step) valueSet.add(i)
+ continue
+ }
+
+ if (rangePart.includes('-')) {
+ const [fromRaw, toRaw] = rangePart.split('-')
+ const from = Number(fromRaw)
+ const to = Number(toRaw)
+ if (!Number.isFinite(from) || !Number.isFinite(to)) continue
+ const start = Math.max(min, Math.min(max, from))
+ const end = Math.max(min, Math.min(max, to))
+ for (let i = start; i <= end; i += step) valueSet.add(i)
+ continue
+ }
+
+ const single = Number(rangePart)
+ if (!Number.isFinite(single)) continue
+ if (single >= min && single <= max) valueSet.add(single)
+ }
+
+ return { any: false, values: valueSet }
+}
+
+function parseField(token: string, min: number, max: number): ParsedField {
+ const parsed = parseToken(token, min, max)
+ return {
+ any: parsed.any,
+ matches: (value: number) => parsed.values.has(value),
+ }
+}
+
+function parseCron(raw: string): ParsedCron | null {
+ const normalized = normalizeCronExpression(raw)
+ const parts = normalized.split(/\s+/).filter(Boolean)
+ if (parts.length !== 5) return null
+
+ return {
+ minute: parseField(parts[0], 0, 59),
+ hour: parseField(parts[1], 0, 23),
+ dayOfMonth: parseField(parts[2], 1, 31),
+ month: parseField(parts[3], 1, 12),
+ dayOfWeek: parseField(parts[4], 0, 6),
+ }
+}
+
+function matchesDay(parsed: ParsedCron, date: Date): boolean {
+ const dayOfMonthMatches = parsed.dayOfMonth.matches(date.getDate())
+ const dayOfWeekMatches = parsed.dayOfWeek.matches(date.getDay())
+
+ if (parsed.dayOfMonth.any && parsed.dayOfWeek.any) return true
+ if (parsed.dayOfMonth.any) return dayOfWeekMatches
+ if (parsed.dayOfWeek.any) return dayOfMonthMatches
+ return dayOfMonthMatches || dayOfWeekMatches
+}
+
+export function buildDayKey(date: Date): string {
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+}
+
+export function getCronOccurrences(
+ schedule: string,
+ rangeStartMs: number,
+ rangeEndMs: number,
+ max = 1000
+): CronOccurrence[] {
+ if (!schedule || !Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs)) return []
+ if (rangeEndMs <= rangeStartMs || max <= 0) return []
+
+ const parsed = parseCron(schedule)
+ if (!parsed) return []
+
+ const occurrences: CronOccurrence[] = []
+ const cursor = new Date(rangeStartMs)
+ cursor.setSeconds(0, 0)
+ if (cursor.getTime() < rangeStartMs) {
+ cursor.setMinutes(cursor.getMinutes() + 1, 0, 0)
+ }
+
+ while (cursor.getTime() < rangeEndMs && occurrences.length < max) {
+ if (
+ parsed.month.matches(cursor.getMonth() + 1) &&
+ matchesDay(parsed, cursor) &&
+ parsed.hour.matches(cursor.getHours()) &&
+ parsed.minute.matches(cursor.getMinutes())
+ ) {
+ occurrences.push({
+ atMs: cursor.getTime(),
+ dayKey: buildDayKey(cursor),
+ })
+ }
+ cursor.setMinutes(cursor.getMinutes() + 1, 0, 0)
+ }
+
+ return occurrences
+}
diff --git a/src/proxy.ts b/src/proxy.ts
index 44d14d9..dd051f3 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -52,6 +52,22 @@ function applySecurityHeaders(response: NextResponse): NextResponse {
return response
}
+function extractApiKeyFromRequest(request: NextRequest): string {
+ const direct = (request.headers.get('x-api-key') || '').trim()
+ if (direct) return direct
+
+ const authorization = (request.headers.get('authorization') || '').trim()
+ if (!authorization) return ''
+
+ const [scheme, ...rest] = authorization.split(/\s+/)
+ if (!scheme || rest.length === 0) return ''
+ const normalized = scheme.toLowerCase()
+ if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') {
+ return rest.join(' ').trim()
+ }
+ return ''
+}
+
export function proxy(request: NextRequest) {
// Network access control.
// In production: default-deny unless explicitly allowed.
@@ -63,7 +79,8 @@ export function proxy(request: NextRequest) {
.map((s) => s.trim())
.filter(Boolean)
- const isAllowedHost = allowAnyHost || allowedPatterns.some((p) => hostMatches(p, hostName))
+ const enforceAllowlist = !allowAnyHost && allowedPatterns.length > 0
+ const isAllowedHost = !enforceAllowlist || allowedPatterns.some((p) => hostMatches(p, hostName))
if (!isAllowedHost) {
return new NextResponse('Forbidden', { status: 403 })
@@ -97,8 +114,10 @@ export function proxy(request: NextRequest) {
// API routes: accept session cookie OR API key
if (pathname.startsWith('/api/')) {
- const apiKey = request.headers.get('x-api-key')
- if (sessionToken || (apiKey && safeCompare(apiKey, process.env.API_KEY || ''))) {
+ const configuredApiKey = (process.env.API_KEY || '').trim()
+ const apiKey = extractApiKeyFromRequest(request)
+ const hasValidApiKey = Boolean(configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey))
+ if (sessionToken || hasValidApiKey) {
return applySecurityHeaders(NextResponse.next())
}