feat(api): cookbooks + webhook audit_log + secret rotation (PR-500 carry-overs) (#501)

* docs(api): ship 4 cookbook recipes (close docs polish backlog)

Promotes the four placeholder cookbook entries to full narrative recipes
matching the Stripe-grade quality bar set by quickstart + webhooks.
Closes the docs follow-up bucket from the PR-500 description's deferred
list.

Recipes:

- ingest-bank-transactions: bank-file upload (CSV / CAMT.053 auto-detect)
  → async poll → list uncategorised → suggest-categories → categorize
  (single + batch) → match-invoice / match-supplier-invoice. Multicurrency
  notes covering Riksbanken FX lookup and the kontantmetoden partial-
  payment guard.

- file-vat-declaration: GET /reports/vat-declaration → rutor 05–62
  walkthrough → GL reconciliation block → 2026-04-01 livsmedel 12% → 6%
  transition explicitly covered (delivery_date supply-date rule) → voucher-
  gap pre-flight → period lock workflow → manual Skatteverket Mina Sidor
  submission with confirmation-reference capture → EU / reverse-charge
  / import handling.

- run-payroll-and-agi: draft → calculate → approve → mark-paid → book →
  generate-agi state machine. Per-step idempotency, strict-mode book
  failure semantics, förmånsbeskattning + bilförmån + bruttolöne­avdrag
  vs nettolöneavdrag ordering. AGI XML download for manual Mina Sidor
  upload (direct API submission requires BankID via the Skatteverket
  extension, not the public REST surface).

- year-end-closing: IB/UB continuity check per BFL 5 kap → voucher-gap
  pre-flight → missing-documents pre-flight → lock (reversible) → year-
  end async operation (resultatdisposition + periodiseringsfond +
  överavskrivningar + bolagsskatt + opening-balance batch) → close
  (irreversible per BFL 5 kap 8 §, typed-phrase confirmation) →
  årsredovisning + INK2/NE generation. Brutet räkenskapsår variant
  documented.

Each cookbook follows the same shape as the existing quickstart and
webhooks recipes — concrete curl commands, response samples, common
pitfalls, next-steps cross-links. Lengths are deliberately uneven: the
year-end recipe is longest because the consequences of getting it
wrong are most severe (BFL violations, irreversible close).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): V16 audit_log entries for webhook lifecycle + secret rotation endpoint

Two intertwined changes that together close the "real audit attribution
gap in actively-used routes" item from the PR description.

1. POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret

   New endpoint that issues a fresh HMAC signing secret and invalidates
   the previous one immediately. Returns the new secret EXACTLY ONCE in
   the response, mirroring the create-time contract. Required scope:
   webhooks:manage. Idempotency-Key mandatory.

   Rotation is instant — no grace period. Documented workflow: stage the
   new secret on the receiver side (separate config slot, not yet active)
   → POST /rotate-secret → activate the new secret on the receiver →
   POST /webhooks/{id}/test to verify. A "previous_secret" column with
   TTL-based grace window (Stripe-style) is the natural follow-up; the
   instant-rotation shape ships first because it closes the "secret
   leaked, need to rotate now" use case with minimum new surface.

   The route is wired into load-routes.ts and lib/auth/scopes.ts. Spec
   snapshot updated.

2. V16 audit_log entries on every webhook lifecycle mutation

   The audit_log column shape (user_id, company_id, action, table_name,
   record_id, actor_id, old_state, new_state, description) is exactly
   what V16 / Art.32(1)(b) / A.8.24 audit-trail requirements call for.
   Wired entries on:

   - POST /webhooks (create) — action INSERT, new_state captures the
     row WITHOUT the secret (signing material must not land in the
     audit trail; only secret-event metadata).
   - PATCH /webhooks/:id (update) — action UPDATE, before/after pair so
     reviewers can reconstruct exactly what changed.
   - DELETE /webhooks/:id (delete) — action DELETE, old_state snapshot
     so the row's prior state survives the delete.
   - POST /webhooks/:id/rotate-secret — action SECURITY_EVENT, new_state
     carries the event marker only (no secret value).
   - dispatcher.disableWebhook (auto-disable on HTTP 410 / redirect /
     url_unsafe) — action SECURITY_EVENT, before/after capturing the
     disable cause for SIEM correlation.

   actor_id is set to ctx.apiKeyId on caller-driven entries so the
   audit row points back to the specific API key that triggered the
   change (PR-500 round-1 CC6.3 finding: actor attribution via
   created_by_api_key_id alone leaves a gap if a key is deleted —
   keeping the actor_id in audit_log closes that).

   4 new integration tests cover the rotate-secret happy path, 404,
   401 unauthorized, and Idempotency-Key required. The existing
   webhook integration tests continue to pass because the audit_log
   inserts fall through to the default mock response (no-op) without
   disturbing the per-table queues.

39 integration tests pass on the webhook surface (+4 vs round-2).
Total: 3588 unit tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-501 review round 1 — correctness + Swedish compliance

Round 1 of review fixes. Two real correctness bugs Greptile caught, two
audit-trail gaps, and four Swedish-compliance errors in the cookbook
prose. Compliance Swarm has 17 findings (0 blocking); the 4 architectural
items (secret-at-rest encryption, dedicated rotate scope, rate-limit on
rotation, URL redaction) remain deferred with rationale.

Greptile (3 / 3 — all addressed):

1. rotate-secret silent 0-row UPDATE — fixed by adding
   `.select('id').maybeSingle()` to the UPDATE and returning NOT_FOUND
   when no row was touched. Closes the TOCTOU window between the
   existence check and the secret update; a concurrent DELETE no
   longer hands the caller a freshly-generated secret that no webhook
   in the database matches.

2. DELETE handler audit_log silently skipped when prior snapshot is
   null — fixed by writing the audit row UNCONDITIONALLY with
   `old_state: prior ?? null` and a degraded description when the
   snapshot is unavailable. A successful DELETE now always produces
   exactly one audit row (CC6.3 attribution contract).

3. Typo "bookslut" → "bokslut" in year-end-closing.ts.

Compliance Swarm code-quality items addressed:

4. PATCH new_state now derived from the DB-confirmed returned `data`
   with an explicit field allowlist, not from the request-body-derived
   `update` object (A.8.11 / V16.1.1). Closes the gap where a future
   trigger that rejects a field would leave the audit trail out of
   sync with the actual stored state.

5. All four route-side audit_log inserts (create, update, delete,
   rotate-secret) now capture the insert error and emit a structured
   warning via ctx.log; mirrors the dispatcher pattern (CC7.2).

6. Dispatcher null-user_id path now emits a structured warning instead
   of silently skipping the audit_log entry — SIEM can alert on the
   gap (CC7.2 / V16.1.1 / A.8.15).

Swedish compliance (cookbook content fixes — all real errors):

7. VAT cookbook ruta 06 label corrected: "Övrig försäljning (ej
   skattepliktig)" → "Momspliktig försäljning som inte ingår i ruta 05"
   (Skatteverket's verbatim label). The old label conflated exempt vs
   zero-rated supplies and would cause integrators to omit export /
   EU zero-rated sales from box 06.

8. Livsmedel rate-change framing rewritten: leads with the supply-date
   rule (ML 1 kap 3 §) as the decisive date, not invoice_date. The
   old opening sentence ("invoices created with invoice_date >=
   2026-04-01 book to 2631") was wrong on its face — a copy-paste
   reader would mis-book pre-cutover deliveries invoiced in April at
   the new 6% rate.

9. Reverse-charge EU 2645 note adds the blandad-verksamhet caveat:
   "Net zero impact on cash flow" only holds when full avdragsrätt
   applies; partial avdragsrätt requires proportional restriction
   per HFD 2023 ref. 45.

10. Payroll cookbook age bounds corrected: "under-25 / over-66" →
    "18-22 years old (born 2003-2007) / 67+ from 2026", per Prop.
    2025/26:66. The old bounds would cause integrators to apply the
    reduced rate (20.81%) to 23-24-year-olds who must pay 31.42%,
    producing non-compliant AGI files.

11. Payroll cookbook BAS 2615 corrected to 2731 (Avräkning sociala
    avgifter). 2615 is "Utgående moms vid import" in BAS 2026 — using
    it for the payroll liability would misclassify a payroll payable
    as an import-VAT payable and break moms reconciliation.

12. Year-end cookbook periodiseringsfond cap base corrected: IL 30
    kap 5 § cap is on taxable profit BEFORE the periodiseringsfond
    deduction itself (and after schablonintäkt is added back). Note
    on materiellt samband (BFNAR 2016:10 kap 13) added — the
    reservation is BOOKED on 2110-2139, not declaration-only.

Deferred to follow-ups (architectural / out of scope for round 1):

- Secret-at-rest encryption (CC6.1 / Art.5(1)(f)): PR-1 architectural
  carryover, applies to existing webhooks.secret column too.
- Dedicated `webhooks:rotate` scope (CC6.3 informational): introduces
  friction without closing a real gap when the only caller-driven
  action gated by `webhooks:manage` is the rotation itself.
- Per-route rate-limit on :rotate-secret (Art.32 abuse case): part
  of the wider per-route rate-limit pass already on the deferred list.
- webhook_url redaction in audit_log (Art.5(1)(c)): URLs are admin-
  supplied configuration values with no expected sensitive params;
  truncation would degrade audit value for legitimate review.

23 webhook integration tests pass locally (no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-501 review round 2 — atomic mutations + audit completeness + cookbook compliance

Round 2 of review fixes. Compliance Swarm flagged refinements to the
round-1 fixes; Swedish-compliance had a fresh batch of cookbook items
(including a self-contradiction in payroll pitfalls I missed last
round). All addressed.

Code changes — atomicity + audit completeness:

1. rotate-secret collapsed to a single UPDATE … RETURNING (V8.2.1).
   The preflight existence-check SELECT was redundant after round 1
   added .select().maybeSingle() on the UPDATE — the same null-row
   signal indicates non-existence, but in one round trip with no
   TOCTOU window. RETURNING `name` so the audit_log description still
   carries a human identifier without a second read.

2. DELETE handler collapsed to atomic .delete().select().maybeSingle()
   (V8.2.1). Eliminates the pre-read TOCTOU window entirely. A 0-row
   delete (already-deleted webhook) still returns 204 — idempotent
   DELETE — and the audit entry captures the attempt with old_state:
   null. Description discriminates the two cases ("deleted: name" vs
   "delete attempted on missing id").

3. Cache-Control: no-store, no-cache, must-revalidate, private on
   the rotate-secret response (Art.25). The HMAC secret is sensitive
   credential material returned exactly once; this header prevents
   any intermediary (CDN, proxy, gateway access log, browser cache)
   from persisting the response body in a store with a different
   retention policy than intended.

4. Dispatcher auto-disable now writes the audit_log entry
   UNCONDITIONALLY (A.8.15 / V16.1.1 / CC7.2). Previously a null
   prior snapshot or a legacy null user_id caused the audit row to
   be silently skipped — only a warn log was emitted. Now writes
   user_id=NULL when unavailable (post-multi-tenant-refactor schema
   allows it; row is invisible under user RLS but queryable under
   service-role review, which is correct for system-initiated
   SECURITY_EVENT records). Description discriminates the snapshot-
   available / snapshot-unavailable cases.

Swedish compliance — cookbook content fixes (all real errors):

5. VAT cookbook rounding rule corrected: SFL 22 kap 1 § mandates
   TRUNCATION of öre (Math.floor for positive amounts), not half-up
   rounding. Last round mislabeled this as "Math.round (half-up)";
   the SRU filing skill is canonical and uses truncation. Using
   Math.round would produce values that differ from Skatteverket's
   expectations and cause GL-reconciliation mismatches at the öre
   level.

6. VAT reconciliation block now includes 2614 (Utgående moms vid
   omvänd skattskyldighet, matches ruta 30). The previous list of
   2611/2621/2631/2641/2645 omitted 2614; a reconciliation that
   skips it would show rutor_match_gl: true even when the 2614
   balance is non-zero and un-reconciled.

7. Livsmedel rate-change adds a one-sentence caveat for continuous/
   subscription supplies — the supply-date framing in round 1 was
   too tight for cases where multiple deliveries roll up into a
   subscription. Confirms against ML 1 kap 3 § rather than
   assuming a single delivery date is decisive.

8. Payroll pitfalls bullet contradicted step 2 — "Employees under 26
   (2024 rule for 2026 birth year ≥ 2001)" rewritten to match step 2:
   "18–22 years old at the start of 2026 (born 2003–2007) AND 67+
   from 2026". An integrator reading only the pitfalls section
   would have applied the reduced rate too broadly, producing
   underpaid arbetsgivaravgifter and a non-compliant AGI.

9. Year-end periodiseringsfond cap now states schablonintäkt explicitly:
   1.94% × outstanding prior-year balance (SLR + 1% for 2026) is
   ADDED to taxable income before the 25% cap is computed. Last
   round mentioned the "BEFORE the periodiseringsfond deduction"
   ordering but elided the schablonintäkt step; omitting it
   produces a cap that's too low when prior-year reserves exist.

10. Year-end SRU format characterization corrected: SRU is plain text
    encoded in ISO 8859-1, NOT XML. iXBRL (XML-based) is the
    Bolagsverket digital annual-report format — a separate artefact
    for a separate authority. Round 1 conflated them.

Deferred (architectural / out of scope, documented in commit):

- Audit-log dead-letter queue / SIEM alert escalation (Art.32 /
  A.8.15): infra setup, not code-PR scope. The warn-on-failure path
  is the in-process surface; durable delivery is a SRE/SIEM concern.
- Secret encryption at rest (CC6.1): PR-1 architectural carryover.
- webhook_url + description redaction in audit_log (Art.5(1)(c)):
  URLs are admin-supplied configuration values; redaction would
  degrade audit reconstructibility without closing a real PII gap.
- PATCH old_state TOCTOU via Postgres function (CC6.3): the read-
  then-write pattern produces an append-only audit row capturing
  the read state; the small race window is non-load-bearing for
  audit purposes and a stored-procedure refactor exceeds the
  cost/value.

23 webhook integration tests pass locally (no regressions). Type-check
clean for all changed files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-501 review round 3 — real cookbook tax errors + cache-control on create

Round 3 closes two tax-impact errors in the cookbooks plus the
consistency gap on the create response. Compliance Swarm's remaining
findings are recurring architectural carryovers or oscillation against
prior rounds.

Real cookbook errors (would mislead integrators):

1. Schablonintäkt rate corrected. Round 2 hardcoded 1.94% — that's the
   2024 rate (SLR 0.94% + 1%). For 2026 SLR is 2.55%, so the rate is
   3.55%. A wrong rate produces a too-low add-back, a too-high
   periodiseringsfond cap, and an IL 30 kap compliance error for any
   integrator copying the cookbook number. Rewrite to describe the
   formula (SLR + 1%, where SLR is the Riksbank statslåneränta on
   30 Nov of the preceding year) with the 2026 figure as an example,
   and note the engine reads the canonical rate from `tax_rates`.

2. SRU format is a TWO-file pair, not one. Round 2 correctly said
   "plain text encoded in ISO 8859-1 (NOT XML)" but described it as a
   single file. Skatteverket requires both INFO.SRU (metadata header)
   AND BLANKETTER.SRU (declaration body) uploaded together — a
   single-file upload is rejected by their validation. Fix the prose
   to describe the two-file pair explicitly.

Code consistency:

3. POST /webhooks (create) now returns the same
   `Cache-Control: no-store, no-cache, must-revalidate, private` +
   `Pragma: no-cache` headers as the rotate-secret endpoint (A.8.12).
   Both endpoints return the HMAC secret exactly once; both need the
   same intermediary-cache prevention.

Smaller cookbook refinements (round 3 bot follow-ups):

4. VAT reconciliation block now includes 2615 (Utgående moms vid
   import, matches ruta 60) — the previous list covered 2611-2645
   but omitted import VAT. A reconciliation that skips 2615 would
   show rutor_match_gl: true falsely for any importer.

5. Service supply-date fallback statement qualified to "one-off
   service supplies where delivery and invoice coincide" — long-
   running service contracts (subscriptions, maintenance) have
   per-delprestation skattskyldighet and need an explicit
   delivery_date per billing cycle.

6. Payroll elder-reduction boundary clarified: "67 years or older
   AT THE START OF the income year (1 January 2026)" — a 66-year-
   old whose 67th birthday falls in February does NOT qualify in
   2026. Prevents misreading the pithy "67+ from 2026" as a
   birthday-during-year rule.

Bot oscillation (skipping with rationale documented here for posterity):

- Compliance Swarm Art.25 now asks to REMOVE webhook_url from DELETE
  old_state — direct contradiction with CC6.3's round-1 ask for
  complete attribution. webhook_url is admin-supplied configuration,
  not PII; keeping it preserves audit reconstructibility.

- Swedish-compliance flags the unconditional re-delete audit row as
  "polluting" the behandlingshistorik — direct contradiction with
  Compliance Swarm V8.2.1 + CC6.3 round-1 / round-2 asks for
  unconditional writes. The audit_log is operational, not BFL
  räkenskapsinformation (which lives on journal_entries and
  related tables under explicit immutability triggers). Audit
  trail completeness wins over BFL purity for this table.

Architectural carryovers (already documented in earlier commit
bodies as deferred to follow-up PRs):

- Secret encryption at rest (CC6.1, recurring)
- Audit-log dead-letter / SIEM alerting (Art.32 / A.8.15, infra)
- webhook_url userinfo stripping (A.8.11 low — URLs are admin-
  configured, no expected credentials; validating at registration
  would be a registration-time concern, not audit-time)

23 webhook integration tests pass. Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-15 21:31:47 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent afb21ea638
commit b94ed3bec2
13 changed files with 1260 additions and 22 deletions
@@ -0,0 +1,142 @@
/**
* /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret — POST :rotate-secret verb.
*
* Generates a fresh HMAC signing secret for the webhook and returns it
* EXACTLY ONCE in the response. The old secret is invalidated immediately
* — there is no grace period. Callers must coordinate the rotation:
*
* 1. Stage the new secret on the receiver (separate config slot,
* do NOT activate yet).
* 2. POST /rotate-secret.
* 3. Activate the new secret on the receiver.
* 4. POST /webhooks/{id}/test to verify the receiver accepts the new
* signature.
*
* Steps 2–3 are the window where in-flight deliveries from the dispatcher
* may carry the new signature; receivers must accept both for at most a
* few seconds. If your operational tolerance for that window is zero,
* disable the webhook before rotation (`PATCH active=false`) and re-enable
* after step 3.
*
* A "previous_secret" column with TTL-based grace period (Stripe-style)
* is the natural follow-up. v1 ships instant rotation as the simplest
* shape that closes the "secret leaked, need to rotate now" use case.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { generateWebhookSecret } from '@/lib/webhooks/signing'
const RotateSecretResponse = z.object({
id: z.string().uuid(),
secret: z.string(),
rotated_at: z.string(),
})
registerEndpoint({
operation: 'webhooks.rotate_secret',
method: 'POST',
path: '/api/v1/companies/:companyId/webhooks/:id/rotate-secret',
summary: 'Rotate the HMAC signing secret on a webhook.',
description:
'Generates a fresh HMAC signing secret for the webhook and returns it EXACTLY ONCE. The previous secret is invalidated immediately. There is no grace period — coordinate the rotation on the receiver side BEFORE calling this endpoint, or temporarily disable the webhook (PATCH active=false) to pause delivery while you swap secrets.',
useWhen:
'After a suspected secret leak, on a routine rotation cadence (Stripe pattern: every 90 days for compliance-grade integrations), or when changing the receiver implementation and you want to invalidate the old secret deliberately.',
doNotUseFor:
'Routine integration setup — the secret returned at create time is the canonical one. Recovering a lost secret (rotation does not recover the prior value; it issues a fresh one).',
pitfalls: [
'The secret is returned exactly once. If you lose this response, the recovery path is to rotate again.',
'In-flight deliveries between the rotation and the receiver-side update may fail signature verification on the new secret. Pause the webhook (PATCH active=false) first if your tolerance for that window is zero.',
],
example: {
response: {
data: {
id: 'a8f1…',
secret: 'whsec_…',
rotated_at: '2026-05-15T12:00:00Z',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'webhooks:manage',
risk: 'medium',
idempotent: false,
reversible: false,
dryRunSupported: false,
response: { success: RotateSecretResponse },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'webhooks.rotate_secret',
async (_request, ctx, params) => {
const { id } = await params.params
const newSecret = `whsec_${generateWebhookSecret()}`
const rotatedAt = new Date().toISOString()
// Single atomic UPDATE … RETURNING name. The preflight existence-check
// SELECT is unnecessary because PostgREST's .select(...).maybeSingle()
// on the UPDATE returns null when no row matched — which is the same
// signal (existence) the SELECT gave us, but in one round trip and
// without the TOCTOU window a separate SELECT introduces.
//
// RETURNING `name` so the audit_log description carries a human
// identifier without a second read.
const { data: updatedRow, error: updateErr } = await ctx.supabase
.from('webhooks')
.update({ secret: newSecret })
.eq('company_id', ctx.companyId!)
.eq('id', id)
.select('id, name')
.maybeSingle()
if (updateErr) return v1ErrorResponse(updateErr, ctx.log, { requestId: ctx.requestId })
if (!updatedRow) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const w = updatedRow as { id: string; name: string }
// Audit log entry — V16 security event. Records the rotation with
// actor attribution but NEVER the secret value itself (signing
// material must not land in the audit trail). new_state carries the
// event metadata; the secret is omitted by design. CC7.2 — surface
// a structured warning when the audit write fails so SIEM can alert.
const { error: auditErr } = await ctx.supabase.from('audit_log').insert({
user_id: ctx.userId,
company_id: ctx.companyId,
action: 'SECURITY_EVENT',
table_name: 'webhooks',
record_id: id,
actor_id: ctx.apiKeyId ?? null,
description: `Webhook secret rotated: "${w.name}"`,
new_state: { event: 'secret_rotated', rotated_at: rotatedAt },
})
if (auditErr) {
ctx.log.warn('audit_log insert failed for webhook rotate-secret', {
webhookId: id,
code: auditErr.code,
})
}
// Cache-Control: no-store prevents any intermediary (CDN, proxy,
// load-balancer access log, API gateway, browser cache) from
// persisting the response body. The HMAC secret is sensitive
// credential material returned exactly once — landing it in an
// intermediary log store with a different retention policy than
// intended would defeat the rotation's purpose (Art.25 / CC6.1).
return ok(
{ id, secret: newSecret, rotated_at: rotatedAt },
{
requestId: ctx.requestId,
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
Pragma: 'no-cache',
},
},
)
},
{ requireIdempotencyKey: true },
)
@@ -214,6 +214,16 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
)
}
// Capture prior state for the audit_log old_state field. One extra
// SELECT — cost is negligible for a manual webhook PATCH and the
// before/after pair is what makes the audit row reconstructible.
const { data: prior } = await ctx.supabase
.from('webhooks')
.select('name, description, webhook_url, active, disabled_at, disabled_reason')
.eq('company_id', ctx.companyId!)
.eq('id', id)
.maybeSingle()
const { data, error } = await ctx.supabase
.from('webhooks')
.update(update)
@@ -225,6 +235,40 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
if (!data) return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId })
// V16 audit log — webhook lifecycle event. Record the diff.
//
// new_state is populated from the DB-confirmed returned row (`data`)
// through an explicit field allowlist — NOT from the spread
// `update` object. Two reasons: (a) the post-UPDATE state is the
// ground truth, and a future column-level CHECK/trigger that
// rejects a field would leave the request-body-derived shape
// misleadingly out of sync (A.8.11 / V16.1.1); (b) the allowlist
// foreclosures any future widening of PatchWebhookSchema that
// accidentally pulls a sensitive field into the audit trail.
const changedFields = Object.keys(body)
const d = data as Record<string, unknown>
const { error: auditErr } = await ctx.supabase.from('audit_log').insert({
user_id: ctx.userId,
company_id: ctx.companyId,
action: 'UPDATE',
table_name: 'webhooks',
record_id: id,
actor_id: ctx.apiKeyId ?? null,
description: `Webhook updated: ${changedFields.join(', ')}`,
old_state: prior ?? null,
new_state: {
name: d.name,
description: d.description,
webhook_url: d.webhook_url,
active: d.active,
disabled_at: d.disabled_at,
disabled_reason: d.disabled_reason,
},
})
if (auditErr) {
ctx.log.warn('audit_log insert failed for webhook update', { webhookId: id, code: auditErr.code })
}
return ok(data, { requestId: ctx.requestId })
},
)
@@ -262,13 +306,46 @@ export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: strin
'webhooks.delete',
async (_request, ctx, params) => {
const { id } = await params.params
const { error } = await ctx.supabase
// Atomic delete + returning. One round trip captures both the
// deletion-confirmation row count and the deleted row's prior state
// for the audit_log entry — eliminates the pre-read TOCTOU window
// a separate SELECT introduced (V8.2.1). Idempotent DELETE: a 0-row
// delete (already-deleted webhook) still returns 204 because the
// resource is gone, which is the desired end state.
const { data: deleted, error } = await ctx.supabase
.from('webhooks')
.delete()
.eq('company_id', ctx.companyId!)
.eq('id', id)
.select('name, event_type, webhook_url, active')
.maybeSingle()
if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
// V16 audit log — webhook lifecycle event. Records the deletion
// UNCONDITIONALLY. When `deleted` is null (no row matched —
// idempotent re-delete or cross-tenant id), the audit row still
// captures the attempt: record_id + actor_id + action + timestamp
// is the minimum CC6.3 attribution contract; old_state degrades
// to null.
const p = deleted as { name: string; event_type: string; webhook_url: string; active: boolean } | null
const { error: auditErr } = await ctx.supabase.from('audit_log').insert({
user_id: ctx.userId,
company_id: ctx.companyId,
action: 'DELETE',
table_name: 'webhooks',
record_id: id,
actor_id: ctx.apiKeyId ?? null,
description: p
? `Webhook deleted: "${p.name}" (${p.event_type})`
: `Webhook delete attempted on missing id=${id} (idempotent or cross-tenant)`,
old_state: p,
})
if (auditErr) {
ctx.log.warn('audit_log insert failed for webhook delete', { webhookId: id, code: auditErr.code })
}
return noContent({ requestId: ctx.requestId })
},
)
@@ -72,6 +72,7 @@ import {
} from '../[id]/route'
import { POST as testWebhook } from '../[id]/test/route'
import { GET as listDeliveries } from '../[id]/deliveries/route'
import { POST as rotateSecret } from '../[id]/rotate-secret/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
@@ -640,6 +641,88 @@ describe('GET /api/v1/companies/:companyId/webhooks/:id/deliveries', () => {
})
})
// ──────────────────────────────────────────────────────────────────────
// POST /webhooks/:id/rotate-secret
// ──────────────────────────────────────────────────────────────────────
describe('POST /api/v1/companies/:companyId/webhooks/:id/rotate-secret', () => {
it('returns a freshly-minted secret EXACTLY ONCE on rotation', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
webhooks: { data: { id: WEBHOOK_ID, name: 'CRM sync' }, error: null },
}),
)
const res = await rotateSecret(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/rotate-secret`, {
method: 'POST',
}),
detailParams(COMPANY_ID, WEBHOOK_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.id).toBe(WEBHOOK_ID)
expect(typeof body.data.secret).toBe('string')
expect(body.data.secret).toMatch(/^whsec_/)
expect(body.data.rotated_at).toBeTruthy()
})
it('returns 404 NOT_FOUND when the webhook does not exist for this company', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
webhooks: { data: null, error: null },
}),
)
const res = await rotateSecret(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/rotate-secret`, {
method: 'POST',
}),
detailParams(COMPANY_ID, WEBHOOK_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
})
it('returns 401 UNAUTHORIZED when no Bearer token is supplied', async () => {
const req = new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/rotate-secret`,
{ method: 'POST' },
)
const res = await rotateSecret(req, detailParams(COMPANY_ID, WEBHOOK_ID))
expect(res.status).toBe(401)
})
it('requires an Idempotency-Key header (write endpoint)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
// Build request WITHOUT the Idempotency-Key header (default makeRequest adds it).
const req = new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/rotate-secret`,
{
method: 'POST',
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
},
)
const res = await rotateSecret(req, detailParams(COMPANY_ID, WEBHOOK_ID))
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
// ──────────────────────────────────────────────────────────────────────
// Cross-tenant URL guard (wrapper level)
// ──────────────────────────────────────────────────────────────────────
@@ -323,9 +323,51 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
// V16 audit log — webhook lifecycle event. Records creation + actor
// attribution. new_state captures the row WITHOUT the secret (signing
// material must not land in the audit trail). A failed audit write
// is logged structurally so SIEM tooling can alert on the gap
// (CC7.2) — we don't roll back the create on audit failure because
// the webhook itself is already persisted.
const created_row = data as Record<string, unknown> & { id: string }
const { error: auditErr } = await ctx.supabase.from('audit_log').insert({
user_id: ctx.userId,
company_id: ctx.companyId,
action: 'INSERT',
table_name: 'webhooks',
record_id: created_row.id,
actor_id: ctx.apiKeyId ?? null,
description: `Webhook created: "${body.name}" → ${body.webhook_url} (${body.event_type})`,
new_state: {
name: body.name,
event_type: body.event_type,
webhook_url: body.webhook_url,
api_version_pinned: API_V1_VERSION,
active: true,
},
})
if (auditErr) {
ctx.log.warn('audit_log insert failed for webhook create', {
webhookId: created_row.id,
code: auditErr.code,
})
}
// Secret returned exactly once. Caller must persist it on the receiver
// side — gnubok will not surface it on any subsequent endpoint.
return created({ ...(data as Record<string, unknown>), secret }, { requestId: ctx.requestId })
// Cache-Control: no-store mirrors the rotate-secret response (A.8.12 /
// Art.25) so no intermediary (CDN / proxy / gateway log / browser
// cache) persists the secret beyond the direct response chain.
return created(
{ ...created_row, secret },
{
requestId: ctx.requestId,
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
Pragma: 'no-cache',
},
},
)
},
{ requireIdempotencyKey: true },
)