@andypai/agent-kanban 0.7.0 → 0.8.1
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.
- package/README.md +22 -12
- package/package.json +8 -1
- package/src/__tests__/api.test.ts +220 -0
- package/src/__tests__/cli-task-lifecycle-matrix.test.ts +1262 -0
- package/src/__tests__/index.test.ts +107 -3
- package/src/__tests__/jira-wiring.test.ts +25 -2
- package/src/__tests__/server.test.ts +78 -0
- package/src/__tests__/sync-config.test.ts +14 -1
- package/src/__tests__/tracker-config-webhook-secret.test.ts +89 -0
- package/src/__tests__/transport-input.test.ts +67 -1
- package/src/__tests__/tunnel.test.ts +116 -0
- package/src/__tests__/webhook-events-receipts.test.ts +155 -0
- package/src/__tests__/webhooks.test.ts +47 -1
- package/src/api.ts +54 -16
- package/src/db.ts +18 -1
- package/src/index.ts +76 -17
- package/src/mcp/index.ts +2 -10
- package/src/mcp/types.ts +1 -1
- package/src/metrics-spec.ts +1 -1
- package/src/providers/factory.ts +1 -1
- package/src/providers/jira-adf.ts +8 -11
- package/src/providers/jira-client.ts +2 -2
- package/src/providers/jira-core.ts +12 -6
- package/src/providers/linear-cache.ts +4 -4
- package/src/providers/linear-client.ts +1 -1
- package/src/providers/linear-core.ts +9 -3
- package/src/providers/local.ts +2 -2
- package/src/providers/postgres-linear-cache.ts +1 -1
- package/src/providers/sqlite-local-store.ts +5 -1
- package/src/storage-config.ts +2 -4
- package/src/sync-config.ts +7 -2
- package/src/tracker-config.ts +74 -6
- package/src/transport-input.ts +46 -14
- package/src/tunnel.ts +22 -5
|
@@ -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 {
|
|
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
|
@@ -5,7 +5,7 @@ import { parsePositiveInt } from './transport-input'
|
|
|
5
5
|
import { success } from './output'
|
|
6
6
|
import { normalizeCreateTaskInput } from './use-cases'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
type WsEvent =
|
|
9
9
|
| { type: 'task:upsert'; task: Task; columnId: string }
|
|
10
10
|
| { type: 'task:delete'; id: string }
|
|
11
11
|
// Fallback when a mutation has no precise event; the UI does a full refresh.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
217
|
-
const commentId =
|
|
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 =
|
|
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) :
|
|
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 {
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
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
|
|
567
|
-
// token
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/mcp/index.ts
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
export { createTrackerCore } from './core'
|
|
2
2
|
export { createTrackerMcpServer } from './server'
|
|
3
|
-
export { TrackerMcpError
|
|
4
|
-
export type {
|
|
5
|
-
export type {
|
|
6
|
-
TrackerMcpAuthResolver,
|
|
7
|
-
TrackerMcpHooks,
|
|
8
|
-
TrackerMcpPolicy,
|
|
9
|
-
TrackerMcpServer,
|
|
10
|
-
TrackerMcpTool,
|
|
11
|
-
TrackerMcpToolHandlerContext,
|
|
12
|
-
} from './types'
|
|
3
|
+
export { TrackerMcpError } from './errors'
|
|
4
|
+
export type { TrackerMcpPolicy, TrackerMcpTool } from './types'
|
|
13
5
|
export { defaultTools } from './server'
|
package/src/mcp/types.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface TrackerMcpHooks<TScope> {
|
|
|
61
61
|
}): Promise<void> | void
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
interface TrackerMcpToolHandlerContext<TScope, TArgs = Record<string, unknown>> {
|
|
65
65
|
scope: TScope
|
|
66
66
|
args: TArgs
|
|
67
67
|
request?: Request
|
package/src/metrics-spec.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
const PRIORITY_RANK: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 }
|
|
15
15
|
|
|
16
16
|
/** A column plus how many tasks currently sit in it. */
|
|
17
|
-
|
|
17
|
+
interface MetricsColumnCount extends ClassifiableColumn {
|
|
18
18
|
count: number
|
|
19
19
|
}
|
|
20
20
|
|
package/src/providers/factory.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -17,18 +17,18 @@ export interface AdfDocument {
|
|
|
17
17
|
content: AdfBlockNode[]
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
interface AdfMark {
|
|
21
21
|
type: string
|
|
22
22
|
attrs?: Record<string, unknown>
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
interface AdfTextNode {
|
|
26
26
|
type: 'text'
|
|
27
27
|
text: string
|
|
28
28
|
marks?: AdfMark[]
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
interface AdfUnknownInlineNode {
|
|
32
32
|
type: string
|
|
33
33
|
[key: string]: unknown
|
|
34
34
|
}
|
|
@@ -40,7 +40,7 @@ export interface AdfParagraphNode {
|
|
|
40
40
|
content?: AdfInlineNode[]
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
interface AdfListItemNode {
|
|
44
44
|
type: 'listItem'
|
|
45
45
|
content: AdfBlockNode[]
|
|
46
46
|
}
|
|
@@ -50,7 +50,7 @@ export interface AdfBulletListNode {
|
|
|
50
50
|
content: AdfListItemNode[]
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
interface AdfOrderedListNode {
|
|
54
54
|
type: 'orderedList'
|
|
55
55
|
content: AdfListItemNode[]
|
|
56
56
|
attrs?: { order?: number }
|
|
@@ -62,7 +62,7 @@ export interface AdfCodeBlockNode {
|
|
|
62
62
|
content?: AdfInlineNode[]
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
interface AdfHeadingNode {
|
|
66
66
|
type: 'heading'
|
|
67
67
|
attrs: { level: number }
|
|
68
68
|
content?: AdfInlineNode[]
|
|
@@ -74,12 +74,12 @@ export interface AdfExpandNode {
|
|
|
74
74
|
content: AdfBlockNode[]
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
interface AdfUnknownBlockNode {
|
|
78
78
|
type: string
|
|
79
79
|
[key: string]: unknown
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
type AdfBlockNode =
|
|
83
83
|
| AdfParagraphNode
|
|
84
84
|
| AdfBulletListNode
|
|
85
85
|
| AdfOrderedListNode
|
|
@@ -88,9 +88,6 @@ export type AdfBlockNode =
|
|
|
88
88
|
| AdfExpandNode
|
|
89
89
|
| AdfUnknownBlockNode
|
|
90
90
|
|
|
91
|
-
// Public AdfNode union covers every node shape this module recognizes.
|
|
92
|
-
export type AdfNode = AdfDocument | AdfBlockNode | AdfListItemNode | AdfInlineNode
|
|
93
|
-
|
|
94
91
|
const BULLET_MARKER = /^[-*] (.*)$/
|
|
95
92
|
const ORDERED_MARKER = /^(\d+)\. (.*)$/
|
|
96
93
|
// Opening/closing fence: `` ``` `` optionally followed by a language tag with
|