@andypai/agent-kanban 0.8.0 → 0.9.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.
- package/README.md +10 -5
- package/package.json +8 -1
- package/src/__tests__/webhooks.test.ts +50 -1
- package/src/api.ts +1 -1
- package/src/mcp/index.ts +2 -10
- package/src/mcp/types.ts +1 -1
- package/src/metrics-spec.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 +26 -14
- package/src/providers/linear-cache.ts +4 -4
- package/src/providers/linear-client.ts +1 -1
- package/src/providers/postgres-linear-cache.ts +1 -1
- package/src/storage-config.ts +2 -4
- package/src/tracker-config.ts +2 -2
- package/src/webhook-events.ts +6 -2
- package/src/webhooks.ts +27 -1
package/README.md
CHANGED
|
@@ -20,7 +20,8 @@ That buys you a few things that are easy to miss at first:
|
|
|
20
20
|
|
|
21
21
|
## Documentation
|
|
22
22
|
|
|
23
|
-
- [`docs/
|
|
23
|
+
- [`docs/INDEX.md`](https://github.com/abpai/agent-kanban/blob/main/docs/INDEX.md) for the documentation index
|
|
24
|
+
- [`docs/SPEC_CONTRACT.md`](https://github.com/abpai/agent-kanban/blob/main/docs/SPEC_CONTRACT.md) for the repo proof menu and spec contract
|
|
24
25
|
- [`docs/workflow.md`](https://github.com/abpai/agent-kanban/blob/main/docs/workflow.md) for a common day-to-day workflow
|
|
25
26
|
- [`docs/mcp.md`](https://github.com/abpai/agent-kanban/blob/main/docs/mcp.md) for the reusable tracker MCP module
|
|
26
27
|
- [`docs/providers/linear.md`](https://github.com/abpai/agent-kanban/blob/main/docs/providers/linear.md) for Linear provider details
|
|
@@ -271,14 +272,18 @@ compatibility. To require authentication, set a token via `--token` or the
|
|
|
271
272
|
accepts `?token=<token>` instead.
|
|
272
273
|
- `/api/health` stays public (liveness probe).
|
|
273
274
|
- `/api/webhooks/*` are **not** covered by this token — they authenticate with
|
|
274
|
-
the provider webhook secret
|
|
275
|
+
the provider webhook secret when one is configured. Without that secret,
|
|
276
|
+
Linear/Jira webhooks run in local open dev mode and accept unsigned payloads.
|
|
275
277
|
- The bundled UI picks the token up once from `?token=` / `#token=` and stores
|
|
276
278
|
it in `localStorage`, so you can open `https://<host>/?token=<token>`.
|
|
277
279
|
|
|
278
280
|
`--tunnel` exposes the dashboard publicly, so it **refuses to start without a
|
|
279
|
-
token**.
|
|
280
|
-
|
|
281
|
-
|
|
281
|
+
token**. In Linear or Jira mode it also refuses to start unless the matching
|
|
282
|
+
webhook secret is set, so auth-exempt webhook routes cannot accept unsigned
|
|
283
|
+
public writes. CORS is off by default (same-origin); set
|
|
284
|
+
`KANBAN_ALLOWED_ORIGIN` (or `--allowed-origin`) to allow a specific cross-origin
|
|
285
|
+
browser client. CORS is origin hygiene, not an auth control — the token is the
|
|
286
|
+
security boundary.
|
|
282
287
|
|
|
283
288
|
### mcp
|
|
284
289
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
|
|
5
5
|
"homepage": "https://github.com/abpai/agent-kanban#readme",
|
|
6
6
|
"repository": {
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
|
+
"bootstrap": "bun install && cd ui && bun install",
|
|
49
|
+
"smoke": "bun src/index.ts --help",
|
|
48
50
|
"dev": "bun --watch src/index.ts",
|
|
49
51
|
"start": "bun src/index.ts",
|
|
50
52
|
"build": "bun build ./src/index.ts --target bun --outdir ./dist",
|
|
@@ -56,6 +58,10 @@
|
|
|
56
58
|
"check": "bun run lint && bun run typecheck && bun run ui:typecheck",
|
|
57
59
|
"test": "bun test",
|
|
58
60
|
"test:watch": "bun test --watch",
|
|
61
|
+
"knip": "knip",
|
|
62
|
+
"test:pg": "DATABASE_URL=${DATABASE_URL:-postgres://postgres:postgres@localhost:${KANBAN_PG_PORT:-5432}/kanban_test} bun test",
|
|
63
|
+
"pg:up": "docker compose -f docker-compose.postgres.yml up -d --wait",
|
|
64
|
+
"pg:down": "docker compose -f docker-compose.postgres.yml down -v",
|
|
59
65
|
"seed:test": "bun --env-file=.env.test scripts/seed-test-db.ts",
|
|
60
66
|
"serve": "bun src/index.ts serve",
|
|
61
67
|
"ui:dev": "cd ui && bun run dev",
|
|
@@ -83,6 +89,7 @@
|
|
|
83
89
|
"eslint-config-prettier": "^10.1.8",
|
|
84
90
|
"eslint-plugin-prettier": "^5.5.4",
|
|
85
91
|
"husky": "^8.0.3",
|
|
92
|
+
"knip": "^6.24.0",
|
|
86
93
|
"lint-staged": "^15.2.0",
|
|
87
94
|
"prettier": "^3.7.4",
|
|
88
95
|
"typescript": "^5.7.2"
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
headerLower,
|
|
7
7
|
verifyHmacSha256,
|
|
8
8
|
verifySha256HmacSignatureHeader,
|
|
9
|
+
webhookSignatureStatus,
|
|
9
10
|
} from '../webhooks'
|
|
10
11
|
import { JiraProvider, type JiraProviderConfig } from '../providers/jira'
|
|
11
12
|
import { JiraClient } from '../providers/jira-client'
|
|
@@ -98,7 +99,12 @@ describe('authorizeWebhook (F32)', () => {
|
|
|
98
99
|
signature: 'deadbeef',
|
|
99
100
|
verify,
|
|
100
101
|
})
|
|
101
|
-
expect(result).toEqual({
|
|
102
|
+
expect(result).toEqual({
|
|
103
|
+
handled: false,
|
|
104
|
+
unauthorized: true,
|
|
105
|
+
message: 'Invalid signature',
|
|
106
|
+
signatureStatus: 'invalid',
|
|
107
|
+
})
|
|
102
108
|
})
|
|
103
109
|
})
|
|
104
110
|
|
|
@@ -792,3 +798,46 @@ describe('webhook open dev mode when secret is unset', () => {
|
|
|
792
798
|
}
|
|
793
799
|
})
|
|
794
800
|
})
|
|
801
|
+
|
|
802
|
+
describe('webhookSignatureStatus', () => {
|
|
803
|
+
const verify = verifySha256HmacSignatureHeader
|
|
804
|
+
const body = '{"hello":"world"}'
|
|
805
|
+
|
|
806
|
+
test('no secret configured -> not_configured', () => {
|
|
807
|
+
expect(
|
|
808
|
+
webhookSignatureStatus({ secret: undefined, rawBody: body, signature: null, verify }),
|
|
809
|
+
).toBe('not_configured')
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
test('secret configured, no signature header -> missing', () => {
|
|
813
|
+
expect(webhookSignatureStatus({ secret: 's3', rawBody: body, signature: null, verify })).toBe(
|
|
814
|
+
'missing',
|
|
815
|
+
)
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
test('bad signature -> invalid; good signature -> valid', () => {
|
|
819
|
+
expect(
|
|
820
|
+
webhookSignatureStatus({ secret: 's3', rawBody: body, signature: 'sha256=nope', verify }),
|
|
821
|
+
).toBe('invalid')
|
|
822
|
+
expect(
|
|
823
|
+
webhookSignatureStatus({
|
|
824
|
+
secret: 's3',
|
|
825
|
+
rawBody: body,
|
|
826
|
+
signature: `sha256=${hmac('s3', body)}`,
|
|
827
|
+
verify,
|
|
828
|
+
}),
|
|
829
|
+
).toBe('valid')
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
test('authorizeWebhook rejections carry the verdict', () => {
|
|
833
|
+
const rejected = authorizeWebhook({
|
|
834
|
+
secret: 's3',
|
|
835
|
+
rawBody: body,
|
|
836
|
+
signature: 'sha256=nope',
|
|
837
|
+
verify,
|
|
838
|
+
})
|
|
839
|
+
expect(rejected?.signatureStatus).toBe('invalid')
|
|
840
|
+
const noHeader = authorizeWebhook({ secret: 's3', rawBody: body, signature: null, verify })
|
|
841
|
+
expect(noHeader?.signatureStatus).toBe('missing')
|
|
842
|
+
})
|
|
843
|
+
})
|
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.
|
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
|
|
|
@@ -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
|
|
@@ -168,7 +168,7 @@ export interface JiraIssueType {
|
|
|
168
168
|
name: string
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
interface JiraChangelogItem {
|
|
172
172
|
field: string
|
|
173
173
|
fieldtype?: string
|
|
174
174
|
fromString?: string | null
|
|
@@ -177,7 +177,7 @@ export interface JiraChangelogItem {
|
|
|
177
177
|
to?: string | null
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
interface JiraChangelogEntry {
|
|
181
181
|
id: string
|
|
182
182
|
author?: { accountId?: string; displayName?: string }
|
|
183
183
|
created: string
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
} from '../types'
|
|
14
14
|
import {
|
|
15
15
|
authorizeWebhook,
|
|
16
|
+
webhookSignatureStatus,
|
|
16
17
|
headerLower,
|
|
17
18
|
verifySha256HmacSignatureHeader,
|
|
18
19
|
type WebhookRequest,
|
|
@@ -51,9 +52,9 @@ import { WEBHOOK_SECRET_ENV, webhookSecretFromEnv } from '../tracker-config'
|
|
|
51
52
|
import { warnOnce } from './warn-once'
|
|
52
53
|
import { applyTaskFilters, forEachWithConcurrency, SyncGate, syncStatusFromMeta } from './sync-core'
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
|
|
57
58
|
if (!lastFullSyncAt) return true
|
|
58
59
|
const lastFullSyncAtMs = Date.parse(lastFullSyncAt)
|
|
59
60
|
if (!Number.isFinite(lastFullSyncAtMs)) return true
|
|
@@ -64,7 +65,7 @@ export function shouldRunFullReconcile(lastFullSyncAt: string | null, now: numbe
|
|
|
64
65
|
// priorities; the write path looks up the resolved name (case-insensitive)
|
|
65
66
|
// in the cached `jira_priorities` table, so renames that preserve the default
|
|
66
67
|
// casing still resolve.
|
|
67
|
-
|
|
68
|
+
const CANONICAL_TO_JIRA_DEFAULT: Record<Priority, string> = {
|
|
68
69
|
urgent: 'Highest',
|
|
69
70
|
high: 'High',
|
|
70
71
|
medium: 'Medium',
|
|
@@ -368,8 +369,8 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
368
369
|
}
|
|
369
370
|
}
|
|
370
371
|
|
|
371
|
-
// Fetch changelog per changed issue so
|
|
372
|
-
// `moved` trigger
|
|
372
|
+
// Fetch changelog per changed issue so a downstream consumer's
|
|
373
|
+
// poll-based `moved` trigger works. Server-side dedupe
|
|
373
374
|
// keyed on (issue_id, history_id, item_field) keeps this cheap
|
|
374
375
|
// even if the same issue is updated repeatedly.
|
|
375
376
|
await forEachWithConcurrency(page.issues, 5, async (issue) => {
|
|
@@ -884,36 +885,47 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
884
885
|
`[jira] ${WEBHOOK_SECRET_ENV['jira']} is not set — accepting webhook without signature verification (open dev mode)`,
|
|
885
886
|
)
|
|
886
887
|
}
|
|
888
|
+
const signature = headerLower(payload.headers, 'x-hub-signature')
|
|
889
|
+
const signatureStatus = webhookSignatureStatus({
|
|
890
|
+
secret,
|
|
891
|
+
rawBody: payload.rawBody,
|
|
892
|
+
signature,
|
|
893
|
+
verify: verifySha256HmacSignatureHeader,
|
|
894
|
+
})
|
|
887
895
|
const auth = authorizeWebhook({
|
|
888
896
|
secret,
|
|
889
897
|
rawBody: payload.rawBody,
|
|
890
|
-
signature
|
|
898
|
+
signature,
|
|
891
899
|
verify: verifySha256HmacSignatureHeader,
|
|
892
900
|
})
|
|
893
|
-
if (auth) return auth
|
|
901
|
+
if (auth) return { ...auth, signatureStatus }
|
|
902
|
+
const attachVerdict = (result: WebhookResult): WebhookResult => ({
|
|
903
|
+
...result,
|
|
904
|
+
signatureStatus,
|
|
905
|
+
})
|
|
894
906
|
let body: { webhookEvent?: string; issue?: JiraIssue } = {}
|
|
895
907
|
try {
|
|
896
908
|
body = JSON.parse(payload.rawBody) as typeof body
|
|
897
909
|
} catch {
|
|
898
|
-
return { handled: false, message: 'Invalid JSON body' }
|
|
910
|
+
return attachVerdict({ handled: false, message: 'Invalid JSON body' })
|
|
899
911
|
}
|
|
900
912
|
const event = body.webhookEvent ?? ''
|
|
901
913
|
const issue = body.issue
|
|
902
|
-
if (!issue) return { handled: false, message: `No issue in payload (${event})` }
|
|
914
|
+
if (!issue) return attachVerdict({ handled: false, message: `No issue in payload (${event})` })
|
|
903
915
|
|
|
904
916
|
if (event === 'jira:issue_deleted') {
|
|
905
917
|
await this.cache.deleteIssue(issue.id)
|
|
906
918
|
await this.cache.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
|
|
907
|
-
return { handled: true }
|
|
919
|
+
return attachVerdict({ handled: true })
|
|
908
920
|
}
|
|
909
921
|
|
|
910
922
|
if (event === 'jira:issue_created' || event === 'jira:issue_updated') {
|
|
911
923
|
const projectKey = issue.fields.project?.key
|
|
912
924
|
if (projectKey !== this.config.projectKey) {
|
|
913
|
-
return {
|
|
925
|
+
return attachVerdict({
|
|
914
926
|
handled: false,
|
|
915
927
|
message: `Ignoring issue from project '${projectKey ?? 'unknown'}'`,
|
|
916
|
-
}
|
|
928
|
+
})
|
|
917
929
|
}
|
|
918
930
|
await this.cache.upsertIssues([
|
|
919
931
|
toCacheIssue(issue, this.config.baseUrl, this.config.projectKey),
|
|
@@ -924,9 +936,9 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
924
936
|
})
|
|
925
937
|
}
|
|
926
938
|
await this.cache.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
|
|
927
|
-
return { handled: true }
|
|
939
|
+
return attachVerdict({ handled: true })
|
|
928
940
|
}
|
|
929
941
|
|
|
930
|
-
return { handled: false, message: `Unsupported event: ${event}` }
|
|
942
|
+
return attachVerdict({ handled: false, message: `Unsupported event: ${event}` })
|
|
931
943
|
}
|
|
932
944
|
}
|
|
@@ -316,10 +316,10 @@ export function upsertProjects(
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
// Bound per-value storage so repeated edits to a long description can't balloon the cache.
|
|
319
|
-
//
|
|
320
|
-
// backends clamp identically and the truncation marker can't drift
|
|
321
|
-
|
|
322
|
-
|
|
319
|
+
// clampActivityValue is shared with the Postgres cache (postgres-linear-cache.ts)
|
|
320
|
+
// so both backends clamp identically and the truncation marker can't drift.
|
|
321
|
+
const ACTIVITY_VALUE_MAX_CHARS = 4096
|
|
322
|
+
const ACTIVITY_TRUNCATION_SUFFIX = '…[truncated]'
|
|
323
323
|
const ACTIVITY_VALUE_BUDGET = ACTIVITY_VALUE_MAX_CHARS - ACTIVITY_TRUNCATION_SUFFIX.length
|
|
324
324
|
|
|
325
325
|
export function clampActivityValue(value: string): string {
|
|
@@ -694,7 +694,7 @@ export async function resolveLabelIdsForCreate(
|
|
|
694
694
|
return resolveLinearLabelIds(inputLabels, await client.listIssueLabels())
|
|
695
695
|
}
|
|
696
696
|
|
|
697
|
-
|
|
697
|
+
function resolveLinearLabelIds(
|
|
698
698
|
inputLabels: string[] | undefined,
|
|
699
699
|
availableLabels: LinearIssueLabel[],
|
|
700
700
|
): string[] | undefined {
|
|
@@ -15,7 +15,7 @@ import { parseProviderTeamInfo } from './team-info'
|
|
|
15
15
|
|
|
16
16
|
export type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
type LinearIssueRow = LinearTaskRow
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Postgres-backed cache/repository for the Linear provider. Mirrors the role of
|
package/src/storage-config.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { getDbPath } from './db'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export interface SqliteKanbanStorageConfig {
|
|
3
|
+
interface SqliteKanbanStorageConfig {
|
|
6
4
|
mode: 'sqlite'
|
|
7
5
|
sqlitePath: string
|
|
8
6
|
}
|
|
9
7
|
|
|
10
|
-
|
|
8
|
+
interface PostgresKanbanStorageConfig {
|
|
11
9
|
mode: 'postgres'
|
|
12
10
|
databaseUrl: string
|
|
13
11
|
}
|
package/src/tracker-config.ts
CHANGED
|
@@ -62,14 +62,14 @@ export interface LocalTrackerConfig {
|
|
|
62
62
|
syncIntervalMs?: number
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
interface LinearTrackerConfig {
|
|
66
66
|
provider: 'linear'
|
|
67
67
|
apiKey: string
|
|
68
68
|
teamId: string
|
|
69
69
|
syncIntervalMs?: number
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
interface JiraTrackerConfig {
|
|
73
73
|
provider: 'jira'
|
|
74
74
|
baseUrl: string
|
|
75
75
|
email: string
|
package/src/webhook-events.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `webhook_events` — a small, generically-named receipts table that the Postgres
|
|
3
3
|
* providers append to on every received webhook. It is *not* used by agent-kanban
|
|
4
|
-
* itself; it exists so an external consumer (
|
|
5
|
-
* panel) can show "did the sidecar receive/process a
|
|
4
|
+
* itself; it exists so an external consumer (for example a software factory's
|
|
5
|
+
* dashboard "Webhooks" panel) can show "did the sidecar receive/process a
|
|
6
|
+
* tracker webhook, and when".
|
|
6
7
|
*
|
|
7
8
|
* Ownership: agent-kanban owns and creates this table; consumers read it
|
|
8
9
|
* read-only. Columns a consumer can rely on:
|
|
@@ -94,6 +95,9 @@ export async function withWebhookRecording(
|
|
|
94
95
|
provider,
|
|
95
96
|
...meta,
|
|
96
97
|
status: webhookEventStatus(result),
|
|
98
|
+
...(result.signatureStatus === undefined
|
|
99
|
+
? {}
|
|
100
|
+
: { detail: { signatureStatus: result.signatureStatus } }),
|
|
97
101
|
})
|
|
98
102
|
return result
|
|
99
103
|
}
|
package/src/webhooks.ts
CHANGED
|
@@ -6,10 +6,14 @@ export interface WebhookRequest {
|
|
|
6
6
|
rawBody: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export type WebhookSignatureStatus = 'valid' | 'invalid' | 'missing' | 'not_configured'
|
|
10
|
+
|
|
9
11
|
export interface WebhookResult {
|
|
10
12
|
handled: boolean
|
|
11
13
|
unauthorized?: boolean
|
|
12
14
|
message?: string
|
|
15
|
+
/** Persisted signature verdict for this delivery (never a guess). */
|
|
16
|
+
signatureStatus?: WebhookSignatureStatus
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
/**
|
|
@@ -26,11 +30,33 @@ export function authorizeWebhook(opts: {
|
|
|
26
30
|
const { secret, rawBody, signature, verify } = opts
|
|
27
31
|
if (!secret) return null
|
|
28
32
|
if (!verify(secret, rawBody, signature)) {
|
|
29
|
-
return {
|
|
33
|
+
return {
|
|
34
|
+
handled: false,
|
|
35
|
+
unauthorized: true,
|
|
36
|
+
message: 'Invalid signature',
|
|
37
|
+
signatureStatus: signature ? 'invalid' : 'missing',
|
|
38
|
+
}
|
|
30
39
|
}
|
|
31
40
|
return null
|
|
32
41
|
}
|
|
33
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Signature verdict for a delivery, computed the same way authorizeWebhook
|
|
45
|
+
* decides authorization. Providers attach this to every WebhookResult so the
|
|
46
|
+
* receipts table persists a true verdict instead of leaving consumers to guess.
|
|
47
|
+
*/
|
|
48
|
+
export function webhookSignatureStatus(opts: {
|
|
49
|
+
secret: string | undefined
|
|
50
|
+
rawBody: string
|
|
51
|
+
signature: string | undefined | null
|
|
52
|
+
verify: (secret: string, rawBody: string, signature: string | undefined | null) => boolean
|
|
53
|
+
}): WebhookSignatureStatus {
|
|
54
|
+
const { secret, rawBody, signature, verify } = opts
|
|
55
|
+
if (!secret) return 'not_configured'
|
|
56
|
+
if (!signature) return 'missing'
|
|
57
|
+
return verify(secret, rawBody, signature) ? 'valid' : 'invalid'
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
export function verifyHmacSha256(
|
|
35
61
|
secret: string,
|
|
36
62
|
rawBody: string,
|