@andypai/agent-kanban 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +42 -19
  2. package/package.json +3 -2
  3. package/src/__tests__/activity.test.ts +12 -0
  4. package/src/__tests__/api.test.ts +4 -2
  5. package/src/__tests__/board-utils.test.ts +16 -3
  6. package/src/__tests__/column-roles.test.ts +34 -0
  7. package/src/__tests__/commands/board.test.ts +7 -0
  8. package/src/__tests__/conflict.test.ts +11 -1
  9. package/src/__tests__/db.test.ts +70 -0
  10. package/src/__tests__/index.test.ts +55 -0
  11. package/src/__tests__/jira-cache.test.ts +56 -0
  12. package/src/__tests__/jira-jql.test.ts +51 -0
  13. package/src/__tests__/jira-provider-read.test.ts +50 -0
  14. package/src/__tests__/mcp-server.test.ts +2 -2
  15. package/src/__tests__/metrics.test.ts +37 -1
  16. package/src/__tests__/postgres-local-provider.test.ts +90 -1
  17. package/src/__tests__/server.test.ts +126 -0
  18. package/src/__tests__/use-cases.test.ts +77 -0
  19. package/src/__tests__/webhooks.test.ts +91 -22
  20. package/src/api.ts +58 -36
  21. package/src/column-roles.ts +52 -0
  22. package/src/commands/board.ts +4 -4
  23. package/src/db.ts +145 -114
  24. package/src/errors.ts +1 -0
  25. package/src/index.ts +84 -33
  26. package/src/mcp/core.ts +8 -7
  27. package/src/metrics.ts +48 -23
  28. package/src/provider-runtime.ts +9 -2
  29. package/src/providers/index.ts +4 -1
  30. package/src/providers/jira-cache.ts +23 -6
  31. package/src/providers/jira-core.ts +926 -0
  32. package/src/providers/jira-jql.ts +48 -0
  33. package/src/providers/jira.ts +111 -759
  34. package/src/providers/linear-cache.ts +5 -3
  35. package/src/providers/linear-core.ts +693 -0
  36. package/src/providers/linear.ts +70 -554
  37. package/src/providers/postgres-jira-cache.ts +597 -0
  38. package/src/providers/postgres-jira.ts +15 -1312
  39. package/src/providers/postgres-linear-cache.ts +500 -0
  40. package/src/providers/postgres-linear.ts +22 -1088
  41. package/src/providers/postgres-local.ts +181 -74
  42. package/src/providers/types.ts +71 -2
  43. package/src/server.ts +118 -32
  44. package/src/use-cases.ts +139 -0
  45. package/src/webhooks.ts +19 -0
  46. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  47. package/ui/dist/index.html +1 -1
  48. package/ui/dist/assets/index-CFhtfqCn.js +0 -40
@@ -1,7 +1,8 @@
1
- import type { Sql } from 'postgres'
1
+ import type { Sql, TransactionSql } from 'postgres'
2
2
 
3
3
  import { ErrorCode, KanbanError } from '../errors'
4
4
  import { generateId } from '../id'
5
+ import { selectDoneColumnIds, selectInProgressColumnIds } from '../column-roles'
5
6
  import type {
6
7
  ActivityEntry,
7
8
  BoardBootstrap,
@@ -26,6 +27,10 @@ import type {
26
27
  import type { LocalTrackerConfig } from '../tracker-config'
27
28
  import { normalizeLabels, parseStoredLabels } from '../labels'
28
29
 
30
+ // Accepts either the root connection or a scoped transaction handle so the
31
+ // mutation helpers can run inside or outside a `sql.begin(...)` block.
32
+ type Exec = Sql | TransactionSql
33
+
29
34
  const DEFAULT_COLUMNS = [
30
35
  { name: 'recurring', position: 0 },
31
36
  { name: 'backlog', position: 1 },
@@ -269,8 +274,9 @@ export class PostgresLocalProvider implements KanbanProvider {
269
274
  fieldChanged: string | null,
270
275
  oldValue: string | null,
271
276
  newValue: string | null,
277
+ exec: Exec = this.sql,
272
278
  ): Promise<void> {
273
- await this.sql`
279
+ await exec`
274
280
  INSERT INTO activity_log (id, task_id, action, field_changed, old_value, new_value, timestamp)
275
281
  VALUES (${generateId('a')}, ${taskId}, ${action}, ${fieldChanged}, ${oldValue}, ${newValue}, ${nowIso()})
276
282
  `
@@ -360,23 +366,26 @@ export class PostgresLocalProvider implements KanbanProvider {
360
366
  const column = input.column
361
367
  ? await this.resolveColumn(input.column)
362
368
  : await this.resolveDefaultTaskColumn()
363
- const [positionRow] = await this.sql<{ next: string | number }[]>`
364
- SELECT COALESCE(MAX(position), -1) + 1 AS next FROM tasks WHERE column_id = ${column.id}
365
- `
366
369
  const id = generateId('t')
367
370
  const timestamp = nowIso()
368
- await this.sql`
369
- INSERT INTO tasks (
370
- id, title, description, column_id, position, priority, assignee, project, labels, metadata,
371
- revision, created_at, updated_at
372
- )
373
- VALUES (
374
- ${id}, ${input.title}, ${input.description ?? ''}, ${column.id}, ${Number(positionRow?.next ?? 0)},
375
- ${priority}, ${input.assignee ?? ''}, ${input.project ?? ''}, ${JSON.stringify(labels)}, ${metadata},
376
- 0, ${timestamp}, ${timestamp}
377
- )
378
- `
379
- await this.insertActivity(id, 'created', null, null, input.title)
371
+ await this.sql.begin(async (tx) => {
372
+ const [positionRow] = await tx<{ next: string | number }[]>`
373
+ SELECT COALESCE(MAX(position), -1) + 1 AS next FROM tasks WHERE column_id = ${column.id}
374
+ `
375
+ await tx`
376
+ INSERT INTO tasks (
377
+ id, title, description, column_id, position, priority, assignee, project, labels, metadata,
378
+ revision, created_at, updated_at
379
+ )
380
+ VALUES (
381
+ ${id}, ${input.title}, ${input.description ?? ''}, ${column.id}, ${Number(positionRow?.next ?? 0)},
382
+ ${priority}, ${input.assignee ?? ''}, ${input.project ?? ''}, ${JSON.stringify(labels)}, ${metadata},
383
+ 0, ${timestamp}, ${timestamp}
384
+ )
385
+ `
386
+ await this.insertActivity(id, 'created', null, null, input.title, tx)
387
+ await this.enterColumn(id, column.id, tx)
388
+ })
380
389
  return this.getTask(id)
381
390
  }
382
391
 
@@ -403,39 +412,43 @@ export class PostgresLocalProvider implements KanbanProvider {
403
412
  project: input.project ?? current.project,
404
413
  metadata: metadata ?? current.metadata,
405
414
  }
406
- await this.sql`
407
- UPDATE tasks
408
- SET title = ${next.title},
409
- description = ${next.description},
410
- priority = ${next.priority},
411
- assignee = ${next.assignee},
412
- project = ${next.project},
413
- metadata = ${next.metadata},
414
- revision = revision + 1,
415
- updated_at = ${nowIso()}
416
- WHERE id = ${current.id}
417
- `
418
- if (input.title !== undefined && input.title !== current.title) {
419
- await this.insertActivity(current.id, 'updated', 'title', current.title, input.title)
420
- }
421
- if (input.assignee !== undefined && input.assignee !== current.assignee) {
422
- await this.insertActivity(
423
- current.id,
424
- 'assigned',
425
- 'assignee',
426
- current.assignee,
427
- input.assignee,
428
- )
429
- }
430
- if (input.priority !== undefined && input.priority !== current.priority) {
431
- await this.insertActivity(
432
- current.id,
433
- 'prioritized',
434
- 'priority',
435
- current.priority,
436
- input.priority,
437
- )
438
- }
415
+ await this.sql.begin(async (tx) => {
416
+ await tx`
417
+ UPDATE tasks
418
+ SET title = ${next.title},
419
+ description = ${next.description},
420
+ priority = ${next.priority},
421
+ assignee = ${next.assignee},
422
+ project = ${next.project},
423
+ metadata = ${next.metadata},
424
+ revision = revision + 1,
425
+ updated_at = ${nowIso()}
426
+ WHERE id = ${current.id}
427
+ `
428
+ if (input.title !== undefined && input.title !== current.title) {
429
+ await this.insertActivity(current.id, 'updated', 'title', current.title, input.title, tx)
430
+ }
431
+ if (input.assignee !== undefined && input.assignee !== current.assignee) {
432
+ await this.insertActivity(
433
+ current.id,
434
+ 'assigned',
435
+ 'assignee',
436
+ current.assignee,
437
+ input.assignee,
438
+ tx,
439
+ )
440
+ }
441
+ if (input.priority !== undefined && input.priority !== current.priority) {
442
+ await this.insertActivity(
443
+ current.id,
444
+ 'prioritized',
445
+ 'priority',
446
+ current.priority,
447
+ input.priority,
448
+ tx,
449
+ )
450
+ }
451
+ })
439
452
  return this.getTask(current.id)
440
453
  }
441
454
 
@@ -443,23 +456,48 @@ export class PostgresLocalProvider implements KanbanProvider {
443
456
  await this.ready
444
457
  const current = await this.requireTask(idOrRef)
445
458
  const column = await this.resolveColumn(columnName)
446
- const oldColumn = current.column_name ?? current.column_id
447
- await this.sql`
448
- UPDATE tasks
449
- SET column_id = ${column.id},
450
- revision = revision + 1,
451
- updated_at = ${nowIso()}
452
- WHERE id = ${current.id}
453
- `
454
- await this.insertActivity(current.id, 'moved', 'column', oldColumn, column.name)
459
+ const oldColumnId = current.column_id
460
+
461
+ // No-op move to the same column: skip the write so we don't fragment
462
+ // column-time tracking (spurious exit/enter) or re-append the task.
463
+ if (column.id === oldColumnId) return this.getTask(current.id)
464
+
465
+ await this.sql.begin(async (tx) => {
466
+ const [posRow] = await tx<{ next: string | number }[]>`
467
+ SELECT COALESCE(MAX(position), -1) + 1 AS next FROM tasks WHERE column_id = ${column.id}
468
+ `
469
+ await tx`
470
+ UPDATE tasks
471
+ SET column_id = ${column.id},
472
+ position = ${Number(posRow?.next ?? 0)},
473
+ revision = revision + 1,
474
+ updated_at = ${nowIso()}
475
+ WHERE id = ${current.id}
476
+ `
477
+ await this.renumberColumn(oldColumnId, tx)
478
+ await this.exitColumn(current.id, oldColumnId, tx)
479
+ await this.enterColumn(current.id, column.id, tx)
480
+ await this.insertActivity(
481
+ current.id,
482
+ 'moved',
483
+ 'column',
484
+ current.column_name ?? oldColumnId,
485
+ column.name,
486
+ tx,
487
+ )
488
+ })
455
489
  return this.getTask(current.id)
456
490
  }
457
491
 
458
492
  async deleteTask(idOrRef: string): Promise<Task> {
459
493
  await this.ready
460
494
  const task = await this.getTask(idOrRef)
461
- await this.sql`DELETE FROM tasks WHERE id = ${task.id}`
462
- await this.insertActivity(task.id, 'deleted', null, task.title, null)
495
+ await this.sql.begin(async (tx) => {
496
+ await this.exitColumn(task.id, task.column_id, tx)
497
+ await this.insertActivity(task.id, 'deleted', null, task.title, null, tx)
498
+ await tx`DELETE FROM tasks WHERE id = ${task.id}`
499
+ await this.renumberColumn(task.column_id, tx)
500
+ })
463
501
  return task
464
502
  }
465
503
 
@@ -529,14 +567,20 @@ export class PostgresLocalProvider implements KanbanProvider {
529
567
  const [total] = await this.sql<
530
568
  { count: string | number }[]
531
569
  >`SELECT COUNT(*) AS count FROM tasks`
532
- const [doneColumn] = await this.sql<{ id: string }[]>`
533
- SELECT id FROM columns WHERE LOWER(name) = 'done' LIMIT 1
570
+ // Classify columns by role (custom names / KANBAN_DEFAULT_COLUMNS) instead of
571
+ // matching the literal 'done' / 'in-progress'. Kept in lockstep with the
572
+ // SQLite copy in metrics.ts:getBoardMetrics.
573
+ const columns = await this.sql<{ id: string; name: string; position: number }[]>`
574
+ SELECT id, name, position FROM columns ORDER BY position
534
575
  `
535
- const [completed] = doneColumn
536
- ? await this.sql<{ count: string | number }[]>`
537
- SELECT COUNT(*) AS count FROM tasks WHERE column_id = ${doneColumn.id}
576
+ const doneColumnIds = selectDoneColumnIds(columns)
577
+ const inProgressColumnIds = selectInProgressColumnIds(columns)
578
+ const [completed] =
579
+ doneColumnIds.length > 0
580
+ ? await this.sql<{ count: string | number }[]>`
581
+ SELECT COUNT(*) AS count FROM tasks WHERE column_id IN ${this.sql(doneColumnIds)}
538
582
  `
539
- : [{ count: 0 }]
583
+ : [{ count: 0 }]
540
584
  const tasksByColumn = await this.sql<{ column_name: string; count: string | number }[]>`
541
585
  SELECT columns.name AS column_name, COUNT(tasks.id) AS count
542
586
  FROM columns
@@ -551,6 +595,39 @@ export class PostgresLocalProvider implements KanbanProvider {
551
595
  const projects = await this.discoveredProjects()
552
596
  const totalTasks = Number(total?.count ?? 0)
553
597
  const completedTasks = Number(completed?.count ?? 0)
598
+ // Average completion time = first tracked entry (creation) -> first time the
599
+ // task entered Done. Using the Done *entry* (not exit) counts tasks resting
600
+ // in Done; MIN() handles tasks that re-enter Done. Cast to timestamptz so the
601
+ // ISO-UTC strings compare correctly regardless of the server timezone. Kept
602
+ // in lockstep with the SQLite copy in metrics.ts:getBoardMetrics.
603
+ const [avgResult] =
604
+ doneColumnIds.length > 0
605
+ ? await this.sql<{ avg_hours: string | number | null }[]>`
606
+ SELECT AVG(
607
+ EXTRACT(EPOCH FROM (done_enter.entered_at - first_enter.entered_at)) / 3600
608
+ ) AS avg_hours
609
+ FROM (
610
+ SELECT ct.task_id, MIN(ct.entered_at::timestamptz) AS entered_at
611
+ FROM column_time_tracking ct
612
+ WHERE ct.column_id IN ${this.sql(doneColumnIds)}
613
+ GROUP BY ct.task_id
614
+ ) done_enter
615
+ JOIN (
616
+ SELECT task_id, MIN(entered_at::timestamptz) AS entered_at
617
+ FROM column_time_tracking GROUP BY task_id
618
+ ) first_enter ON first_enter.task_id = done_enter.task_id
619
+ `
620
+ : [{ avg_hours: null }]
621
+ const [weekCount] = await this.sql<{ count: string | number }[]>`
622
+ SELECT COUNT(*) AS count FROM tasks
623
+ WHERE created_at::timestamptz >= NOW() - INTERVAL '7 days'
624
+ `
625
+ const inProgressNames = new Set(
626
+ columns.filter((c) => inProgressColumnIds.includes(c.id)).map((c) => c.name),
627
+ )
628
+ const inProgressCount = tasksByColumn
629
+ .filter((row) => inProgressNames.has(row.column_name))
630
+ .reduce((sum, row) => sum + Number(row.count), 0)
554
631
  return {
555
632
  tasksByColumn: tasksByColumn.map((row) => ({
556
633
  column_name: row.column_name,
@@ -562,13 +639,10 @@ export class PostgresLocalProvider implements KanbanProvider {
562
639
  })),
563
640
  totalTasks,
564
641
  completedTasks,
565
- avgCompletionHours: null,
566
- recentActivity: await this.getActivity(10),
567
- tasksCreatedThisWeek: 0,
568
- inProgressCount:
569
- tasksByColumn.find((row) => row.column_name === 'in-progress')?.count === undefined
570
- ? 0
571
- : Number(tasksByColumn.find((row) => row.column_name === 'in-progress')!.count),
642
+ avgCompletionHours: avgResult?.avg_hours != null ? Number(avgResult.avg_hours) : null,
643
+ recentActivity: await this.getActivity(20),
644
+ tasksCreatedThisWeek: Number(weekCount?.count ?? 0),
645
+ inProgressCount,
572
646
  completionPercent: totalTasks === 0 ? 0 : Math.round((completedTasks / totalTasks) * 100),
573
647
  assignees,
574
648
  projects,
@@ -589,6 +663,39 @@ export class PostgresLocalProvider implements KanbanProvider {
589
663
  return rows.map((row) => row.project)
590
664
  }
591
665
 
666
+ private async enterColumn(
667
+ taskId: string,
668
+ columnId: string,
669
+ exec: Exec = this.sql,
670
+ ): Promise<void> {
671
+ const id = generateId('ct')
672
+ await exec`
673
+ INSERT INTO column_time_tracking (id, task_id, column_id, entered_at)
674
+ VALUES (${id}, ${taskId}, ${columnId}, ${nowIso()})
675
+ `
676
+ }
677
+
678
+ private async exitColumn(taskId: string, columnId: string, exec: Exec = this.sql): Promise<void> {
679
+ await exec`
680
+ UPDATE column_time_tracking
681
+ SET exited_at = ${nowIso()}
682
+ WHERE task_id = ${taskId} AND column_id = ${columnId} AND exited_at IS NULL
683
+ `
684
+ }
685
+
686
+ // Compact positions to a contiguous 0..n-1 sequence in a single statement.
687
+ private async renumberColumn(columnId: string, exec: Exec = this.sql): Promise<void> {
688
+ await exec`
689
+ UPDATE tasks t
690
+ SET position = s.rn
691
+ FROM (
692
+ SELECT id, ROW_NUMBER() OVER (ORDER BY position, created_at, id) - 1 AS rn
693
+ FROM tasks WHERE column_id = ${columnId}
694
+ ) s
695
+ WHERE t.id = s.id AND t.position <> s.rn
696
+ `
697
+ }
698
+
592
699
  async getConfig(): Promise<BoardConfig> {
593
700
  await this.ready
594
701
  return {
@@ -55,28 +55,97 @@ export interface ProviderSyncStatus {
55
55
  lastWebhookAt: string | null
56
56
  }
57
57
 
58
- export interface KanbanProvider {
58
+ /**
59
+ * The provider contract is composed from small, cohesive capability interfaces
60
+ * rather than one monolithic type. `KanbanProvider` is their intersection, so
61
+ * every existing consumer keeps the same surface; the split exists to document
62
+ * which operations belong together and to let internal code depend on just the
63
+ * capability it needs.
64
+ *
65
+ * Note on "unsupported" operations: providers that cannot perform a given
66
+ * operation (e.g. Jira/Linear `deleteTask`, `getMetrics`, `patchConfig`) still
67
+ * implement the method but throw `UNSUPPORTED_OPERATION` at runtime, and the
68
+ * UI hides those affordances via `ProviderCapabilities`. These members are
69
+ * therefore required on the contract — only the cache/webhook members below,
70
+ * which a provider may genuinely not have, are optional.
71
+ */
72
+
73
+ /** Provider self-identification. */
74
+ export interface ProviderIdentity {
59
75
  readonly type: 'local' | 'linear' | 'jira'
76
+ }
60
77
 
78
+ /** Board/context/config reads that materialize the current board view. */
79
+ export interface BoardReader {
61
80
  getContext(): Promise<ProviderContext>
62
81
  getBootstrap(): Promise<BoardBootstrap>
63
82
  getBoard(): Promise<BoardView>
64
83
  listColumns(): Promise<Column[]>
84
+ getConfig(): Promise<BoardConfig>
85
+ }
86
+
87
+ /** Task reads. */
88
+ export interface TaskReader {
65
89
  listTasks(filters?: TaskListFilters): Promise<Task[]>
66
90
  getTask(idOrRef: string): Promise<Task>
91
+ }
92
+
93
+ /** Task mutations. */
94
+ export interface TaskWriter {
67
95
  createTask(input: CreateTaskInput): Promise<Task>
68
96
  updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task>
69
97
  moveTask(idOrRef: string, column: string): Promise<Task>
70
98
  deleteTask(idOrRef: string): Promise<Task>
99
+ }
100
+
101
+ /** Comment reads and writes. */
102
+ export interface CommentCapability {
71
103
  listComments(idOrRef: string): Promise<TaskComment[]>
72
104
  getComment(idOrRef: string, commentId: string): Promise<TaskComment>
73
105
  comment(idOrRef: string, body: string): Promise<TaskComment>
74
106
  updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment>
107
+ }
108
+
109
+ /** Activity-feed reads. */
110
+ export interface ActivityReader {
75
111
  getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]>
112
+ }
113
+
114
+ /** Board-metrics reads. */
115
+ export interface MetricsReader {
76
116
  getMetrics(): Promise<BoardMetrics>
77
- getConfig(): Promise<BoardConfig>
117
+ }
118
+
119
+ /** Board-config mutations. */
120
+ export interface ConfigWriter {
78
121
  patchConfig(input: Partial<BoardConfig>): Promise<BoardConfig>
122
+ }
123
+
124
+ /**
125
+ * Optional cache-management hooks used by the server's background warmer. Only
126
+ * providers backed by a cache (Linear/Jira) implement these; the local
127
+ * provider omits them and the server guards every call.
128
+ */
129
+ export interface CacheManaged {
79
130
  syncCache?(): Promise<void>
131
+ // Signals that a background warmer owns cache refresh (server mode), so the
132
+ // provider may serve reads from the warm cache without a blocking foreground sync.
133
+ setBackgroundManaged?(managed: boolean): void
80
134
  getSyncStatus?(): Promise<ProviderSyncStatus | null>
135
+ }
136
+
137
+ /** Optional inbound-webhook handling (Linear/Jira only). */
138
+ export interface WebhookReceiver {
81
139
  handleWebhook?(payload: WebhookRequest): Promise<WebhookResult>
82
140
  }
141
+
142
+ export type KanbanProvider = ProviderIdentity &
143
+ BoardReader &
144
+ TaskReader &
145
+ TaskWriter &
146
+ CommentCapability &
147
+ ActivityReader &
148
+ MetricsReader &
149
+ ConfigWriter &
150
+ CacheManaged &
151
+ WebhookReceiver
package/src/server.ts CHANGED
@@ -1,15 +1,30 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { Buffer } from 'node:buffer'
4
+ import { timingSafeEqual } from 'node:crypto'
3
5
  import { handleRequest } from './api'
4
6
  import type { ServerWebSocket } from 'bun'
5
7
  import type { KanbanProvider } from './providers/types'
6
8
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from './sync-config'
7
9
 
8
- const wsClients = new Set<ServerWebSocket<unknown>>()
9
- const CORS_HEADERS = {
10
- 'Access-Control-Allow-Origin': '*',
11
- 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
12
- 'Access-Control-Allow-Headers': 'Content-Type',
10
+ // CORS is origin hygiene for cross-origin browser clients, never an auth control.
11
+ // When no allowed origin is configured we emit no CORS headers (same-origin only),
12
+ // which covers the bundled UI (served from this server) and the vite dev proxy.
13
+ function buildCorsHeaders(allowedOrigin?: string): Record<string, string> {
14
+ if (!allowedOrigin) return {}
15
+ return {
16
+ 'Access-Control-Allow-Origin': allowedOrigin,
17
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
18
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
19
+ Vary: 'Origin',
20
+ }
21
+ }
22
+
23
+ function safeEqual(a: string, b: string): boolean {
24
+ const aBuf = Buffer.from(a)
25
+ const bBuf = Buffer.from(b)
26
+ if (aBuf.length !== bBuf.length) return false
27
+ return timingSafeEqual(aBuf, bBuf)
13
28
  }
14
29
  interface BackgroundSyncState {
15
30
  enabled: boolean
@@ -22,6 +37,16 @@ interface BackgroundSyncState {
22
37
 
23
38
  export interface StartServerOptions {
24
39
  syncIntervalMs?: number
40
+ /**
41
+ * When set, all `/api/*` routes (reads and mutations) and the `/ws` upgrade
42
+ * require `Authorization: Bearer <token>` (or `?token=` for the WebSocket,
43
+ * which cannot send headers). `/api/health` and `/api/webhooks/*` are exempt:
44
+ * health is a liveness probe and webhooks authenticate with the provider
45
+ * webhook secret instead. When unset, the API is open (localhost default).
46
+ */
47
+ authToken?: string
48
+ /** Allowed CORS origin; when unset, no CORS headers are emitted (same-origin). */
49
+ allowedOrigin?: string
25
50
  }
26
51
 
27
52
  export interface StartedServer {
@@ -29,22 +54,15 @@ export interface StartedServer {
29
54
  stop(closeActiveConnections?: boolean): void
30
55
  }
31
56
 
32
- function broadcast(data: unknown): void {
33
- const msg = JSON.stringify(data)
34
- for (const ws of wsClients) {
35
- ws.send(msg)
36
- }
37
- }
38
-
39
- function applyCorsHeaders(response: Response): void {
40
- for (const [header, value] of Object.entries(CORS_HEADERS)) {
57
+ function applyCorsHeaders(response: Response, corsHeaders: Record<string, string>): void {
58
+ for (const [header, value] of Object.entries(corsHeaders)) {
41
59
  response.headers.set(header, value)
42
60
  }
43
61
  }
44
62
 
45
- function jsonWithCors(body: unknown, status = 200): Response {
63
+ function jsonWithCors(body: unknown, corsHeaders: Record<string, string>, status = 200): Response {
46
64
  const response = Response.json(body, { status })
47
- applyCorsHeaders(response)
65
+ applyCorsHeaders(response, corsHeaders)
48
66
  return response
49
67
  }
50
68
 
@@ -66,6 +84,48 @@ export function startServer(
66
84
  const syncIntervalMs = opts.syncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
67
85
  const syncCache = provider.syncCache?.bind(provider)
68
86
  const getSyncStatus = provider.getSyncStatus?.bind(provider)
87
+ const corsHeaders = buildCorsHeaders(opts.allowedOrigin)
88
+ const authToken = opts.authToken
89
+
90
+ // Per-instance so multiple servers (e.g. in tests) don't share or cross-broadcast
91
+ // to each other's sockets.
92
+ const wsClients = new Set<ServerWebSocket<unknown>>()
93
+ const broadcast = (data: unknown): void => {
94
+ const msg = JSON.stringify(data)
95
+ for (const ws of wsClients) {
96
+ // A dead/closing socket must not abort the fan-out to the rest.
97
+ try {
98
+ ws.send(msg)
99
+ } catch {
100
+ wsClients.delete(ws)
101
+ }
102
+ }
103
+ }
104
+
105
+ const isAuthorized = (req: Request, url: URL, allowQueryToken: boolean): boolean => {
106
+ if (!authToken) return true
107
+ const header = req.headers.get('authorization')
108
+ // The `?token=` fallback exists only for the WebSocket handshake, which can't
109
+ // carry an Authorization header. HTTP API routes require the header so tokens
110
+ // don't end up in URLs, logs, or referrers.
111
+ const provided = header?.startsWith('Bearer ')
112
+ ? header.slice('Bearer '.length)
113
+ : allowQueryToken
114
+ ? url.searchParams.get('token')
115
+ : null
116
+ return provided ? safeEqual(provided, authToken) : false
117
+ }
118
+
119
+ // Health is a public liveness probe; webhooks authenticate with the provider
120
+ // webhook secret, not this token. Everything else under /api plus /ws is gated.
121
+ const pathRequiresAuth = (pathname: string): boolean => {
122
+ if (!authToken) return false
123
+ if (pathname === '/ws') return true
124
+ if (pathname === '/api/health') return false
125
+ if (pathname.startsWith('/api/webhooks/')) return false
126
+ return pathname.startsWith('/api/')
127
+ }
128
+
69
129
  const backgroundSync: BackgroundSyncState = {
70
130
  enabled: typeof syncCache === 'function',
71
131
  inFlight: false,
@@ -103,6 +163,9 @@ export function startServer(
103
163
  }
104
164
 
105
165
  if (syncCache) {
166
+ // The background warmer owns cache refresh; let the provider serve reads from
167
+ // the warm cache instead of blocking each request on a provider network sync.
168
+ provider.setBackgroundManaged?.(true)
106
169
  void runBackgroundSync('startup').finally(() => {
107
170
  scheduleBackgroundSync()
108
171
  })
@@ -110,6 +173,11 @@ export function startServer(
110
173
 
111
174
  const server = Bun.serve({
112
175
  port,
176
+ // Default is 10s; a cold-start provider sync (e.g. a Jira full reconcile) can
177
+ // exceed that and Bun would otherwise close the connection mid-flight, surfacing
178
+ // as ERR_EMPTY_RESPONSE. Steady-state reads are served from the warm cache and
179
+ // return well under this ceiling; this is just a safety net for slow cold starts.
180
+ idleTimeout: 255,
113
181
  websocket: {
114
182
  open(ws) {
115
183
  wsClients.add(ws)
@@ -129,7 +197,17 @@ export function startServer(
129
197
 
130
198
  // Handle OPTIONS preflight first (before /api routing)
131
199
  if (req.method === 'OPTIONS') {
132
- return new Response(null, { headers: CORS_HEADERS })
200
+ return new Response(null, { headers: corsHeaders })
201
+ }
202
+
203
+ // Auth gate: protect all /api/* and /ws except the public health probe and
204
+ // webhook routes (which authenticate with the provider webhook secret).
205
+ if (pathRequiresAuth(pathname) && !isAuthorized(req, url, pathname === '/ws')) {
206
+ return jsonWithCors(
207
+ { ok: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid API token' } },
208
+ corsHeaders,
209
+ 401,
210
+ )
133
211
  }
134
212
 
135
213
  // WebSocket upgrade
@@ -140,10 +218,13 @@ export function startServer(
140
218
  }
141
219
 
142
220
  if (pathname === '/api/health') {
143
- return jsonWithCors({
144
- ok: true,
145
- data: { status: 'running', wsClients: wsClients.size, provider: provider.type },
146
- })
221
+ return jsonWithCors(
222
+ {
223
+ ok: true,
224
+ data: { status: 'running', wsClients: wsClients.size, provider: provider.type },
225
+ },
226
+ corsHeaders,
227
+ )
147
228
  }
148
229
 
149
230
  if (pathname === '/api/ready') {
@@ -157,22 +238,26 @@ export function startServer(
157
238
  backgroundSync,
158
239
  },
159
240
  },
241
+ corsHeaders,
160
242
  ready ? 200 : 503,
161
243
  )
162
244
  }
163
245
 
164
246
  if (pathname === '/api/sync-status') {
165
247
  const providerSync = (await getSyncStatus?.()) ?? null
166
- return jsonWithCors({
167
- ok: true,
168
- data: {
169
- status: 'running',
170
- provider: provider.type,
171
- wsClients: wsClients.size,
172
- backgroundSync,
173
- providerSync,
248
+ return jsonWithCors(
249
+ {
250
+ ok: true,
251
+ data: {
252
+ status: 'running',
253
+ provider: provider.type,
254
+ wsClients: wsClients.size,
255
+ backgroundSync,
256
+ providerSync,
257
+ },
174
258
  },
175
- })
259
+ corsHeaders,
260
+ )
176
261
  }
177
262
 
178
263
  if (pathname.startsWith('/api/')) {
@@ -180,7 +265,7 @@ export function startServer(
180
265
  forwardedUrl.pathname = pathname
181
266
  const forwardedReq = new Request(forwardedUrl.toString(), req)
182
267
  const result = await handleRequest(provider, forwardedReq)
183
- applyCorsHeaders(result.response)
268
+ applyCorsHeaders(result.response, corsHeaders)
184
269
  if (result.mutated && result.response.ok) {
185
270
  broadcast(result.event ?? { type: 'refresh' })
186
271
  }
@@ -212,7 +297,8 @@ export function startServer(
212
297
  clearTimeout(syncTimer)
213
298
  syncTimer = null
214
299
  }
215
- server.stop(closeActiveConnections)
300
+ wsClients.clear()
301
+ void server.stop(closeActiveConnections)
216
302
  },
217
303
  }
218
304
  }