Nyk commited on
Commit
250a974
·
1 Parent(s): e7f2128

fix: resolve cron calendar and auth regressions from open issues

Browse files
src/components/panels/agent-detail-tabs.tsx CHANGED
@@ -510,7 +510,12 @@ export function MemoryTab({
510
  return (
511
  <div className="p-6 space-y-4">
512
  <div className="flex justify-between items-center">
513
- <h4 className="text-lg font-medium text-foreground">Working Memory</h4>
 
 
 
 
 
514
  <div className="flex gap-2">
515
  {!editing && (
516
  <>
 
510
  return (
511
  <div className="p-6 space-y-4">
512
  <div className="flex justify-between items-center">
513
+ <div>
514
+ <h4 className="text-lg font-medium text-foreground">Working Memory</h4>
515
+ <p className="text-xs text-muted-foreground mt-1">
516
+ Agent-level scratchpad only. Use the global Memory page to browse all workspace memory files.
517
+ </p>
518
+ </div>
519
  <div className="flex gap-2">
520
  {!editing && (
521
  <>
src/components/panels/cron-management-panel.tsx CHANGED
@@ -1,7 +1,8 @@
1
  'use client'
2
 
3
- import { useState, useEffect, useCallback } from 'react'
4
  import { useMissionControl, CronJob } from '@/store'
 
5
 
6
  interface NewJobForm {
7
  name: string
@@ -56,6 +57,7 @@ export function CronManagementPanel() {
56
  const [availableModels, setAvailableModels] = useState<string[]>([])
57
  const [calendarView, setCalendarView] = useState<CalendarViewMode>('week')
58
  const [calendarDate, setCalendarDate] = useState<Date>(startOfDay(new Date()))
 
59
  const [searchQuery, setSearchQuery] = useState('')
60
  const [agentFilter, setAgentFilter] = useState('all')
61
  const [stateFilter, setStateFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
@@ -307,39 +309,69 @@ export function CronManagementPanel() {
307
  return matchesQuery && matchesAgent && matchesState
308
  })
309
 
310
- const agendaJobs = [...filteredJobs].sort((a, b) => {
311
- const aRun = typeof a.nextRun === 'number' ? a.nextRun : Number.POSITIVE_INFINITY
312
- const bRun = typeof b.nextRun === 'number' ? b.nextRun : Number.POSITIVE_INFINITY
313
- return aRun - bRun
314
- })
315
-
316
  const dayStart = startOfDay(calendarDate)
317
  const dayEnd = addDays(dayStart, 1)
318
- const dayJobs = filteredJobs
319
- .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= dayStart.getTime() && job.nextRun < dayEnd.getTime())
320
- .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
321
 
322
  const weekStart = getWeekStart(calendarDate)
323
  const weekDays = Array.from({ length: 7 }, (_, idx) => addDays(weekStart, idx))
324
- const jobsByWeekDay = weekDays.map((date) => {
325
- const start = startOfDay(date).getTime()
326
- const end = addDays(date, 1).getTime()
327
- const jobs = filteredJobs
328
- .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end)
329
- .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
330
- return { date, jobs }
331
- })
332
 
333
  const monthGridStart = getMonthStartGrid(calendarDate)
334
  const monthDays = Array.from({ length: 42 }, (_, idx) => addDays(monthGridStart, idx))
335
- const jobsByMonthDay = monthDays.map((date) => {
336
- const start = startOfDay(date).getTime()
337
- const end = addDays(date, 1).getTime()
338
- const jobs = filteredJobs
339
- .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end)
340
- .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
341
- return { date, jobs }
342
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
 
344
  const moveCalendar = (direction: -1 | 1) => {
345
  setCalendarDate((prev) => {
@@ -392,7 +424,7 @@ export function CronManagementPanel() {
392
  <div className="flex flex-wrap items-center justify-between gap-3">
393
  <div>
394
  <h2 className="text-xl font-semibold">Calendar View</h2>
395
- <p className="text-sm text-muted-foreground">Read-only schedule visibility across all cron jobs</p>
396
  </div>
397
  <div className="flex items-center gap-2">
398
  <button
@@ -466,23 +498,27 @@ export function CronManagementPanel() {
466
  {calendarView === 'agenda' && (
467
  <div className="border border-border rounded-lg overflow-hidden">
468
  <div className="max-h-80 overflow-y-auto divide-y divide-border">
469
- {agendaJobs.length === 0 ? (
470
  <div className="p-4 text-sm text-muted-foreground">No jobs match the current filters.</div>
471
  ) : (
472
- agendaJobs.map((job) => (
473
- <div key={`agenda-${job.id || job.name}`} className="p-3 flex flex-col md:flex-row md:items-center md:justify-between gap-2">
 
 
 
 
474
  <div>
475
- <div className="font-medium text-foreground">{job.name}</div>
476
  <div className="text-xs text-muted-foreground">
477
- {job.agentId || 'system'} · {job.enabled ? 'enabled' : 'disabled'} · {job.schedule}
478
  </div>
479
  </div>
480
  <div className="text-sm text-muted-foreground">
481
- {job.nextRun ? new Date(job.nextRun).toLocaleString() : 'No upcoming run'}
482
  </div>
483
- </div>
484
  ))
485
- )}
486
  </div>
487
  </div>
488
  )}
@@ -493,13 +529,17 @@ export function CronManagementPanel() {
493
  <div className="text-sm text-muted-foreground">No scheduled jobs for this day.</div>
494
  ) : (
495
  <div className="space-y-2">
496
- {dayJobs.map((job) => (
497
- <div key={`day-${job.id || job.name}`} className="p-2 rounded border border-border bg-secondary/40">
498
- <div className="text-sm font-medium text-foreground">{job.name}</div>
 
 
 
 
499
  <div className="text-xs text-muted-foreground">
500
- {job.nextRun ? new Date(job.nextRun).toLocaleTimeString() : 'Unknown time'} · {job.agentId || 'system'} · {job.enabled ? 'enabled' : 'disabled'}
501
  </div>
502
- </div>
503
  ))}
504
  </div>
505
  )}
@@ -509,21 +549,25 @@ export function CronManagementPanel() {
509
  {calendarView === 'week' && (
510
  <div className="grid grid-cols-1 md:grid-cols-7 gap-2">
511
  {jobsByWeekDay.map(({ date, jobs }) => (
512
- <div key={`week-${date.toISOString()}`} className="border border-border rounded-lg p-2 min-h-36">
 
 
 
 
513
  <div className={`text-xs font-medium mb-2 ${isSameDay(date, new Date()) ? 'text-primary' : 'text-muted-foreground'}`}>
514
  {date.toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' })}
515
  </div>
516
  <div className="space-y-1">
517
- {jobs.slice(0, 4).map((job) => (
518
- <div key={`week-job-${job.id || job.name}`} className="text-xs px-2 py-1 rounded bg-secondary text-foreground truncate" title={job.name}>
519
- {job.nextRun ? new Date(job.nextRun).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '--:--'} {job.name}
520
  </div>
521
  ))}
522
  {jobs.length > 4 && (
523
  <div className="text-xs text-muted-foreground">+{jobs.length - 4} more</div>
524
  )}
525
  </div>
526
- </div>
527
  ))}
528
  </div>
529
  )}
@@ -535,15 +579,16 @@ export function CronManagementPanel() {
535
  return (
536
  <div
537
  key={`month-${date.toISOString()}`}
538
- className={`border border-border rounded-lg p-2 min-h-24 ${inCurrentMonth ? 'bg-transparent' : 'bg-secondary/30'}`}
 
539
  >
540
  <div className={`text-xs mb-1 ${isSameDay(date, new Date()) ? 'text-primary font-semibold' : inCurrentMonth ? 'text-foreground' : 'text-muted-foreground'}`}>
541
  {date.getDate()}
542
  </div>
543
  <div className="space-y-1">
544
- {jobs.slice(0, 2).map((job) => (
545
- <div key={`month-job-${job.id || job.name}`} className="text-[11px] px-1.5 py-0.5 rounded bg-secondary text-foreground truncate" title={job.name}>
546
- {job.name}
547
  </div>
548
  ))}
549
  {jobs.length > 2 && <div className="text-[11px] text-muted-foreground">+{jobs.length - 2}</div>}
@@ -553,6 +598,35 @@ export function CronManagementPanel() {
553
  })}
554
  </div>
555
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  </div>
557
  </div>
558
 
 
1
  'use client'
2
 
3
+ import { useState, useEffect, useCallback, useMemo } from 'react'
4
  import { useMissionControl, CronJob } from '@/store'
5
+ import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences'
6
 
7
  interface NewJobForm {
8
  name: string
 
57
  const [availableModels, setAvailableModels] = useState<string[]>([])
58
  const [calendarView, setCalendarView] = useState<CalendarViewMode>('week')
59
  const [calendarDate, setCalendarDate] = useState<Date>(startOfDay(new Date()))
60
+ const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date>(startOfDay(new Date()))
61
  const [searchQuery, setSearchQuery] = useState('')
62
  const [agentFilter, setAgentFilter] = useState('all')
63
  const [stateFilter, setStateFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
 
309
  return matchesQuery && matchesAgent && matchesState
310
  })
311
 
 
 
 
 
 
 
312
  const dayStart = startOfDay(calendarDate)
313
  const dayEnd = addDays(dayStart, 1)
 
 
 
314
 
315
  const weekStart = getWeekStart(calendarDate)
316
  const weekDays = Array.from({ length: 7 }, (_, idx) => addDays(weekStart, idx))
 
 
 
 
 
 
 
 
317
 
318
  const monthGridStart = getMonthStartGrid(calendarDate)
319
  const monthDays = Array.from({ length: 42 }, (_, idx) => addDays(monthGridStart, idx))
320
+
321
+ const calendarBounds = useMemo(() => {
322
+ if (calendarView === 'day') {
323
+ return { startMs: dayStart.getTime(), endMs: dayEnd.getTime() }
324
+ }
325
+ if (calendarView === 'week') {
326
+ return { startMs: weekStart.getTime(), endMs: addDays(weekStart, 7).getTime() }
327
+ }
328
+ if (calendarView === 'month') {
329
+ return { startMs: monthGridStart.getTime(), endMs: addDays(monthGridStart, 42).getTime() }
330
+ }
331
+ const agendaStart = Date.now()
332
+ return { startMs: agendaStart, endMs: addDays(startOfDay(new Date()), 30).getTime() }
333
+ }, [calendarView, dayEnd, dayStart, monthGridStart, weekStart])
334
+
335
+ const calendarOccurrences = useMemo(() => {
336
+ const rows: Array<{ job: CronJob; atMs: number; dayKey: string }> = []
337
+ for (const job of filteredJobs) {
338
+ const occurrences = getCronOccurrences(job.schedule, calendarBounds.startMs, calendarBounds.endMs, 1000)
339
+ for (const occurrence of occurrences) {
340
+ rows.push({ job, atMs: occurrence.atMs, dayKey: occurrence.dayKey })
341
+ }
342
+
343
+ if (occurrences.length === 0 && typeof job.nextRun === 'number' && job.nextRun >= calendarBounds.startMs && job.nextRun < calendarBounds.endMs) {
344
+ rows.push({ job, atMs: job.nextRun, dayKey: buildDayKey(new Date(job.nextRun)) })
345
+ }
346
+ }
347
+
348
+ rows.sort((a, b) => a.atMs - b.atMs)
349
+ return rows
350
+ }, [calendarBounds.endMs, calendarBounds.startMs, filteredJobs])
351
+
352
+ const occurrencesByDay = useMemo(() => {
353
+ const dayMap = new Map<string, Array<{ job: CronJob; atMs: number }>>()
354
+ for (const row of calendarOccurrences) {
355
+ const existing = dayMap.get(row.dayKey) || []
356
+ existing.push({ job: row.job, atMs: row.atMs })
357
+ dayMap.set(row.dayKey, existing)
358
+ }
359
+ return dayMap
360
+ }, [calendarOccurrences])
361
+
362
+ const dayJobs = occurrencesByDay.get(buildDayKey(dayStart)) || []
363
+
364
+ const jobsByWeekDay = weekDays.map((date) => ({
365
+ date,
366
+ jobs: occurrencesByDay.get(buildDayKey(date)) || [],
367
+ }))
368
+
369
+ const jobsByMonthDay = monthDays.map((date) => ({
370
+ date,
371
+ jobs: occurrencesByDay.get(buildDayKey(date)) || [],
372
+ }))
373
+
374
+ const selectedDayJobs = occurrencesByDay.get(buildDayKey(selectedCalendarDate)) || []
375
 
376
  const moveCalendar = (direction: -1 | 1) => {
377
  setCalendarDate((prev) => {
 
424
  <div className="flex flex-wrap items-center justify-between gap-3">
425
  <div>
426
  <h2 className="text-xl font-semibold">Calendar View</h2>
427
+ <p className="text-sm text-muted-foreground">Interactive schedule across all matching cron jobs</p>
428
  </div>
429
  <div className="flex items-center gap-2">
430
  <button
 
498
  {calendarView === 'agenda' && (
499
  <div className="border border-border rounded-lg overflow-hidden">
500
  <div className="max-h-80 overflow-y-auto divide-y divide-border">
501
+ {calendarOccurrences.length === 0 ? (
502
  <div className="p-4 text-sm text-muted-foreground">No jobs match the current filters.</div>
503
  ) : (
504
+ calendarOccurrences.map((row) => (
505
+ <button
506
+ key={`agenda-${row.job.id || row.job.name}-${row.atMs}`}
507
+ onClick={() => handleJobSelect(row.job)}
508
+ className="w-full p-3 text-left flex flex-col md:flex-row md:items-center md:justify-between gap-2 hover:bg-secondary transition-colors"
509
+ >
510
  <div>
511
+ <div className="font-medium text-foreground">{row.job.name}</div>
512
  <div className="text-xs text-muted-foreground">
513
+ {row.job.agentId || 'system'} · {row.job.enabled ? 'enabled' : 'disabled'} · {row.job.schedule}
514
  </div>
515
  </div>
516
  <div className="text-sm text-muted-foreground">
517
+ {new Date(row.atMs).toLocaleString()}
518
  </div>
519
+ </button>
520
  ))
521
+ )}
522
  </div>
523
  </div>
524
  )}
 
529
  <div className="text-sm text-muted-foreground">No scheduled jobs for this day.</div>
530
  ) : (
531
  <div className="space-y-2">
532
+ {dayJobs.map((row) => (
533
+ <button
534
+ key={`day-${row.job.id || row.job.name}-${row.atMs}`}
535
+ onClick={() => handleJobSelect(row.job)}
536
+ className="w-full p-2 rounded border border-border bg-secondary/40 hover:bg-secondary transition-colors text-left"
537
+ >
538
+ <div className="text-sm font-medium text-foreground">{row.job.name}</div>
539
  <div className="text-xs text-muted-foreground">
540
+ {new Date(row.atMs).toLocaleTimeString()} · {row.job.agentId || 'system'} · {row.job.enabled ? 'enabled' : 'disabled'}
541
  </div>
542
+ </button>
543
  ))}
544
  </div>
545
  )}
 
549
  {calendarView === 'week' && (
550
  <div className="grid grid-cols-1 md:grid-cols-7 gap-2">
551
  {jobsByWeekDay.map(({ date, jobs }) => (
552
+ <button
553
+ key={`week-${date.toISOString()}`}
554
+ onClick={() => setSelectedCalendarDate(startOfDay(date))}
555
+ className={`border border-border rounded-lg p-2 min-h-36 text-left ${isSameDay(date, selectedCalendarDate) ? 'bg-primary/10 border-primary/40' : 'hover:bg-secondary/50'}`}
556
+ >
557
  <div className={`text-xs font-medium mb-2 ${isSameDay(date, new Date()) ? 'text-primary' : 'text-muted-foreground'}`}>
558
  {date.toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' })}
559
  </div>
560
  <div className="space-y-1">
561
+ {jobs.slice(0, 4).map((row) => (
562
+ <div key={`week-job-${row.job.id || row.job.name}-${row.atMs}`} className="text-xs px-2 py-1 rounded bg-secondary text-foreground truncate" title={row.job.name}>
563
+ {new Date(row.atMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} {row.job.name}
564
  </div>
565
  ))}
566
  {jobs.length > 4 && (
567
  <div className="text-xs text-muted-foreground">+{jobs.length - 4} more</div>
568
  )}
569
  </div>
570
+ </button>
571
  ))}
572
  </div>
573
  )}
 
579
  return (
580
  <div
581
  key={`month-${date.toISOString()}`}
582
+ onClick={() => setSelectedCalendarDate(startOfDay(date))}
583
+ className={`border border-border rounded-lg p-2 min-h-24 cursor-pointer ${inCurrentMonth ? 'bg-transparent' : 'bg-secondary/30'} ${isSameDay(date, selectedCalendarDate) ? 'border-primary/40 bg-primary/10' : 'hover:bg-secondary/50'}`}
584
  >
585
  <div className={`text-xs mb-1 ${isSameDay(date, new Date()) ? 'text-primary font-semibold' : inCurrentMonth ? 'text-foreground' : 'text-muted-foreground'}`}>
586
  {date.getDate()}
587
  </div>
588
  <div className="space-y-1">
589
+ {jobs.slice(0, 2).map((row) => (
590
+ <div key={`month-job-${row.job.id || row.job.name}-${row.atMs}`} className="text-[11px] px-1.5 py-0.5 rounded bg-secondary text-foreground truncate" title={row.job.name}>
591
+ {row.job.name}
592
  </div>
593
  ))}
594
  {jobs.length > 2 && <div className="text-[11px] text-muted-foreground">+{jobs.length - 2}</div>}
 
598
  })}
599
  </div>
600
  )}
601
+
602
+ {calendarView !== 'agenda' && (
603
+ <div className="border border-border rounded-lg p-3">
604
+ <div className="flex items-center justify-between mb-2">
605
+ <h3 className="text-sm font-medium text-foreground">
606
+ {selectedCalendarDate.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' })}
607
+ </h3>
608
+ <span className="text-xs text-muted-foreground">{selectedDayJobs.length} jobs</span>
609
+ </div>
610
+ {selectedDayJobs.length === 0 ? (
611
+ <div className="text-sm text-muted-foreground">No jobs scheduled on this date.</div>
612
+ ) : (
613
+ <div className="space-y-2">
614
+ {selectedDayJobs.map((row) => (
615
+ <button
616
+ key={`selected-day-${row.job.id || row.job.name}-${row.atMs}`}
617
+ onClick={() => handleJobSelect(row.job)}
618
+ className="w-full text-left p-2 rounded border border-border bg-secondary/40 hover:bg-secondary transition-colors"
619
+ >
620
+ <div className="text-sm font-medium text-foreground">{row.job.name}</div>
621
+ <div className="text-xs text-muted-foreground">
622
+ {new Date(row.atMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} · {row.job.agentId || 'system'}
623
+ </div>
624
+ </button>
625
+ ))}
626
+ </div>
627
+ )}
628
+ </div>
629
+ )}
630
  </div>
631
  </div>
632
 
src/components/panels/memory-browser-panel.tsx CHANGED
@@ -353,6 +353,9 @@ export function MemoryBrowserPanel() {
353
  ? 'Browse and manage local knowledge files and memory'
354
  : 'Explore knowledge files and memory structure'}
355
  </p>
 
 
 
356
 
357
  {/* Tab Navigation */}
358
  <div className="flex gap-2 mt-4">
 
353
  ? 'Browse and manage local knowledge files and memory'
354
  : 'Explore knowledge files and memory structure'}
355
  </p>
356
+ <p className="text-xs text-muted-foreground mt-1">
357
+ This page shows all workspace memory files. The agent profile Memory tab only edits that single agent&apos;s working memory.
358
+ </p>
359
 
360
  {/* Tab Navigation */}
361
  <div className="flex gap-2 mt-4">
src/components/panels/super-admin-panel.tsx CHANGED
@@ -461,6 +461,9 @@ export function SuperAdminPanel() {
461
  </button>
462
  {createExpanded && (
463
  <div className="p-4 space-y-3">
 
 
 
464
  {gatewayLoadError && (
465
  <div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20">
466
  Gateway list unavailable: {gatewayLoadError}. Using fallback owner value.
 
461
  </button>
462
  {createExpanded && (
463
  <div className="p-4 space-y-3">
464
+ <div className="text-xs text-muted-foreground">
465
+ Add a new workspace/client instance here. Fill the form below and click <span className="text-foreground font-medium">Create + Queue</span>.
466
+ </div>
467
  {gatewayLoadError && (
468
  <div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20">
469
  Gateway list unavailable: {gatewayLoadError}. Using fallback owner value.
src/lib/__tests__/auth.test.ts CHANGED
@@ -105,4 +105,23 @@ describe('requireRole', () => {
105
  )
106
  expect(result.user).toBeDefined()
107
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  })
 
105
  )
106
  expect(result.user).toBeDefined()
107
  })
108
+
109
+ it('accepts Authorization Bearer API key', () => {
110
+ const result = requireRole(
111
+ makeRequest({ authorization: 'Bearer test-api-key-secret' }),
112
+ 'admin',
113
+ )
114
+ expect(result.user).toBeDefined()
115
+ expect(result.user!.username).toBe('api')
116
+ })
117
+
118
+ it('rejects API key auth when API_KEY is not configured', () => {
119
+ process.env = { ...originalEnv, API_KEY: '' }
120
+ const result = requireRole(
121
+ makeRequest({ 'x-api-key': 'test-api-key-secret' }),
122
+ 'viewer',
123
+ )
124
+ expect(result.status).toBe(401)
125
+ expect(result.user).toBeUndefined()
126
+ })
127
  })
src/lib/__tests__/cron-occurrences.test.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences'
3
+
4
+ describe('buildDayKey', () => {
5
+ it('formats YYYY-MM-DD in local time', () => {
6
+ const date = new Date(2026, 2, 4, 9, 15, 0, 0)
7
+ expect(buildDayKey(date)).toBe('2026-03-04')
8
+ })
9
+ })
10
+
11
+ describe('getCronOccurrences', () => {
12
+ it('expands daily schedule across range', () => {
13
+ const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
14
+ const end = new Date(2026, 2, 5, 0, 0, 0, 0).getTime()
15
+ const rows = getCronOccurrences('0 0 * * *', start, end)
16
+ expect(rows).toHaveLength(4)
17
+ expect(rows.map((r) => r.dayKey)).toEqual([
18
+ '2026-03-01',
19
+ '2026-03-02',
20
+ '2026-03-03',
21
+ '2026-03-04',
22
+ ])
23
+ })
24
+
25
+ it('supports step values', () => {
26
+ const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
27
+ const end = new Date(2026, 2, 1, 3, 0, 0, 0).getTime()
28
+ const rows = getCronOccurrences('*/30 * * * *', start, end)
29
+ expect(rows).toHaveLength(6)
30
+ })
31
+
32
+ it('ignores OpenClaw timezone suffix in display schedule', () => {
33
+ const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
34
+ const end = new Date(2026, 2, 2, 0, 0, 0, 0).getTime()
35
+ const rows = getCronOccurrences('0 6 * * * (UTC)', start, end)
36
+ expect(rows).toHaveLength(1)
37
+ })
38
+
39
+ it('returns empty list for invalid cron', () => {
40
+ const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
41
+ const end = new Date(2026, 2, 2, 0, 0, 0, 0).getTime()
42
+ expect(getCronOccurrences('invalid', start, end)).toEqual([])
43
+ })
44
+ })
src/lib/auth.ts CHANGED
@@ -277,8 +277,9 @@ export function getUserFromRequest(request: Request): User | null {
277
  }
278
 
279
  // Check API key - return synthetic user
280
- const apiKey = request.headers.get('x-api-key')
281
- if (apiKey && safeCompare(apiKey, process.env.API_KEY || '')) {
 
282
  return {
283
  id: 0,
284
  username: 'api',
@@ -294,6 +295,24 @@ export function getUserFromRequest(request: Request): User | null {
294
  return null
295
  }
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  /**
298
  * Role hierarchy levels for access control.
299
  * viewer < operator < admin
 
277
  }
278
 
279
  // Check API key - return synthetic user
280
+ const configuredApiKey = (process.env.API_KEY || '').trim()
281
+ const apiKey = extractApiKeyFromHeaders(request.headers)
282
+ if (configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) {
283
  return {
284
  id: 0,
285
  username: 'api',
 
295
  return null
296
  }
297
 
298
+ function extractApiKeyFromHeaders(headers: Headers): string | null {
299
+ const direct = (headers.get('x-api-key') || '').trim()
300
+ if (direct) return direct
301
+
302
+ const authorization = (headers.get('authorization') || '').trim()
303
+ if (!authorization) return null
304
+
305
+ const [scheme, ...rest] = authorization.split(/\s+/)
306
+ if (!scheme || rest.length === 0) return null
307
+
308
+ const normalized = scheme.toLowerCase()
309
+ if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') {
310
+ return rest.join(' ').trim() || null
311
+ }
312
+
313
+ return null
314
+ }
315
+
316
  /**
317
  * Role hierarchy levels for access control.
318
  * viewer < operator < admin
src/lib/cron-occurrences.ts ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface CronOccurrence {
2
+ atMs: number
3
+ dayKey: string
4
+ }
5
+
6
+ interface ParsedField {
7
+ any: boolean
8
+ matches: (value: number) => boolean
9
+ }
10
+
11
+ interface ParsedCron {
12
+ minute: ParsedField
13
+ hour: ParsedField
14
+ dayOfMonth: ParsedField
15
+ month: ParsedField
16
+ dayOfWeek: ParsedField
17
+ }
18
+
19
+ function normalizeCronExpression(raw: string): string {
20
+ const trimmed = raw.trim()
21
+ const tzSuffixMatch = trimmed.match(/^(.*)\s+\([^)]+\)$/)
22
+ return (tzSuffixMatch?.[1] || trimmed).trim()
23
+ }
24
+
25
+ function parseToken(token: string, min: number, max: number): { any: boolean; values: Set<number> } {
26
+ const valueSet = new Set<number>()
27
+ const trimmed = token.trim()
28
+ if (trimmed === '*') {
29
+ for (let i = min; i <= max; i += 1) valueSet.add(i)
30
+ return { any: true, values: valueSet }
31
+ }
32
+
33
+ for (const part of trimmed.split(',')) {
34
+ const section = part.trim()
35
+ if (!section) continue
36
+
37
+ const [rangePart, stepPart] = section.split('/')
38
+ const step = stepPart ? Number(stepPart) : 1
39
+ if (!Number.isFinite(step) || step <= 0) continue
40
+
41
+ if (rangePart === '*') {
42
+ for (let i = min; i <= max; i += step) valueSet.add(i)
43
+ continue
44
+ }
45
+
46
+ if (rangePart.includes('-')) {
47
+ const [fromRaw, toRaw] = rangePart.split('-')
48
+ const from = Number(fromRaw)
49
+ const to = Number(toRaw)
50
+ if (!Number.isFinite(from) || !Number.isFinite(to)) continue
51
+ const start = Math.max(min, Math.min(max, from))
52
+ const end = Math.max(min, Math.min(max, to))
53
+ for (let i = start; i <= end; i += step) valueSet.add(i)
54
+ continue
55
+ }
56
+
57
+ const single = Number(rangePart)
58
+ if (!Number.isFinite(single)) continue
59
+ if (single >= min && single <= max) valueSet.add(single)
60
+ }
61
+
62
+ return { any: false, values: valueSet }
63
+ }
64
+
65
+ function parseField(token: string, min: number, max: number): ParsedField {
66
+ const parsed = parseToken(token, min, max)
67
+ return {
68
+ any: parsed.any,
69
+ matches: (value: number) => parsed.values.has(value),
70
+ }
71
+ }
72
+
73
+ function parseCron(raw: string): ParsedCron | null {
74
+ const normalized = normalizeCronExpression(raw)
75
+ const parts = normalized.split(/\s+/).filter(Boolean)
76
+ if (parts.length !== 5) return null
77
+
78
+ return {
79
+ minute: parseField(parts[0], 0, 59),
80
+ hour: parseField(parts[1], 0, 23),
81
+ dayOfMonth: parseField(parts[2], 1, 31),
82
+ month: parseField(parts[3], 1, 12),
83
+ dayOfWeek: parseField(parts[4], 0, 6),
84
+ }
85
+ }
86
+
87
+ function matchesDay(parsed: ParsedCron, date: Date): boolean {
88
+ const dayOfMonthMatches = parsed.dayOfMonth.matches(date.getDate())
89
+ const dayOfWeekMatches = parsed.dayOfWeek.matches(date.getDay())
90
+
91
+ if (parsed.dayOfMonth.any && parsed.dayOfWeek.any) return true
92
+ if (parsed.dayOfMonth.any) return dayOfWeekMatches
93
+ if (parsed.dayOfWeek.any) return dayOfMonthMatches
94
+ return dayOfMonthMatches || dayOfWeekMatches
95
+ }
96
+
97
+ export function buildDayKey(date: Date): string {
98
+ const year = date.getFullYear()
99
+ const month = String(date.getMonth() + 1).padStart(2, '0')
100
+ const day = String(date.getDate()).padStart(2, '0')
101
+ return `${year}-${month}-${day}`
102
+ }
103
+
104
+ export function getCronOccurrences(
105
+ schedule: string,
106
+ rangeStartMs: number,
107
+ rangeEndMs: number,
108
+ max = 1000
109
+ ): CronOccurrence[] {
110
+ if (!schedule || !Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs)) return []
111
+ if (rangeEndMs <= rangeStartMs || max <= 0) return []
112
+
113
+ const parsed = parseCron(schedule)
114
+ if (!parsed) return []
115
+
116
+ const occurrences: CronOccurrence[] = []
117
+ const cursor = new Date(rangeStartMs)
118
+ cursor.setSeconds(0, 0)
119
+ if (cursor.getTime() < rangeStartMs) {
120
+ cursor.setMinutes(cursor.getMinutes() + 1, 0, 0)
121
+ }
122
+
123
+ while (cursor.getTime() < rangeEndMs && occurrences.length < max) {
124
+ if (
125
+ parsed.month.matches(cursor.getMonth() + 1) &&
126
+ matchesDay(parsed, cursor) &&
127
+ parsed.hour.matches(cursor.getHours()) &&
128
+ parsed.minute.matches(cursor.getMinutes())
129
+ ) {
130
+ occurrences.push({
131
+ atMs: cursor.getTime(),
132
+ dayKey: buildDayKey(cursor),
133
+ })
134
+ }
135
+ cursor.setMinutes(cursor.getMinutes() + 1, 0, 0)
136
+ }
137
+
138
+ return occurrences
139
+ }
src/proxy.ts CHANGED
@@ -52,6 +52,22 @@ function applySecurityHeaders(response: NextResponse): NextResponse {
52
  return response
53
  }
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  export function proxy(request: NextRequest) {
56
  // Network access control.
57
  // In production: default-deny unless explicitly allowed.
@@ -63,7 +79,8 @@ export function proxy(request: NextRequest) {
63
  .map((s) => s.trim())
64
  .filter(Boolean)
65
 
66
- const isAllowedHost = allowAnyHost || allowedPatterns.some((p) => hostMatches(p, hostName))
 
67
 
68
  if (!isAllowedHost) {
69
  return new NextResponse('Forbidden', { status: 403 })
@@ -97,8 +114,10 @@ export function proxy(request: NextRequest) {
97
 
98
  // API routes: accept session cookie OR API key
99
  if (pathname.startsWith('/api/')) {
100
- const apiKey = request.headers.get('x-api-key')
101
- if (sessionToken || (apiKey && safeCompare(apiKey, process.env.API_KEY || ''))) {
 
 
102
  return applySecurityHeaders(NextResponse.next())
103
  }
104
 
 
52
  return response
53
  }
54
 
55
+ function extractApiKeyFromRequest(request: NextRequest): string {
56
+ const direct = (request.headers.get('x-api-key') || '').trim()
57
+ if (direct) return direct
58
+
59
+ const authorization = (request.headers.get('authorization') || '').trim()
60
+ if (!authorization) return ''
61
+
62
+ const [scheme, ...rest] = authorization.split(/\s+/)
63
+ if (!scheme || rest.length === 0) return ''
64
+ const normalized = scheme.toLowerCase()
65
+ if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') {
66
+ return rest.join(' ').trim()
67
+ }
68
+ return ''
69
+ }
70
+
71
  export function proxy(request: NextRequest) {
72
  // Network access control.
73
  // In production: default-deny unless explicitly allowed.
 
79
  .map((s) => s.trim())
80
  .filter(Boolean)
81
 
82
+ const enforceAllowlist = !allowAnyHost && allowedPatterns.length > 0
83
+ const isAllowedHost = !enforceAllowlist || allowedPatterns.some((p) => hostMatches(p, hostName))
84
 
85
  if (!isAllowedHost) {
86
  return new NextResponse('Forbidden', { status: 403 })
 
114
 
115
  // API routes: accept session cookie OR API key
116
  if (pathname.startsWith('/api/')) {
117
+ const configuredApiKey = (process.env.API_KEY || '').trim()
118
+ const apiKey = extractApiKeyFromRequest(request)
119
+ const hasValidApiKey = Boolean(configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey))
120
+ if (sessionToken || hasValidApiKey) {
121
  return applySecurityHeaders(NextResponse.next())
122
  }
123