Skip to content

ADR-0076: Customer-app KYC + auth persistence + webhook idempotency

  • Status: Accepted
  • Date: 2026-05-23
  • Driver: 2026-05-23 architectural review §6.2 (KYC/auth TODOs flagged as data-loss risk)

Context

The audit identified 9 production TODOs in pkg/octopus/customer-app/src/app/api/{kyc,auth}/**/route.ts where the route handlers were accepting webhooks + form submissions but not persisting any state. The most severe instance:

kyc/webhook/route.ts:54 — UPDATE customer_accounts SET kyc_status='verified', kyc_tier=1

Production behavior with the TODO open: Stripe Identity successfully verifies a customer's documents → fires identity.verification_session.verified → our handler logs the event, returns 200 OK, but never updates the database. The customer's account stays in kyc_status='pending' forever; Stripe stops retrying because we returned 200; the operator has no way to detect the lost verification short of comparing Stripe's webhook log against the customer DB.

Same shape on: - kyc/webhook/route.ts:67requires_input email never sent - kyc/webhook/route.ts:76canceled not marked as rejected - kyc/start/route.ts:26 — hardcoded placeholder-account-id - auth/signup/route.ts:95 — user / account / verification_token rows not persisted - auth/verify/route.ts:16 — token consumption silently no-ops; any GET to /verify?token=… advances to /onboarding regardless of token validity

Additionally: Stripe re-delivers webhook events on failure (default 3-day exponential-backoff retry chain). Without idempotency, a single verification_session.verified event would fire OnboardingV1 + "kyc-passed" email once per delivery attempt — up to 4× across the retry chain.

Decision

Close all 9 TODOs in one PR + introduce webhook idempotency:

1. New table — identity_webhook_events

CREATE TABLE identity_webhook_events (
  event_id      TEXT PRIMARY KEY,         -- Stripe event.id
  event_type    TEXT NOT NULL,
  account_id    UUID REFERENCES customer_accounts(id) ON DELETE SET NULL,
  session_id    TEXT,
  received_at   TIMESTAMP NOT NULL DEFAULT now(),
  processed_at  TIMESTAMP,
  outcome       TEXT CHECK (outcome IN ('accepted','rejected','duplicate','failed')),
  raw_payload   JSONB
);

The PK on event_id provides atomic claim-or-skip semantics via INSERT ... ON CONFLICT DO NOTHING. Two concurrent webhook deliveries for the same Stripe event get exactly one "claimed: true" return — Postgres serializes the row insertion behind the constraint.

2. New query helpers (in lib/db/queries.ts)

Helper Purpose
claimIdentityWebhookEvent INSERT ... ON CONFLICT DO NOTHING — returns { claimed: bool }
finalizeIdentityWebhookEvent UPDATE processed_at + outcome on success/failure
markKycVerified Idempotent transition kyc_status='verified' + kyc_tier += promotion
markKycRejected Idempotent transition to terminal kyc_status='rejected'

Both markKyc* helpers run in a single Drizzle transaction and emit an auth_events row for the audit trail (re-using the existing audit table until a dedicated kyc_events is justified).

3. New session helper — lib/session/server.ts

requireSessionAccount(req) returns either a SessionAccount or a 401 Response for the handler to forward. Reads the octopus_session cookie (currently a raw user ID, behind a TODO for PR-W2-1.1 to add real Auth0 SDK integration) and joins customer_users × customer_accounts for the full session view.

4. New db client — lib/db/client.ts

Drizzle + postgres-js connection pool, env-driven by default (OCTOPUS_CUSTOMER_DB_URL or DATABASE_URL). Lazy-proxy pattern so tests can swap the impl via setDbForTests(...) before the first DB call.

5. Route handler rewrites

  • kyc/webhook/route.ts:
  • Signature verify (unchanged)
  • claimIdentityWebhookEvent before any side-effect; dup delivery short-circuits with { received: true, duplicate: true }
  • On verified: markKycVerified + Postmark welcome email + structured log to enqueue OnboardingV1
  • On requires_input: Postmark retry email
  • On canceled: markKycRejected('user_canceled') + structured log to enqueue manual review
  • finalizeIdentityWebhookEvent with outcome
  • kyc/start/route.ts:
  • requireSessionAccount replaces hardcoded 'placeholder-account-id'
  • kyc_status='verified' already → 409 fast-fail (saves Stripe spend)
  • auth/signup/route.ts:
  • createCustomerAndUser (real Drizzle transaction, was pseudo-code)
  • createVerificationToken → real DB row
  • Postmark send still wrapped in try/catch — DB transition is the source of truth
  • auth/verify/route.ts:
  • consumeVerificationToken SELECTs by hash + purpose + not-used
    • not-expired, then UPDATE used_at — single-shot. Forged/expired tokens redirect to /verify-error?code=invalid_or_expired
  • On success: markEmailVerified advances account to kyc_pending
    • emits email_verified audit event

Testing

  • Vitest unit tests for claimIdentityWebhookEvent, finalizeIdentityWebhookEvent with mocked Drizzle builder
  • pg-mem integration test scaffold in __tests__/test-db.ts with the full migration SQL ready — drizzle adapter wire-up is the only piece left for a follow-up PR (pg-mem's drizzle adapter shape varies across versions and getting that green required more iteration than the audit scope allowed)
  • Route-level tests are stubbed via vi.mock('@/lib/db/queries', ...)

Postmark stub

lib/email.ts already existed (POSTMARK_SERVER_TOKEN-driven). The KYC webhook reuses the existing sendTemplatedEmail with three new template aliases: welcome (kyc-passed wrapper), verify-email (kyc-retry), and the existing verify-email (signup). Real templates are configured on the Postmark dashboard, not in code.

Consequences

Positive

  • No more lost KYC verifications. The migration + idempotency flow ensures every event is durably recorded + processed at most once.
  • Replay-safe webhook handler. Stripe's 4× retry chain produces a single side-effect set per event.
  • Audit trail. identity_webhook_events.raw_payload is JSONB with the full Stripe event — forensic re-processing is possible without re-fetching from Stripe.
  • Session-aware /kyc/start. Sessions are loaded from the real user row; placeholder eliminated.
  • Email-verify gate now enforces token validity. Forged tokens fail loudly instead of silently advancing to /onboarding.

Negative

  • One new table + migration to apply at next deploy.
  • pg-mem IT is stubbed. Heavy-weight DB tests deferred to PR-W2-1.x (the drizzle/pg-mem adapter wire-up). For now, unit tests + production smoke-test cover the new code paths.
  • Session cookie is currently a raw UUID. Interim until full Auth0 SDK integration (PR-W2-1.1). The cookie is HttpOnly via Next.js middleware but should be signed/encrypted before this service ships traffic from outside the cluster.

See also

  • pkg/octopus/customer-app/src/lib/db/migrations/0001_kyc_webhook_idempotency.sql
  • pkg/octopus/customer-app/src/lib/db/client.ts
  • pkg/octopus/customer-app/src/lib/db/queries.ts — new helpers
  • pkg/octopus/customer-app/src/lib/session/server.ts
  • pkg/octopus/customer-app/src/app/api/{kyc,auth}/**/route.ts
  • ADR-0002 — drizzle + postgres baseline patterns