Files
accounted/app/api/v1/webhook-deliveries/[id]/retry/route.ts
T
Jakob WennbergandClaude Opus 4.8 fce6faff2c fix(api): stabilize report pagination + declare real { data, meta } envelope on v1 single/write endpoints (#811)
* fix(reports): stabilize fetchAllRows paging to stop doubled/dropped balances (#790, #791)

PostgREST `.range()` paging is only correct when the underlying query has a
stable TOTAL order. Several aggregating report queries (general ledger, trial
balance, grundbok, supplier/AR ledgers, etc.) paginated without `.order()`, so
on datasets larger than one 1000-row page Postgres could return rows in a
different order between requests — silently DUPLICATING or SKIPPING rows on a
page boundary and doubling or dropping financial totals.

- fetch-all.ts: document the ordering invariant and add an optional
  `dedupeBy` defense-in-depth that drops cross-page duplicates and warns when
  it fires (surfaces a missing `.order()` in logs instead of corrupting money).
- Add a stable `.order()` (line PK or account_number) to every paginated query
  in lib/reports/ and the account-balances route; pass `dedupeBy` on the
  money-aggregating line queries.
- Add fetch-all unit tests and update report test fixtures to carry row ids.

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

* fix(api): declare the real { data, meta } envelope on v1 single/write/204 endpoints (#794)

The OpenAPI generator derives each endpoint's documented body purely from its
registered `response.success` Zod schema, and that schema is never validated at
runtime — so a route could advertise a shape its handler never sends. #802
fixed this for list endpoints; the same drift was latent on single-resource and
write endpoints, which declared the bare resource schema instead of the
`{ data, meta }` envelope the handlers actually return.

- registry.ts: extend `ResponseMetaSchema` with the optional `audit` block and
  `partial_expansions` list that writes/expansions emit; add the `NoBodyResponse`
  sentinel so 204 DELETE handlers document a bare 204 instead of a phantom 200.
- Wrap every single/write endpoint's `response.success` in `dataEnvelope(...)`
  (or `NoBodyResponse` for 204s) across the v1 routes.
- Add a response-envelope contract test that fails CI if any JSON endpoint
  forgets to wrap its schema, with binary downloads and 204s as the only
  exemptions.

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

* fix(reports): extend paging dedupeBy to rc-basis-gaps and opening-balances

Address PR review: these two money-aggregating line queries already had the
stable `.order('id')` (so paging was correct) but didn't carry `id` in the
select, so they couldn't use the `dedupeBy` defense-in-depth that general-ledger
and trial-balance got. Select `id` and pass `dedupeBy: r => r.id` so the whole
report layer applies the ordering invariant consistently.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:42:50 +02:00

222 lines
9.5 KiB
TypeScript

/**
* /api/v1/webhook-deliveries/{id}/retry — POST :retry verb.
*
* Re-enqueues a `dead` delivery by INSERTing a fresh row pointing at the
* same payload, NOT by mutating the dead row in place (the immutability
* trigger blocks that). The new row enters `pending` and the dispatcher
* picks it up at next-minute boundary.
*
* Live (`pending` / `in_flight` / `failed`) deliveries cannot be retried
* via this endpoint — the dispatcher already retries failed ones, and the
* other states aren't terminal. Only `dead` (and `delivered`, for callers
* that explicitly want to redeliver a message) qualify.
*
* The route lives outside the /companies/{companyId}/ tree because callers
* referencing a delivery already have its id; nesting under company would
* force the receiver-debugging UI to round-trip company resolution from
* the delivery id. Tenancy is still enforced — the wrapper resolves the
* delivery's company_id via the row and verifies caller membership.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { minimisePayload } from '@/lib/webhooks/handler'
import { validateWebhookUrl } from '@/lib/webhooks/url-guard'
import { hasScope } from '@/lib/auth/api-keys'
registerEndpoint({
operation: 'webhook_deliveries.retry',
method: 'POST',
path: '/api/v1/webhook-deliveries/:id/retry',
summary: 'Retry a webhook delivery.',
description:
'Re-enqueues a dead (or delivered) delivery as a fresh pending row. The new delivery references the same webhook + payload; the dispatcher picks it up at the next per-minute cron tick. The original row is preserved in the audit log.',
useWhen:
'After a receiver outage you want to replay deliveries that died, or after fixing a receiver-side bug you want to redeliver a successful one.',
doNotUseFor:
'Retrying live deliveries (pending / in_flight / failed) — the dispatcher is already managing them.',
pitfalls: [
'Retrying a delivered delivery causes the receiver to see the event twice. Receivers MUST be idempotent (check the X-Gnubok-Delivery header).',
],
example: {
response: {
data: {
webhook_delivery_id: 'wh_dlv_NEW',
status: 'pending',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
// Special-case scope: this endpoint lives outside the companies/ tree
// but still belongs to the webhooks domain. Add to lib/auth/scopes.ts in
// the same commit as the v1 wiring.
scope: 'webhooks:manage',
risk: 'medium',
idempotent: false,
reversible: false,
dryRunSupported: false,
response: {
success: dataEnvelope(z.object({
webhook_delivery_id: z.string().uuid(),
status: z.literal('pending'),
})),
},
})
export const POST = withApiV1<{ params: Promise<{ id: string }> }>(
'webhook_deliveries.retry',
async (_request, ctx, params) => {
const { id } = await params.params
// Fetch the original delivery and its company to enforce tenancy.
const { data: original, error: lookupErr } = await ctx.supabase
.from('webhook_deliveries')
.select('id, webhook_id, company_id, event_type, payload, previous_attributes, api_version, status')
.eq('id', id)
.maybeSingle()
if (lookupErr) return v1ErrorResponse(lookupErr, ctx.log, { requestId: ctx.requestId })
if (!original) return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId })
type O = {
id: string
webhook_id: string
company_id: string
event_type: string
payload: Record<string, unknown>
previous_attributes: Record<string, unknown> | null
api_version: string
status: 'pending' | 'in_flight' | 'delivered' | 'failed' | 'dead'
}
const o = original as O
// Tenancy check — the wrapper does not have a companyId from the URL
// here (deliberate; see file header). Verify the caller is a member of
// the delivery's company.
const { data: membership, error: membershipErr } = await ctx.supabase
.from('company_members')
.select('company_id')
.eq('user_id', ctx.userId)
.eq('company_id', o.company_id)
.maybeSingle()
if (membershipErr) return v1ErrorResponse(membershipErr, ctx.log, { requestId: ctx.requestId })
if (!membership) {
// 404 (not 403) so we don't leak existence of the delivery to a
// non-member; matches the wrapper's standard pattern.
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
if (o.status !== 'dead' && o.status !== 'delivered') {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'status',
message: `Only dead or delivered deliveries can be retried (current: ${o.status}).`,
},
})
}
// Mirror the create-route elevated-scope gate. A key with only
// webhooks:manage must NOT be able to re-emit a salary_run.* / agi.*
// payload — those carry personnummer, lönesummor, skatteavdrag, and
// the original create call required webhooks:manage AND payroll:read.
// Retry checks the SAME pair against the CALLING key's scopes (which
// may differ from the key that created the webhook in the first place).
const PAYROLL_SENSITIVE = /^(salary_run\.|agi\.)/
if (PAYROLL_SENSITIVE.test(o.event_type) && !hasScope(ctx.scopes, 'payroll:read')) {
return v1ErrorResponseFromCode('INSUFFICIENT_SCOPE', ctx.log, {
requestId: ctx.requestId,
details: {
required_scope: 'payroll:read',
reason: `Retrying ${o.event_type} requires payroll:read in addition to webhooks:manage.`,
},
})
}
// Re-verify that the parent webhook still exists, still belongs to the
// delivery's company, and is still active immediately before INSERT.
// Closes the TOCTOU window between the membership check above and the
// INSERT — without this a webhook deleted in between would have its
// retry land in webhook_deliveries with a now-dangling webhook_id, and
// a webhook re-registered to a different company in between would let
// the caller redeliver an event to a webhook they never created.
const { data: webhook, error: webhookErr } = await ctx.supabase
.from('webhooks')
.select('id, webhook_url, active, disabled_at')
.eq('id', o.webhook_id)
.eq('company_id', o.company_id)
.maybeSingle()
if (webhookErr) return v1ErrorResponse(webhookErr, ctx.log, { requestId: ctx.requestId })
if (!webhook) {
// The original webhook no longer exists or is no longer in this
// company. There's nothing to redeliver to. 404, not VALIDATION_ERROR
// — the resource the caller targeted is genuinely gone.
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const w = webhook as { id: string; webhook_url: string; active: boolean; disabled_at: string | null }
if (!w.active || w.disabled_at) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'webhook.active', message: 'Webhook is disabled — re-enable before retrying.' },
})
}
// Re-run the SSRF guard against the webhook's CURRENT url. The URL
// may have changed via PATCH between the original delivery and this
// retry call. The dispatch-time guard would catch a malicious URL
// eventually, but allowing the INSERT first means a poisoned row
// sits in the queue until the next cron tick. Validating here closes
// the window — the retry refuses up-front and the audit trail gets
// a clean VALIDATION_ERROR rather than a deferred dispatch-time
// 'dead' row with reason='url_unsafe'.
const urlCheck = await validateWebhookUrl(w.webhook_url)
if (!urlCheck.ok) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'webhook.webhook_url', reason: urlCheck.reason, message: urlCheck.detail },
})
}
// Re-run the data-minimisation projection on the original payload
// before re-enqueueing. If the original delivery predates a
// minimisePayload tightening (e.g. a future projection drops more
// fields), the retry must not silently re-deliver the unminimised
// shape. Idempotent on already-minimised payloads.
const minimised = minimisePayload(o.payload)
const { data: replay, error: insertErr } = await ctx.supabase
.from('webhook_deliveries')
.insert({
webhook_id: o.webhook_id,
company_id: o.company_id,
event_type: o.event_type,
payload: minimised,
previous_attributes: o.previous_attributes,
api_version: o.api_version,
// Link the retry to the API request that triggered it. The
// original delivery's request_id is preserved on its own audit
// row; the retry gets a fresh correlation pointing at the
// :retry call.
request_id: ctx.requestId,
})
.select('id')
.single()
if (insertErr || !replay) {
return v1ErrorResponse(insertErr ?? new Error('insert returned no row'), ctx.log, {
requestId: ctx.requestId,
})
}
return ok(
{ webhook_delivery_id: (replay as { id: string }).id, status: 'pending' as const },
{ requestId: ctx.requestId },
)
},
)