@andypai/agent-kanban 0.7.0 → 0.8.0

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.
@@ -0,0 +1,155 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2
+ import type { Sql } from 'postgres'
3
+ import {
4
+ ensureWebhookEventsSchema,
5
+ recordWebhookEvent,
6
+ withWebhookRecording,
7
+ } from '../webhook-events'
8
+
9
+ // A minimal fake of the `postgres` tagged-template client. It records every
10
+ // query (joined SQL text + interpolated values) and exposes `.json()` like the
11
+ // real client, so the receipts helpers can be unit-tested without a Postgres.
12
+ interface FakeSql {
13
+ (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>
14
+ json: (v: unknown) => { __json: unknown }
15
+ calls: { text: string; values: unknown[] }[]
16
+ }
17
+
18
+ function makeFakeSql(opts: { fail?: boolean } = {}): FakeSql {
19
+ const calls: { text: string; values: unknown[] }[] = []
20
+ const fn = ((strings: TemplateStringsArray, ...values: unknown[]) => {
21
+ calls.push({ text: strings.join(' ? '), values })
22
+ return opts.fail ? Promise.reject(new Error('db down')) : Promise.resolve([])
23
+ }) as FakeSql
24
+ fn.json = (v: unknown) => ({ __json: v })
25
+ fn.calls = calls
26
+ return fn
27
+ }
28
+
29
+ const asSql = (f: FakeSql): Sql => f as unknown as Sql
30
+
31
+ let prevFlag: string | undefined
32
+ beforeEach(() => {
33
+ prevFlag = process.env['KANBAN_WEBHOOK_EVENTS']
34
+ delete process.env['KANBAN_WEBHOOK_EVENTS'] // default = enabled
35
+ })
36
+ afterEach(() => {
37
+ if (prevFlag === undefined) delete process.env['KANBAN_WEBHOOK_EVENTS']
38
+ else process.env['KANBAN_WEBHOOK_EVENTS'] = prevFlag
39
+ })
40
+
41
+ describe('ensureWebhookEventsSchema (F37)', () => {
42
+ test('enabled: issues CREATE TABLE + CREATE INDEX (idempotent DDL)', async () => {
43
+ const sql = makeFakeSql()
44
+ await ensureWebhookEventsSchema(asSql(sql))
45
+ const text = sql.calls.map((c) => c.text).join('\n')
46
+ expect(text).toContain('CREATE TABLE IF NOT EXISTS webhook_events')
47
+ expect(text).toContain('CREATE INDEX IF NOT EXISTS webhook_events_received_at_idx')
48
+ expect(sql.calls.length).toBe(2)
49
+ })
50
+
51
+ test('disabled: no DDL is issued', async () => {
52
+ process.env['KANBAN_WEBHOOK_EVENTS'] = 'off'
53
+ const sql = makeFakeSql()
54
+ await ensureWebhookEventsSchema(asSql(sql))
55
+ expect(sql.calls.length).toBe(0)
56
+ })
57
+ })
58
+
59
+ describe('recordWebhookEvent (F40)', () => {
60
+ test('enabled: inserts provider/eventType/externalRef/status/detail', async () => {
61
+ const sql = makeFakeSql()
62
+ await recordWebhookEvent(asSql(sql), {
63
+ provider: 'jira',
64
+ eventType: 'jira:issue_updated',
65
+ externalRef: 'ENG-7',
66
+ status: 'accepted',
67
+ detail: { note: 'ok' },
68
+ })
69
+ expect(sql.calls.length).toBe(1)
70
+ const insert = sql.calls[0]!
71
+ expect(insert.text).toContain('INSERT INTO webhook_events')
72
+ expect(insert.values).toEqual([
73
+ 'jira',
74
+ 'jira:issue_updated',
75
+ 'ENG-7',
76
+ 'accepted',
77
+ { __json: { note: 'ok' } },
78
+ ])
79
+ })
80
+
81
+ test('omitted eventType/externalRef/detail become null / empty json', async () => {
82
+ const sql = makeFakeSql()
83
+ await recordWebhookEvent(asSql(sql), { provider: 'linear', status: 'skipped' })
84
+ const insert = sql.calls[0]!
85
+ expect(insert.values).toEqual(['linear', null, null, 'skipped', { __json: {} }])
86
+ })
87
+
88
+ test('disabled: no insert', async () => {
89
+ process.env['KANBAN_WEBHOOK_EVENTS'] = '0'
90
+ const sql = makeFakeSql()
91
+ await recordWebhookEvent(asSql(sql), { provider: 'jira', status: 'accepted' })
92
+ expect(sql.calls.length).toBe(0)
93
+ })
94
+
95
+ test('a failing insert is swallowed (never throws) and is logged', async () => {
96
+ const warnSpy = mock(() => {})
97
+ const original = console.warn
98
+ console.warn = warnSpy as unknown as typeof console.warn
99
+ try {
100
+ const sql = makeFakeSql({ fail: true })
101
+ await recordWebhookEvent(asSql(sql), { provider: 'jira', status: 'accepted' })
102
+ expect(warnSpy).toHaveBeenCalled()
103
+ } finally {
104
+ console.warn = original
105
+ }
106
+ })
107
+ })
108
+
109
+ describe('withWebhookRecording (F39)', () => {
110
+ test('success: returns dispatch result and records the mapped status', async () => {
111
+ const sql = makeFakeSql()
112
+ const body = JSON.stringify({ webhookEvent: 'jira:issue_updated', issue: { key: 'ENG-9' } })
113
+ const result = await withWebhookRecording(
114
+ asSql(sql),
115
+ 'jira',
116
+ { headers: {}, rawBody: body },
117
+ async () => ({ handled: true }),
118
+ )
119
+ expect(result).toEqual({ handled: true })
120
+ const insert = sql.calls.find((c) => c.text.includes('INSERT INTO webhook_events'))
121
+ expect(insert).toBeDefined()
122
+ // provider, eventType(from body), externalRef(from body), status
123
+ expect(insert!.values.slice(0, 4)).toEqual(['jira', 'jira:issue_updated', 'ENG-9', 'accepted'])
124
+ })
125
+
126
+ test('unhandled dispatch is recorded as skipped', async () => {
127
+ const sql = makeFakeSql()
128
+ const result = await withWebhookRecording(
129
+ asSql(sql),
130
+ 'linear',
131
+ { headers: {}, rawBody: '{}' },
132
+ async () => ({ handled: false }),
133
+ )
134
+ expect(result).toEqual({ handled: false })
135
+ const insert = sql.calls.find((c) => c.text.includes('INSERT INTO webhook_events'))!
136
+ expect(insert.values[3]).toBe('skipped')
137
+ })
138
+
139
+ test('dispatch throw: records an error receipt and rethrows the original error', async () => {
140
+ const sql = makeFakeSql()
141
+ const boom = new Error('apply failed')
142
+ let caught: unknown
143
+ try {
144
+ await withWebhookRecording(asSql(sql), 'jira', { headers: {}, rawBody: '{}' }, async () => {
145
+ throw boom
146
+ })
147
+ } catch (err) {
148
+ caught = err
149
+ }
150
+ expect(caught).toBe(boom)
151
+ const insert = sql.calls.find((c) => c.text.includes('INSERT INTO webhook_events'))!
152
+ expect(insert.values[3]).toBe('error')
153
+ expect(insert.values[4]).toEqual({ __json: { error: 'apply failed' } })
154
+ })
155
+ })
@@ -1,7 +1,12 @@
1
1
  import { beforeEach, afterEach, describe, expect, test } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
3
  import { createHmac } from 'node:crypto'
4
- import { verifyHmacSha256, verifySha256HmacSignatureHeader } from '../webhooks'
4
+ import {
5
+ authorizeWebhook,
6
+ headerLower,
7
+ verifyHmacSha256,
8
+ verifySha256HmacSignatureHeader,
9
+ } from '../webhooks'
5
10
  import { JiraProvider, type JiraProviderConfig } from '../providers/jira'
6
11
  import { JiraClient } from '../providers/jira-client'
7
12
  import { LinearProvider } from '../providers/linear'
@@ -69,6 +74,47 @@ describe('verifyHmacSha256', () => {
69
74
  })
70
75
  })
71
76
 
77
+ describe('authorizeWebhook (F32)', () => {
78
+ const verify = (secret: string, body: string, sig: string | undefined | null): boolean =>
79
+ verifyHmacSha256(secret, body, sig)
80
+
81
+ test('no secret configured → open mode (returns null, request allowed)', () => {
82
+ expect(
83
+ authorizeWebhook({ secret: undefined, rawBody: '{}', signature: 'anything', verify }),
84
+ ).toBeNull()
85
+ expect(authorizeWebhook({ secret: '', rawBody: '{}', signature: undefined, verify })).toBeNull()
86
+ })
87
+
88
+ test('secret configured + valid signature → null (proceed)', () => {
89
+ const body = '{"a":1}'
90
+ const sig = createHmac('sha256', 's3cr3t').update(body).digest('hex')
91
+ expect(authorizeWebhook({ secret: 's3cr3t', rawBody: body, signature: sig, verify })).toBeNull()
92
+ })
93
+
94
+ test('secret configured + bad signature → unauthorized result', () => {
95
+ const result = authorizeWebhook({
96
+ secret: 's3cr3t',
97
+ rawBody: '{"a":1}',
98
+ signature: 'deadbeef',
99
+ verify,
100
+ })
101
+ expect(result).toEqual({ handled: false, unauthorized: true, message: 'Invalid signature' })
102
+ })
103
+ })
104
+
105
+ describe('headerLower (F35)', () => {
106
+ test('case-insensitive lookup returns the matching value', () => {
107
+ const headers = { 'X-Hub-Signature': 'sha256=abc', 'Content-Type': 'application/json' }
108
+ expect(headerLower(headers, 'x-hub-signature')).toBe('sha256=abc')
109
+ expect(headerLower(headers, 'X-HUB-SIGNATURE')).toBe('sha256=abc')
110
+ expect(headerLower(headers, 'content-type')).toBe('application/json')
111
+ })
112
+
113
+ test('missing header → undefined', () => {
114
+ expect(headerLower({ a: '1' }, 'linear-signature')).toBeUndefined()
115
+ })
116
+ })
117
+
72
118
  describe('verifySha256HmacSignatureHeader', () => {
73
119
  test('accepts Jira WebSub-style sha256 signatures', () => {
74
120
  const body = '{"hello":"world"}'
package/src/api.ts CHANGED
@@ -52,27 +52,49 @@ function statusForCode(code: string): number {
52
52
  if (code === ErrorCode.PROVIDER_RATE_LIMITED) return 429
53
53
  if (code === ErrorCode.CONFLICT) return 409
54
54
  if (code === ErrorCode.UNSUPPORTED_OPERATION) return 400
55
- if (code === ErrorCode.PROVIDER_NOT_CONFIGURED) return 500
55
+ // Server-side / upstream failures must surface as 5xx, not as the default 400.
56
+ // (The MCP layer likewise treats these as `provider_unavailable`.)
57
+ if (code === ErrorCode.PROVIDER_UPSTREAM_ERROR) return 502
58
+ if (code === ErrorCode.PROVIDER_SYNC_REQUIRED) return 503
59
+ if (code === ErrorCode.PROVIDER_NOT_CONFIGURED || code === ErrorCode.INTERNAL_ERROR) return 500
56
60
  return 400
57
61
  }
58
62
 
63
+ // decodeURIComponent throws URIError on malformed percent-encoding (e.g. a lone
64
+ // `%`). Path params flow straight into it, so a bad URL must surface as a 400
65
+ // envelope rather than escaping handleRequest as an unhandled rejection.
66
+ function decodePathParam(raw: string): string {
67
+ try {
68
+ return decodeURIComponent(raw)
69
+ } catch {
70
+ throw new KanbanError(ErrorCode.INVALID_ARGUMENT, 'Malformed percent-encoding in URL path')
71
+ }
72
+ }
73
+
59
74
  function toResponse(result: CliOutput): Response {
60
75
  if (result.ok) return json(result)
61
76
  return json(result, statusForCode(result.error.code))
62
77
  }
63
78
 
79
+ // Map any thrown error to the `{ ok:false, error }` envelope with the right
80
+ // status. Shared by wrapHandler (per-handler) and handleRequest's top-level
81
+ // guard so the two error paths can never drift apart.
82
+ function errorResponse(err: unknown): Response {
83
+ if (err instanceof KanbanError) {
84
+ return json(
85
+ { ok: false, error: { code: err.code, message: err.message } },
86
+ statusForCode(err.code),
87
+ )
88
+ }
89
+ const msg = err instanceof Error ? err.message : String(err)
90
+ return json({ ok: false, error: { code: 'INTERNAL_ERROR', message: msg } }, 500)
91
+ }
92
+
64
93
  async function wrapHandler(fn: () => Promise<CliOutput> | CliOutput): Promise<Response> {
65
94
  try {
66
95
  return toResponse(await fn())
67
96
  } catch (err) {
68
- if (err instanceof KanbanError) {
69
- return json(
70
- { ok: false, error: { code: err.code, message: err.message } },
71
- statusForCode(err.code),
72
- )
73
- }
74
- const msg = err instanceof Error ? err.message : String(err)
75
- return json({ ok: false, error: { code: 'INTERNAL_ERROR', message: msg } }, 500)
97
+ return errorResponse(err)
76
98
  }
77
99
  }
78
100
 
@@ -110,7 +132,20 @@ async function mutationResult<T>(
110
132
  }
111
133
  }
112
134
 
135
+ // Top-level guard: handleRequest must NEVER throw. The webhook branch returns a
136
+ // raw ApiResult (not via wrapHandler), and path-param decoding / body reads can
137
+ // throw before any handler runs. Any escape would reach Bun.serve's fetch (which
138
+ // has no try/catch) and surface as a bare, non-enveloped 500. This wrapper keeps
139
+ // every failure inside the { ok:false, error } contract, never marking mutated.
113
140
  export async function handleRequest(provider: KanbanProvider, req: Request): Promise<ApiResult> {
141
+ try {
142
+ return await dispatchApiRequest(provider, req)
143
+ } catch (err) {
144
+ return { response: errorResponse(err), mutated: false }
145
+ }
146
+ }
147
+
148
+ async function dispatchApiRequest(provider: KanbanProvider, req: Request): Promise<ApiResult> {
114
149
  const url = new URL(req.url)
115
150
  const path = url.pathname
116
151
  const method = req.method
@@ -164,7 +199,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
164
199
 
165
200
  const taskMatch = path.match(/^\/api\/tasks\/([^/]+)$/)
166
201
  if (taskMatch) {
167
- const id = decodeURIComponent(taskMatch[1]!)
202
+ const id = decodePathParam(taskMatch[1]!)
168
203
 
169
204
  if (method === 'GET') {
170
205
  return readResult(() => provider.getTask(id))
@@ -187,7 +222,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
187
222
 
188
223
  const moveMatch = path.match(/^\/api\/tasks\/([^/]+)\/move$/)
189
224
  if (moveMatch && method === 'PATCH') {
190
- const id = decodeURIComponent(moveMatch[1]!)
225
+ const id = decodePathParam(moveMatch[1]!)
191
226
  return mutationResult(async () => {
192
227
  const body = await parseJsonBody<MoveTaskBody>(req)
193
228
  requireArgument(body.column, 'column')
@@ -197,7 +232,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
197
232
 
198
233
  const commentsMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/)
199
234
  if (commentsMatch) {
200
- const id = decodeURIComponent(commentsMatch[1]!)
235
+ const id = decodePathParam(commentsMatch[1]!)
201
236
  if (method === 'GET') {
202
237
  return readResult(() => provider.listComments(id))
203
238
  }
@@ -213,8 +248,8 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
213
248
 
214
249
  const commentMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments\/([^/]+)$/)
215
250
  if (commentMatch) {
216
- const id = decodeURIComponent(commentMatch[1]!)
217
- const commentId = decodeURIComponent(commentMatch[2]!)
251
+ const id = decodePathParam(commentMatch[1]!)
252
+ const commentId = decodePathParam(commentMatch[2]!)
218
253
 
219
254
  if (method === 'PATCH') {
220
255
  return mutationResult(async () => {
@@ -250,7 +285,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
250
285
 
251
286
  const webhookMatch = path.match(/^\/api\/webhooks\/([^/]+)$/)
252
287
  if (webhookMatch && method === 'POST') {
253
- const target = decodeURIComponent(webhookMatch[1]!)
288
+ const target = decodePathParam(webhookMatch[1]!)
254
289
  if (target !== provider.type) {
255
290
  return {
256
291
  response: json(
@@ -286,6 +321,9 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
286
321
  req.headers.forEach((value, key) => {
287
322
  headers[key] = value
288
323
  })
324
+ // A throwing provider.handleWebhook is contained by the top-level guard in
325
+ // handleRequest, which envelopes it as a 500 (or KanbanError status) and
326
+ // leaves mutated:false.
289
327
  const result = await provider.handleWebhook({ headers, rawBody })
290
328
  if (result.unauthorized) {
291
329
  return {
package/src/db.ts CHANGED
@@ -178,6 +178,23 @@ export function resolveColumn(db: Database, idOrName: string): Column {
178
178
  throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No column matching '${idOrName}'`)
179
179
  }
180
180
 
181
+ // Resolve the column a task lands in when the caller gives no explicit column.
182
+ // Mirrors the Postgres local provider's resolveDefaultTaskColumn fallback so the
183
+ // SQLite store stays at parity: prefer a column named "backlog", otherwise the
184
+ // first column by position. (A configured KANBAN_DEFAULT_TASK_COLUMN is handled
185
+ // upstream by being passed in as the explicit column.)
186
+ function resolveDefaultColumn(db: Database): Column {
187
+ const backlog = db
188
+ .query("SELECT * FROM columns WHERE LOWER(name) = 'backlog' ORDER BY position LIMIT 1")
189
+ .get() as Column | null
190
+ if (backlog) return backlog
191
+ const first = db
192
+ .query('SELECT * FROM columns ORDER BY position, name LIMIT 1')
193
+ .get() as Column | null
194
+ if (first) return first
195
+ throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, 'No columns are configured')
196
+ }
197
+
181
198
  export function listColumns(db: Database): Column[] {
182
199
  return db.query('SELECT * FROM columns ORDER BY position').all() as Column[]
183
200
  }
@@ -295,7 +312,7 @@ export function addTask(
295
312
  metadata?: string
296
313
  } = {},
297
314
  ): TaskWithColumn {
298
- const column = opts.column ? resolveColumn(db, opts.column) : resolveColumn(db, 'backlog')
315
+ const column = opts.column ? resolveColumn(db, opts.column) : resolveDefaultColumn(db)
299
316
 
300
317
  if (opts.priority && !['low', 'medium', 'high', 'urgent'].includes(opts.priority)) {
301
318
  throw new KanbanError(
package/src/index.ts CHANGED
@@ -9,13 +9,13 @@ import { boardInit, boardReset } from './commands/board'
9
9
  import { columnAdd, columnDelete, columnList, columnRename, columnReorder } from './commands/column'
10
10
  import { bulkClearDoneCmd, bulkMoveAllCmd } from './commands/bulk'
11
11
  import { getConfigPath, loadConfig, saveConfig } from './config'
12
- import { parsePositiveInt } from './transport-input'
12
+ import { parseBoundedInt, parsePositiveInt } from './transport-input'
13
13
  import type { CliOutput, Priority, ProviderCapabilities } from './types'
14
14
  import { unsupportedOperation } from './providers/errors'
15
15
  import { openKanbanRuntime } from './provider-runtime'
16
- import { trackerConfigFromEnv } from './tracker-config'
16
+ import { WEBHOOK_SECRET_ENV, trackerConfigFromEnv, trackerProviderFromEnv } from './tracker-config'
17
17
  import type { KanbanProvider } from './providers/types'
18
- import { resolvePollingSyncIntervalMs } from './sync-config'
18
+ import { MIN_POLLING_SYNC_INTERVAL_MS } from './sync-config'
19
19
  import { normalizeCreateTaskInput } from './use-cases'
20
20
 
21
21
  interface ParsedArgs {
@@ -420,7 +420,7 @@ Commands:
420
420
  board view View full board (default)
421
421
  board reset Reset board to defaults
422
422
 
423
- task add <title> Add a task [-d desc] [-c column] [-p priority] [-a assignee] [--project name] [--label name] [-m json]
423
+ task add <title> Add a task [-d desc] [-c column] [-p priority] [-a assignee] [--project name] [--label name] [--labels names] [-m json]
424
424
  task list List tasks [-c column] [-p priority] [-a assignee] [--project name] [-l limit] [--sort field]
425
425
  task view <id> View task details
426
426
  task update <id> Update task [--title] [-d] [-p] [-a] [--project name] [-m]
@@ -489,9 +489,12 @@ export function parseServeArgs(argv: string[]): ServeOptions {
489
489
  err instanceof Error ? err.message : String(err),
490
490
  )
491
491
  }
492
- const port = values.port
493
- ? parseInt(values.port as string, 10)
494
- : parseInt(process.env['PORT'] || '3000', 10)
492
+ // Use `!== undefined` (not truthiness) so an explicit empty `--port=` is
493
+ // validated and rejected rather than silently falling back to the default.
494
+ const port =
495
+ values.port !== undefined
496
+ ? parsePort(values.port as string, '--port')
497
+ : parsePort(process.env['PORT'] || '3000', 'PORT')
495
498
  // Flags win over env so a one-off `--token` can override the ambient config.
496
499
  const authToken = (values.token as string | undefined) || process.env['KANBAN_API_TOKEN']
497
500
  const allowedOrigin =
@@ -499,7 +502,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
499
502
  return {
500
503
  db: values.db as string | undefined,
501
504
  port,
502
- ...(values['sync-interval-ms']
505
+ ...(values['sync-interval-ms'] !== undefined
503
506
  ? { syncIntervalMs: parseSyncIntervalMs(values['sync-interval-ms'] as string) }
504
507
  : {}),
505
508
  tunnel: Boolean(values.tunnel),
@@ -508,8 +511,60 @@ export function parseServeArgs(argv: string[]): ServeOptions {
508
511
  }
509
512
  }
510
513
 
514
+ // Strict digits-only CLI contract via the shared parseBoundedInt (rejects
515
+ // hex/scientific, the Number() overflow/precision-loss of an over-long digit
516
+ // string, and values below the minimum — all as INVALID_ARGUMENT). The shared
517
+ // resolvePollingSyncIntervalMs (sync-config.ts) is the env-path parser; this
518
+ // flag is validated strictly here.
511
519
  function parseSyncIntervalMs(raw: string): number {
512
- return resolvePollingSyncIntervalMs(raw, { label: '--sync-interval-ms' })
520
+ return parseBoundedInt(raw, { min: MIN_POLLING_SYNC_INTERVAL_MS, field: '--sync-interval-ms' })
521
+ }
522
+
523
+ // `parseInt` silently accepts `123abc` (→123) and `-1`, and yields NaN for
524
+ // non-numeric input, so validate the port explicitly via parseBoundedInt: digits
525
+ // only, 0–65535 (0 lets the OS pick an ephemeral port). Throws so a bad value
526
+ // surfaces as the structured INVALID_ARGUMENT envelope rather than booting on a
527
+ // garbage port.
528
+ function parsePort(raw: string, label: string): number {
529
+ return parseBoundedInt(raw, { min: 0, max: 65535, field: label })
530
+ }
531
+
532
+ /**
533
+ * Guards public-tunnel startup. A `--tunnel` exposes the server to the internet,
534
+ * so it must require both:
535
+ * 1. an API token (KANBAN_API_TOKEN / --token) for the `/api/*` + `/ws` surface, and
536
+ * 2. a provider webhook signing secret, because `/api/webhooks/*` is exempt from
537
+ * the API token and falls back to "open dev mode" (accept unsigned payloads)
538
+ * when the secret is unset — which over a public tunnel means unauthenticated
539
+ * writes to the cache.
540
+ * Throws KanbanError on violation; the caller prints the message and exits non-zero.
541
+ */
542
+ export function assertTunnelSecurity(
543
+ opts: { tunnel: boolean; authToken?: string },
544
+ env: Record<string, string | undefined>,
545
+ ): void {
546
+ if (!opts.tunnel) return
547
+ if (!opts.authToken) {
548
+ throw new KanbanError(
549
+ ErrorCode.INVALID_ARGUMENT,
550
+ 'Refusing to start a public tunnel without an API token. ' +
551
+ 'Set KANBAN_API_TOKEN or pass --token <token>.',
552
+ )
553
+ }
554
+ const providerType = trackerProviderFromEnv(env)
555
+ // Single source of truth (WEBHOOK_SECRET_ENV is Record<TrackerProvider,…>), so
556
+ // a new webhook-capable provider can't be added without declaring its secret
557
+ // here — closing the fail-open hole where an unmapped provider would start a
558
+ // public tunnel with no signature secret enforced.
559
+ const webhookSecretEnv = WEBHOOK_SECRET_ENV[providerType]
560
+ if (webhookSecretEnv && !env[webhookSecretEnv]) {
561
+ throw new KanbanError(
562
+ ErrorCode.INVALID_ARGUMENT,
563
+ `Refusing to start a public tunnel: ${providerType} webhooks accept unsigned payloads ` +
564
+ `(open dev mode) without ${webhookSecretEnv}, which would expose unauthenticated writes ` +
565
+ `over the public URL. Set ${webhookSecretEnv}.`,
566
+ )
567
+ }
513
568
  }
514
569
 
515
570
  export interface McpOptions {
@@ -563,13 +618,14 @@ if (import.meta.main) {
563
618
  } else if (argv[0] === 'serve') {
564
619
  const opts = parseEntryArgs(() => parseServeArgs(argv))
565
620
 
566
- // A tunnel exposes the dashboard publicly, so refuse to start one without a
567
- // token. Plain localhost serve stays open for backward compatibility.
568
- if (opts.tunnel && !opts.authToken) {
569
- console.error(
570
- 'Refusing to start a public tunnel without an API token. ' +
571
- 'Set KANBAN_API_TOKEN or pass --token <token>.',
572
- )
621
+ // A tunnel exposes the dashboard publicly, so refuse to start one unless it is
622
+ // safe: an API token for /api + /ws, and a provider webhook secret so the
623
+ // auth-exempt /api/webhooks/* surface can't accept unsigned writes. Plain
624
+ // localhost serve stays open for backward compatibility.
625
+ try {
626
+ assertTunnelSecurity(opts, process.env)
627
+ } catch (err) {
628
+ console.error(err instanceof Error ? err.message : String(err))
573
629
  process.exit(1)
574
630
  }
575
631
 
@@ -595,7 +651,10 @@ if (import.meta.main) {
595
651
  if (opts.tunnel) {
596
652
  const { startCloudflareTunnel } = await import('./tunnel')
597
653
  try {
598
- tunnelHandle = startCloudflareTunnel(opts.port)
654
+ // Use the resolved bound port, not opts.port: with `--port=0` the OS
655
+ // picks an ephemeral port, so opts.port (0) would point cloudflared at
656
+ // localhost:0 and never reach the server.
657
+ tunnelHandle = startCloudflareTunnel(server.port)
599
658
  } catch {
600
659
  // startCloudflareTunnel already logged a friendly message
601
660
  }
@@ -68,7 +68,7 @@ export function createSqliteProvider(
68
68
  seedDefaultColumns(db, config.defaultColumns)
69
69
  }
70
70
  return {
71
- provider: new LocalProvider(db, options.dbPath),
71
+ provider: new LocalProvider(db, options.dbPath, config.defaultTaskColumn),
72
72
  capabilities: LOCAL_CAPABILITIES,
73
73
  }
74
74
  }
@@ -47,6 +47,7 @@ import type {
47
47
  UpdateTaskInput,
48
48
  } from './types'
49
49
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
50
+ import { WEBHOOK_SECRET_ENV, webhookSecretFromEnv } from '../tracker-config'
50
51
  import { warnOnce } from './warn-once'
51
52
  import { applyTaskFilters, forEachWithConcurrency, SyncGate, syncStatusFromMeta } from './sync-core'
52
53
 
@@ -872,14 +873,19 @@ export class JiraProviderCore implements KanbanProvider {
872
873
  // Shared webhook dispatch. Postgres wraps this with webhook-event auditing;
873
874
  // SQLite calls it directly via the default handleWebhook above.
874
875
  protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
875
- if (!process.env['JIRA_WEBHOOK_SECRET']) {
876
+ // Resolve the signing secret through WEBHOOK_SECRET_ENV so the runtime
877
+ // enforcement here and the assertTunnelSecurity tunnel gate read the same env
878
+ // name — they can't drift into a fail-open where the gate requires one env and
879
+ // verification reads another (unset) one.
880
+ const secret = webhookSecretFromEnv('jira')
881
+ if (!secret) {
876
882
  warnOnce(
877
883
  'jira-webhook-open-dev-mode',
878
- '[jira] JIRA_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
884
+ `[jira] ${WEBHOOK_SECRET_ENV['jira']} is not set — accepting webhook without signature verification (open dev mode)`,
879
885
  )
880
886
  }
881
887
  const auth = authorizeWebhook({
882
- secret: process.env['JIRA_WEBHOOK_SECRET'],
888
+ secret,
883
889
  rawBody: payload.rawBody,
884
890
  signature: headerLower(payload.headers, 'x-hub-signature'),
885
891
  verify: verifySha256HmacSignatureHeader,
@@ -40,6 +40,7 @@ import type {
40
40
  UpdateTaskInput,
41
41
  } from './types'
42
42
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
43
+ import { WEBHOOK_SECRET_ENV, webhookSecretFromEnv } from '../tracker-config'
43
44
  import { warnOnce } from './warn-once'
44
45
  import {
45
46
  applyTaskFilters,
@@ -596,14 +597,19 @@ export class LinearProviderCore implements KanbanProvider {
596
597
  // Shared webhook dispatch. Postgres wraps this with webhook-event auditing;
597
598
  // SQLite calls it directly via the default handleWebhook above.
598
599
  protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
599
- if (!process.env['LINEAR_WEBHOOK_SECRET']) {
600
+ // Resolve the signing secret through WEBHOOK_SECRET_ENV so the runtime
601
+ // enforcement here and the assertTunnelSecurity tunnel gate read the same env
602
+ // name — they can't drift into a fail-open where the gate requires one env and
603
+ // verification reads another (unset) one.
604
+ const secret = webhookSecretFromEnv('linear')
605
+ if (!secret) {
600
606
  warnOnce(
601
607
  'linear-webhook-open-dev-mode',
602
- '[linear] LINEAR_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
608
+ `[linear] ${WEBHOOK_SECRET_ENV['linear']} is not set — accepting webhook without signature verification (open dev mode)`,
603
609
  )
604
610
  }
605
611
  const auth = authorizeWebhook({
606
- secret: process.env['LINEAR_WEBHOOK_SECRET'],
612
+ secret,
607
613
  rawBody: payload.rawBody,
608
614
  signature: headerLower(payload.headers, 'linear-signature'),
609
615
  verify: verifyHmacSha256,
@@ -5,7 +5,7 @@ import { LocalProviderCore } from './local-core'
5
5
  import { SqliteLocalStore } from './sqlite-local-store'
6
6
 
7
7
  export class LocalProvider extends LocalProviderCore {
8
- constructor(db: Database, dbPath = getDbPath()) {
9
- super(new SqliteLocalStore(db, dbPath))
8
+ constructor(db: Database, dbPath = getDbPath(), defaultTaskColumn?: string) {
9
+ super(new SqliteLocalStore(db, dbPath, defaultTaskColumn))
10
10
  }
11
11
  }
@@ -30,6 +30,7 @@ export class SqliteLocalStore implements LocalStorePort {
30
30
  constructor(
31
31
  private readonly db: Database,
32
32
  private readonly dbPath: string,
33
+ private readonly defaultTaskColumn?: string,
33
34
  ) {}
34
35
 
35
36
  getBoard() {
@@ -53,7 +54,10 @@ export class SqliteLocalStore implements LocalStorePort {
53
54
  }
54
55
 
55
56
  createTask(input: CreateTaskInput) {
56
- return addTask(this.db, input.title, input)
57
+ return addTask(this.db, input.title, {
58
+ ...input,
59
+ column: input.column ?? this.defaultTaskColumn,
60
+ })
57
61
  }
58
62
 
59
63
  updateTask(idOrRef: string, input: Omit<UpdateTaskInput, 'expectedVersion'>) {
@@ -1,4 +1,5 @@
1
1
  import { ErrorCode, KanbanError } from './errors'
2
+ import { parseDecimalDigits } from './transport-input'
2
3
 
3
4
  export const DEFAULT_POLLING_SYNC_INTERVAL_MS = 30_000
4
5
  export const MIN_POLLING_SYNC_INTERVAL_MS = 1_000
@@ -10,8 +11,12 @@ export function resolvePollingSyncIntervalMs(
10
11
  const value = raw?.trim()
11
12
  if (!value) return DEFAULT_POLLING_SYNC_INTERVAL_MS
12
13
 
13
- const parsed = Number(value)
14
- if (!Number.isInteger(parsed) || parsed < MIN_POLLING_SYNC_INTERVAL_MS) {
14
+ // Digits-only via the shared parser (rejects hex/scientific like `0x3e8`/`1e3`
15
+ // and over-long strings that round past MAX_SAFE_INTEGER / overflow to
16
+ // Infinity); a set-but-invalid value is a config error, not a fall-back to the
17
+ // default, mirroring the strict --sync-interval-ms CLI flag.
18
+ const parsed = parseDecimalDigits(value)
19
+ if (parsed === null || parsed < MIN_POLLING_SYNC_INTERVAL_MS) {
15
20
  throw new KanbanError(
16
21
  ErrorCode.INVALID_CONFIG,
17
22
  `${opts.label ?? 'KANBAN_SYNC_INTERVAL_MS'} must be an integer >= ${MIN_POLLING_SYNC_INTERVAL_MS}`,