@andypai/agent-kanban 0.8.1 → 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/package.json +1 -1
- package/src/__tests__/webhooks.test.ts +50 -1
- package/src/providers/jira-core.ts +23 -11
- package/src/webhook-events.ts +6 -2
- package/src/webhooks.ts +27 -1
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": {
|
|
@@ -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
|
+
})
|
|
@@ -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,
|
|
@@ -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
|
}
|
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,
|