@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.8.1",
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({ handled: false, unauthorized: true, message: 'Invalid signature' })
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 the poll-based
372
- // `moved` trigger in @garage/dispatch works. Server-side dedupe
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: headerLower(payload.headers, 'x-hub-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
  }
@@ -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 (Garage Band's Studio "Webhooks"
5
- * panel) can show "did the sidecar receive/process a tracker webhook, and when".
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 { handled: false, unauthorized: true, message: 'Invalid signature' }
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,