# Changelog — Main API (`backend/`)

All notable changes to the Idswyft Main API are documented in this file.

Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.12.14] - 2026-06-05

Closes Sentry issue
[NODE-EXPRESS-7](https://idswyft.sentry.io/issues/NODE-EXPRESS-7) — 14
events across 3 verification IDs and 2 client classes (Android `okhttp`
+ iOS Safari) all the same race: client POSTs `/front-document`, server
starts ~15-30s OCR pipeline, client read-timeout fires before response,
client retries. Server processes the retry, but state has already
transitioned past `AWAITING_FRONT`, so `session.submitFront()`'s
`assertStep` throws `SessionFlowError` → 409 + Sentry alert + duplicate
storage upload + duplicate engine OCR call.

### Fixed
- **`POST /api/v2/verify/:id/front-document` is now idempotent on
  retry** (`backend/src/routes/newVerification.ts`). The handler now
  hydrates session state BEFORE the storage upload and engine OCR call.
  If state is past `AWAITING_FRONT` (and not `FRONT_PROCESSING` or
  `HARD_REJECTED`), the handler returns the cached state with
  `idempotent_retry: true` and HTTP 200 instead of re-running OCR and
  throwing `SessionFlowError`. The retry-after-timeout pattern that
  previously produced duplicate work + a 409 now produces zero wasted
  work and lets the client recover its UI. Real out-of-order flow
  violations (calling front-document while the session is at a later
  step in a fresh session — currently impossible but theoretical) still
  surface as 409. The `HARD_REJECTED` 409 handler is unchanged.

- **`VE_FLOW` / `SessionFlowError` events no longer captured in Sentry**
  (`backend/src/instrument.ts`). The `beforeSend` hook now returns
  `null` for any `SessionFlowError`. With the idempotency fix in place,
  the remaining 409s would be real client bugs — the 409 response is
  the correct user-facing signal, not a Sentry alert to us.

### Added test
- New regression test in `routes.integration` covering the idempotent
  retry path (15/15 in suite, was 14/14).

## [1.12.13] - 2026-06-05

Maintenance release. Absorbs the Dependabot non-major dep-update bundle
that had accumulated during the Railway billing-suspended window. 41
minor/patch bumps across backend, engine, frontend, and shared. No
source code changes. `npm audit` cleared for the first time in months.

### Updated (non-major)
Backend — `@sentry/node`/`@sentry/profiling-node` 10.48 → 10.56,
`@supabase/supabase-js` 2.55 → 2.107, `axios` 1.15 → 1.17,
`@aws-sdk/client-s3`/`s3-request-presigner` 3.1000 → 3.1062, `pg` 8.20
→ 8.21, `@types/pg` 8.18 → 8.20, `sharp` 0.33 → 0.34, `ws` 8.18.3 →
8.21, `jsonwebtoken` 9.0.2 → 9.0.3, `validator` 13.15.26 → 13.15.35,
`dotenv` 17.3.1 → 17.4.2, `morgan` 1.10.1 → 1.11.0, `cors` 2.8.5 →
2.8.6, `express-validator` 7.3.1 → 7.3.2, `tsx` 4.21 → 4.22,
`@noble/ed25519` 3.0.1 → 3.1.0, `@noble/hashes` 2.0.1 → 2.2.0,
`tsc-alias` 1.8.16 → 1.8.17, `geoip-lite` 2.0.1 → 2.0.2.

Frontend — `@tanstack/react-query` 5.85 → 5.101, `react-hook-form`
7.62 → 7.77, `react-router-dom` 7.13 → 7.17, `react-qr-code` 2.0.18
→ 2.0.21, `@remotion/*` 4.0.435 → 4.0.472, `logrocket` 12.1.0 →
12.1.1, `autoprefixer` 10.4 → 10.5, `postcss` 8.5.12 → 8.5.15,
`terser` 5.43 → 5.48, `@typescript-eslint/eslint-plugin` 8.58 →
8.60, `eslint-plugin-react-refresh` 0.4.20 → 0.5.2.

Engine — `@zxing/library` 0.21 → 0.23, `onnxruntime-node` 1.24 →
1.26, `sherpa-onnx-node` 1.12 → 1.13, `canvas` 3.2.2 → 3.2.3.

### Verified
- ✅ `tsc --noEmit` clean against new lockfile (all 4 workspaces)
- ✅ `vitest run` — 1213 / 1215 pass; 2 pre-existing parallel-CPU
  timeout flakes (`brandingSettings`, `routes.integration`) confirmed
  passing in isolation
- ✅ `npm audit` clean
- ✅ Vercel preview deploys succeed
- No source code touched — only `package.json` × 4 + `package-lock.json`

## [1.12.12] - 2026-06-05

Hotfix release. Restores `staging.api.idswyft.app` and `api.idswyft.app`
after a Railway clean rebuild surfaced a latent TypeScript dependency
gap.

### Fixed
- **`@types/ws` added as explicit `devDependency`**
  (`backend/package.json`). `backend/src/config/database.ts:12` has
  `import ws from 'ws'` (the @supabase/realtime-js Node 20 shim from
  v1.12.4's `84373c1`). `@types/ws` was being resolved transitively in
  local `node_modules` and in Railway's cached build layers — typecheck
  passed everywhere and nobody noticed. After a Railway billing-resume
  cache purge on 2026-06-05, a clean rebuild fell back to a transitive
  resolution that no longer carries `@types/ws` and tsc failed at build
  step 14 with `error TS7016: Could not find a declaration file for
  module 'ws'`. Both production and staging main-api had been failing
  to redeploy since. Making the dep explicit closes the gap against any
  future cache purge or npm registry churn. No runtime change.

## [1.12.11] - 2026-06-04

Self-host release. Closes the remaining Supabase-specific blockers in
[`idswyft-community#38`](https://github.com/team-idswyft/idswyft-community/issues/38) —
the four migrations and the `migrate.ts` `_migrations` table init that
referenced `service_role` / `storage.buckets` and failed outright on
stock Postgres. v1.12.8 fixed the env-var mismatch and the lone
`uuid_generate_v4()` outlier; this release takes care of the rest.
Reporter [@miyachan](https://github.com/miyachan) on
[`idswyft-community#38`](https://github.com/team-idswyft/idswyft-community/issues/38).

### Fixed
- **Supabase-only migrations now self-skip on stock Postgres**
  (`supabase/migrations/53_create_branding_bucket.sql`,
  `57_add_rls_policies_all_tables.sql`,
  `59_enable_rls_migrations.sql`,
  `60_create_avatars_bucket.sql`). Each starts with a guard `DO` block
  that raises `SUPABASE_ONLY_MIGRATION_SKIPPED` if the `service_role`
  role isn't present. The runner catches this specific exception and
  records the migration as applied without executing the body — log
  line becomes `⏭️ <file> (skipped — Supabase-only, recorded as
  applied)` instead of the previous `⚠️ <file> skipped: <error>`
  produced by `MIGRATIONS_LENIENT=true`'s blanket error swallow.

  Cleaner audit trail (only Supabase-only migrations skip, not
  whatever else might fail), and the runner doesn't retry on every
  boot — the `_migrations` table records them as applied on first run.
  `MIGRATIONS_LENIENT="true"` remains in `docker-compose.yml` as
  belt-and-suspenders for any future migration that needs but forgets
  the guard.

- **`migrate.ts` `_migrations` table init split** —
  `backend/src/scripts/migrate.ts`. The table-create runs
  unconditionally; the RLS + `service_role` policy block now runs only
  when a role check (`SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_roles
  WHERE rolname = 'service_role')`) returns true. Previously the
  unconditional `CREATE POLICY ... TO service_role` failed before any
  migration file ran, leaving stock-Postgres installations in a
  half-initialized state.

### Notes
- Zero behavior change on cloud edition (Supabase): all four
  migrations are already in `_migrations`, runner skips by filename
  before reading SQL. Smoke-tested with `npm run migrate` against
  production — no new rows, no errors, no behavior delta.
- Existing self-host installations on stock Postgres with
  `MIGRATIONS_LENIENT=true`: these four migrations were being
  lenient-skipped on every boot. After this release they're cleanly
  recorded as applied on first run and never retry.

## [1.12.10] - 2026-06-04

Docs-only release. Closes four community-reported documentation gaps —
no runtime behavior changes. Reporters
[@miyachan](https://github.com/miyachan) on
[`idswyft-community#40`](https://github.com/team-idswyft/idswyft-community/issues/40)
and [@own3mall](https://github.com/own3mall) on
[`idswyft-community#42`](https://github.com/team-idswyft/idswyft-community/issues/42),
[`#43`](https://github.com/team-idswyft/idswyft-community/issues/43),
[`#45`](https://github.com/team-idswyft/idswyft-community/issues/45).

### Docs
- **Hosted-page integration guide rewritten to use the secure
  `verification_url` flow** (`frontend/src/pages/DocsGuides.tsx`).
  Options 1 (Redirect) and 2 (Iframe) previously documented `api_key`
  as a required URL parameter on the hosted verification page,
  teaching integrators an insecure pattern that exposed `ik_*` keys
  to browser history, referrer headers, server logs, and screenshots —
  and let the user call the backend directly with the leaked key,
  fully bypassing the verification flow. The backend has correctly
  implemented the secure flow since at least v1.7: `POST
  /api/v2/verify/initialize` returns a `verification_url` containing a
  single-use `session` token scoped to that verification, valid for
  one hour. Option 3 (SDK embed) was already using this pattern.
  The guide now leads with an amber security warning, a server-side
  Step 1 showing `POST /api/v2/verify/initialize` with `X-API-Key` in
  the header, and Steps 2-Option-1/Option-2 using the backend-issued
  `verification_url`. Reported by
  [@miyachan](https://github.com/miyachan).

- **`ocr_data` field discoverability**
  (`frontend/src/pages/DocsGuides.tsx`). `ocr_data` has been returned
  on the verification status response since v1.2, but the public guide
  never surfaced this. Added a cyan callout block listing the fields
  available in `ocr_data` (`full_name`, `date_of_birth`,
  `document_number`, `nationality`, `address`, with confidence scores)
  and the canonical use case ("cross-check against user-supplied form
  data"). Sample code in the redirect-callback example now dereferences
  `data.ocr_data.full_name` so the field name appears in copy-pastable
  code. Reported by [@own3mall](https://github.com/own3mall).

- **Self-host README — Custom Ports section** (`README.md`). The
  `verification_url` is built from `FRONTEND_URL`; if the operator
  picks a non-standard `IDSWYFT_PORT` in `.env` but doesn't update
  `FRONTEND_URL` to match, generated outbound URLs miss the port. The
  `install.sh` script does not auto-populate `FRONTEND_URL`. New
  subsection under the HTTPS section documents the `.env` shape and
  the `install.sh` gap. Reported by
  [@own3mall](https://github.com/own3mall).

- **Self-host README — Email (OTP login, credential delivery)
  section** (`README.md`). Self-host email goes through Resend; there
  is no SMTP path. The codebase reads only `RESEND_API_KEY`, but a
  reporter (helped by an AI assistant) tried inventing `SMTP_*` env
  vars that don't exist. New subsection spells out the Resend-only
  path with a four-step setup walkthrough, the
  `docker compose logs api | grep -i OTP` workaround for inspecting
  codes while Resend isn't yet configured, and an explicit note that
  the AI-hallucinated registration-allowlist env vars
  (`ALLOWED_ADMIN_EMAILS`, `AUTH_WHITELIST_EMAILS`) don't exist, with
  the reverse-proxy workaround and an invitation to file a feature
  request if a built-in allowlist would help. Reported by
  [@own3mall](https://github.com/own3mall).

### Internal
- **`CLAUDE.md` Git Workflow section** updated with a brief note
  explaining the expected `dev` vs `main` commit-graph divergence on
  the community mirror (synthetic sync commits + one-way deploy
  merges) so future investigations of "why is dev N commits behind?"
  land on the documented answer rather than re-investigating.

## [1.12.9] - 2026-06-04

Security + self-host release. Three internal SSRF findings closed
(`deepsec #125/#126/#127`), the API-key rotation endpoint brought to
parity with the create endpoint's security stack (`deepsec #129`), and a
self-host opt-in flag added for sandbox keys
([`idswyft-community#44`](https://github.com/team-idswyft/idswyft-community/issues/44)
reported by [@own3mall](https://github.com/own3mall)).

### Security
- **SSRF triple — three layers of defense against private-network
  egress** (`backend/src/utils/validateUrl.ts` rewrite +
  `backend/src/routes/batch.ts` + `backend/src/services/webhook.ts` +
  `backend/src/routes/{developer,platform,webhooks.ts}`). The original
  `validateWebhookUrl` / `validateDownloadUrl` inspected `URL.hostname`
  only as a string, so DNS pinning (`evil.example A 169.254.169.254`),
  redirect chasing (`https://attacker.com/r → 302 → 10.x.x.x`), DNS
  rebinding, decimal-encoded IPv4 (`http://2130706433/` = 127.0.0.1),
  and IPv4-mapped IPv6 (`[::ffff:127.0.0.1]`) all bypassed it. The
  webhook test handler additionally leaked `err.message` (containing
  `ECONNREFUSED <internal-ip>:<port>`) to the caller, turning it into a
  developer-accessible internal port scanner.

  Three layers replace the single broken layer:
  1. **Write-time validation** — validators are now async and call
     `dns.lookup({ all: true })`, rejecting if ANY resolved address is
     in a private/reserved range. CIDR-aware private-IP detection
     delegates to `ipaddr.js` (added as explicit dep — was already in
     `node_modules` transitively via Express). Decimal-IPv4 decoder
     handles the `URL.hostname` parser gap.
  2. **Use-time re-validation** — webhook delivery and webhook test
     re-run `validateWebhookUrl` immediately before sending, closing the
     window between registration and send where DNS could flip.
  3. **Connect-time gating** — `getSafeHttpAgent` / `getSafeHttpsAgent`
     return `http`/`https` Agents with a custom `lookup` callback that
     gates the kernel DNS result against `isPrivateIp` per socket. axios
     uses them via `httpAgent` / `httpsAgent`. This is the layer that
     defeats DNS rebinding — the IP is checked at connect time, not
     parse time.

  Plus `maxRedirects: 0` on every axios call and `redirect: 'manual'`
  on `fetch()` (via the new `safeFetch` wrapper) so application code
  never silently chases a 302 to an internal target. The webhook test
  handler no longer echoes `err.message` to the response — clients get a
  generic `"Delivery failed — verify the webhook URL is reachable"` and
  the structured `err.code` is logged internally only.

  46 new tests in `backend/src/utils/__tests__/validateUrl.test.ts`
  cover the private-IP detector across the standard IPv4/IPv6 ranges,
  every documented bypass vector, and `safeFetch`'s redirect refusal.

- **`/api-key/:id/rotate` brought to parity with the create endpoint's
  security stack** (`backend/src/routes/developer/apiKeys.ts`). The
  rotate endpoint was missing every guard the sibling create endpoint
  enforces: no `apiKeyRateLimit`, no `param('id').isUUID()` validation,
  no `validate` middleware, no `maxKeys` cap, no `NODE_ENV` / `is_sandbox`
  gate, and crucially no chained-rotation block — calling rotate
  repeatedly on the same key id minted unbounded active keys around one
  logical slot, each with a future-`expires_at`, all authenticating in
  parallel until staggered expirations fired. `gracePeriodHours` also
  silently coerced non-numeric input (e.g. `"abc"`) to the 168-hour cap
  via `Number(...) || 168` instead of rejecting it.

  Fixes: full middleware stack added (`apiKeyRateLimit` + `param`/`body`
  validators + `validate`); chained rotation refused if old key's
  `expires_at` is already in the future; `maxKeys` check counts active
  non-rotating keys of the same `is_sandbox` type, rejecting if the
  developer would exceed the 3-prod / 10-sandbox cap even ignoring the
  temporary grace-period overlap.

### Added
- **`IDSWYFT_ALLOW_SANDBOX` env var for self-host operators**
  (`backend/src/routes/developer/apiKeys.ts`, `.env.example`).
  Cloud edition enforces strict separation —
  `NODE_ENV=production` stacks mint only production keys,
  `NODE_ENV=development` stacks mint only sandbox keys. Self-host
  operators also run `NODE_ENV=production` but legitimately want sandbox
  keys for testing without polluting their real verification metrics.
  Setting `IDSWYFT_ALLOW_SANDBOX=true` in `.env` bypasses both directions
  of the gate (in both `POST /api-key` and `POST /api-key/:id/rotate`).
  Default unset preserves cloud-edition behavior unchanged. Documented in
  `.env.example` as a commented self-host option. Reported by
  [@own3mall](https://github.com/own3mall) on
  [`idswyft-community#44`](https://github.com/team-idswyft/idswyft-community/issues/44).

### Fixed
- **`webhook_deliveries.id` column default backfilled to `gen_random_uuid()`
  on existing installations**
  (`supabase/migrations/20260604_fix_webhook_deliveries_id_default.sql`).
  v1.12.8 changed the historical `20260319_add_webhook_deliveries.sql` to
  use `gen_random_uuid()` instead of `uuid_generate_v4()` so new
  installations work on stock Postgres without the `uuid-ossp` extension,
  but installations where 20260319 was already applied (cloud production
  on Supabase; self-hosters who installed `uuid-ossp` manually) still had
  the old DEFAULT recorded on the column — the migration runner skips
  already-applied entries by filename. This follow-up migration brings
  every existing installation to parity via `ALTER COLUMN ... SET DEFAULT
  gen_random_uuid()`, which is metadata-only (no table rewrite, no row
  scan). Cloud production was applied manually on 2026-06-04; self-hosters
  and dev environments pick it up on the next `npm run migrate`.

## [1.12.8] - 2026-06-04

Self-host bug-fix release. Unblocks community deployments outside the
docker-compose + Supabase happy path — AWS / ECS Fargate / RDS Aurora /
stock-Postgres operators previously hit three independent blockers in
sequence. Reporter [@miyachan](https://github.com/miyachan) on
`idswyft-community#38` and `#39`; S3 patch contributed by @miyachan via
PR `idswyft-community#41`.

### Fixed
- **S3 default credential chain support** on AWS deployments
  (`backend/src/services/storage.ts`) — the S3 client passed
  `credentials: { accessKeyId: config.storage.awsAccessKey!, ... }`
  unconditionally at all four call sites (store, getSignedUrl, download,
  delete). When deploying on AWS with IAM-role / EC2 instance profile /
  ECS task role / EKS IRSA, `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`
  are unset and the non-null assertions let `undefined` flow into the SDK,
  which rejected with "Resolved credential object is not valid" before
  any request was attempted. The `credentials` key is now included only
  when both env vars are present; otherwise the SDK falls through to the
  default provider chain (env → shared config → IMDSv2 → ECS metadata →
  STS). Patch contributed by [@miyachan](https://github.com/miyachan) via
  `idswyft-community#41`.

- **Migration runner SSL handling**
  (`backend/src/scripts/migrate.ts`) — `migrate.ts` was the only file in
  `backend/` reading the `DB_SSL` env var; every other Postgres consumer
  (`PgClient` adapter, `docker-compose.yml`) already uses `DATABASE_SSL`.
  Self-hosters who set `DATABASE_SSL=true` per the docker-compose
  convention found `npm run migrate` ignored it and refused to enable SSL
  against cloud Postgres like RDS Aurora. Accept `DATABASE_SSL` as the
  canonical name, fall back to `DB_SSL` with a deprecation warning,
  auto-enable SSL for non-local connections (matches PgClient's
  behaviour), and respect `DATABASE_SSL_REJECT_UNAUTHORIZED` for cert
  verification so the migrator and the API server behave identically
  end-to-end. Reported by [@miyachan](https://github.com/miyachan).

- **Stock-Postgres `uuid` function compatibility**
  (`supabase/migrations/20260319_add_webhook_deliveries.sql`) — the lone
  migration using `uuid_generate_v4()` (requires the `uuid-ossp`
  extension, not installed by default on stock Postgres or most managed
  Postgres services). Switched to `gen_random_uuid()` which ships in
  every Postgres ≥13 release. Every other migration in the directory
  already used the latter. Stock-Postgres self-hosters previously hit
  `function uuid_generate_v4() does not exist` and had to either install
  the extension manually or rely on `MIGRATIONS_LENIENT=true` to skip —
  the latter silently disabled webhook persistence on their stack.
  Existing installations are unaffected (changes the column `DEFAULT` for
  new inserts only; tables already created with the old definition keep
  it).

### Notes
- The remaining parts of `idswyft-community#38` (Supabase-specific RLS
  policies in migrations 53 / 57 / 59 / 60, and the `service_role`
  reference in `migrate.ts`'s `_migrations` table init) still rely on
  `MIGRATIONS_LENIENT=true` in docker-compose to mask the failures. A
  proper migration split for non-Supabase self-hosters is tracked
  separately.

## [1.12.7] - 2026-05-07

Bug fix release. Surfaces camera errors and restructures the iOS
camera-initialization lifecycle in the active-liveness flow. Reporter
[@dttran93](https://github.com/dttran93) on `idswyft-community#37`.

### Fixed
- **Active liveness camera silently fell back to static photo on iOS**
  (`frontend/src/components/liveness/ActiveLivenessCapture.tsx`) —
  three silent-failure paths in the active-liveness component were
  stranding iOS users on the legacy static-photo capture, which can
  never pass anti-spoofing thresholds (scores 0.62-0.67 vs ~0.85+
  needed):
  1. The `getUserMedia` catch logged to console and silently called
     `onFallback()`, with no UI signal to the user.
  2. `video.play().catch(() => {})` swallowed `play()` rejections — a
     documented iOS Safari failure mode the existing comment already
     acknowledged for desktop browsers.
  3. If either of the above happened, `streamReady` never flipped, the
     liveness hook stayed disabled, and the user stared at a frozen
     black video forever.

  Fix:
  - New `mapCameraError(err)` helper classifies errors into
    `{ message, allowFallback }`. Only `NotFoundError` (no camera
    hardware) takes the legacy fallback path — every other class
    (`NotAllowedError`, `NotReadableError`, `OverconstrainedError`,
    default) shows a UI error message and a Try Again button.
  - `play().catch()` now surfaces a UI error and clears the readiness
    timer so the 8-second stream-readiness timeout can't later
    overwrite the more specific message.
  - New 8-second stream-readiness timeout — if the `playing` event
    doesn't fire within 8 seconds after stream acquisition, surface
    "Camera failed to start within 8 seconds" rather than leaving
    the user with no signal.
  - Restructured component lifecycle: renders a "Start camera" intro
    screen first, then defers `getUserMedia()` to the button's
    `onClick` handler. This is the iOS-spec-compliant pattern for
    preserving the user-gesture context across the gUM call (iOS
    Safari rejects gUM calls that happen after any awaited microtask
    in an async handler). Applied always, not iOS-conditional, for
    a unified code path.
  - New camera-error retry button distinct from the existing
    challenge-failed retry — different stage of failure.

### Notes
- The deeper hygiene fix in `useActiveLiveness.ts` (the hook accepts
  an `onFallback` parameter it never invokes — a dead pass-through)
  was deliberately deferred to a separate Trello card to keep this
  patch focused on the iOS regression.

## [1.12.6] - 2026-05-07

Internal refactor — no behavior change. Patch release to keep the
`main` commit ↔ tagged-version invariant after the time-range helper
extraction landed on `dev`.

### Changed
- **Calendar-month range helpers** extracted from
  `backend/src/routes/developer/analytics.ts` into
  `backend/src/utils/timeRanges.ts` — exports `getMonthStart(now)`
  and `getNextMonthStart(now)`. The `/api/developer/stats` and
  `/api/developer/analytics` routes previously had two subtly
  different inline shapes for "first instant of this calendar
  month" (one used the 3-arg `Date` constructor, the other used
  `setDate(1)`). Folding them into shared helpers means a future
  timezone or month-boundary fix lands in one place. 10 unit tests
  cover edge cases (January/December boundaries, day-1
  idempotence, input non-mutation, `getNextMonthStart >
  getMonthStart` ordering invariant). Source: Trello card #114
  cheap-first-step (https://trello.com/c/92EKdHLs).

## [1.12.5] - 2026-05-07

Hotfix for staging + production crash on boot. Both Railway main API
deploys had been crashing on every fresh build with
`Error: Node.js 20 detected without native WebSocket support`. Patch
upgrade, no behavior change for healthy deploys.

### Fixed
- **Supabase client crash on Node 20**
  (`backend/src/config/database.ts`, `backend/package.json`) — recent
  versions of `@supabase/realtime-js` (a transitive of
  `@supabase/supabase-js`) require either native WebSocket (Node 22+)
  or an explicit `transport` option to be passed in `RealtimeClient`
  options. Our Railway runtime image is `node:20-slim`, so the new
  library version threw on `createClient(...)` at module init,
  crashing the process before any HTTP route registered. Fix wires
  the `ws` package as the realtime transport
  (`realtime: { transport: ws }`) and promotes `ws` from a transitive
  to an explicit `dependencies` entry so it can't disappear with
  future transitive churn. Forward-compatible — the transport option
  is a no-op once the runtime moves to Node 22+ with native
  WebSocket.

### Operational notes
- The deeper root cause is `RUN npm install` in `backend/Dockerfile`
  (line 27) — it re-resolves transitive deps from `package.json`
  ranges on every build, ignoring the lockfile. That's how a new
  `@supabase/realtime-js` version drifted in without any Idswyft
  code change. Switching to `RUN npm ci` is the right long-term fix
  but is deliberately out of scope here — `npm ci` would fail loudly
  on any `package.json` ↔ `package-lock.json` drift, which is the
  correct behavior but not what we want to discover during a prod
  incident. Tracked as a follow-up.
- A Node 22 upgrade would also remove the need for the shim
  (`@supabase/realtime-js` requires `transport` only on Node < 22),
  but Node 22 changes runtime defaults across TLS, streams, and
  module resolution, and the engine container's `onnxruntime-node`
  compatibility hasn't been validated on Node 22. Not a hotfix-tier
  change.

## [1.12.4] - 2026-05-01

Avatar / public-asset URL fixes for cloud production. No breaking
changes; safe patch upgrade. Two related fixes shipped together.

### Fixed
- **Avatars routed to wrong (private) Supabase bucket**
  (`backend/src/services/storage.ts`,
  `supabase/migrations/60_create_avatars_bucket.sql`) — avatar uploads
  with `STORAGE_PROVIDER=supabase` (the cloud production setting) were
  written to the default `identity-documents` bucket, which is private
  by design (it holds end-user passport / driver's-license images per
  GDPR Article 9). The resulting `getPublicUrl()` always 404'd
  ("Bucket not found") because the bucket isn't public-readable. New
  migration creates a dedicated public `avatars` bucket mirroring the
  pattern from migration 53 (branding bucket), with three RLS policies
  for public read + insert + update. The `storePublicAsset` switch in
  `storage.ts` now routes `folder === 'avatars'` to the new bucket.
  Cleanup nulls broken `developers.avatar_url` rows so the placeholder
  renders until users re-upload.
- **Cross-origin public-asset URL resolution for `local` and `s3`
  providers** (`backend/src/services/storage.ts`,
  `backend/src/routes/{auth,handoff,pageConfig,vaas}.ts`,
  `backend/src/routes/developer/{profile,settings}.ts`,
  `backend/src/config/index.ts`,
  `backend/src/types/index.ts`) — `storePublicAsset` returns relative
  `/api/public/assets/...` paths for the `local` and `s3` providers.
  In any deployment where the frontend and API live on different
  origins (e.g. `staging.idswyft.app` vs `staging.api.idswyft.app`),
  `<img src>` resolved to the frontend host and 404'd. New helper
  `resolvePublicAssetUrl()` reads `PUBLIC_ASSET_BASE_URL` and prepends
  it to relative URLs at API response boundary. Read-time resolution
  (rather than write-time) means any legacy relative URL in the DB
  gets fixed automatically once the env var is set, with no DB
  migration. Helper passes absolute http(s) URLs through unchanged
  (GitHub OAuth avatars, Supabase storage URLs, branding URLs set via
  PUT settings/branding, etc.). 8-test unit suite at
  `services/__tests__/publicAssetUrl.test.ts`. The team-idswyft cloud
  deployment uses `supabase` storage so this path is dormant in
  production, but it's the right fix for self-hosters on `s3` and for
  any future move off Supabase.

### Changed
- **CLAUDE.md compliance section** corrected: previously said "Cloud
  edition uses S3" — that was incorrect doc drift. Cloud edition uses
  Supabase storage (the `supabase` provider). Verification documents
  and selfies → private `identity-documents` bucket; developer
  avatars → public `avatars` bucket (new in this release); branding
  logos → public `branding` bucket. The `s3` provider is supported in
  code for self-hosters but not used on team-idswyft infrastructure.

### Operational notes
- Migration 60 was applied to the shared Supabase DB
  (`kcjugatpfhccjroyliku`, single-DB across staging+production) before
  this version was tagged. No DB work needed on deploy.
- Railway env: `STORAGE_PROVIDER` was reset to `supabase` on both
  `staging` and `production` environments for service
  `idswyfts-main-api` (replacing a stale `local` value that had been
  writing to ephemeral container disk with no volume mount). The new
  value activates on the next Railway redeploy, which this version
  bump triggers.
- Three orphan files still sit in `identity-documents/avatars/` on
  the live storage. Supabase blocks raw-SQL DELETE on
  `storage.objects` via a `protect_delete()` trigger, so the
  migration's cleanup is wrapped in an EXCEPTION-handling DO block
  that succeeds either way. Remove the orphans manually via Studio
  or the Storage API (tracked separately).
- `PUBLIC_ASSET_BASE_URL` is set on Railway staging
  (`https://staging.api.idswyft.app`). Setting it on production is
  optional — it's a no-op when storage returns absolute URLs, which
  the `supabase` provider always does.

## [1.12.3] - 2026-05-01

Dev-portal stat strip accuracy fix and SEO foundation. No breaking
changes; safe patch upgrade.

### Fixed
- **Dev portal stat strip** (`backend/src/routes/developer/analytics.ts`,
  `frontend/src/components/developer/ApiKeysSection.tsx`) — the three
  cards above the API Keys table now agree with the analytics charts
  rendered just below.
  - `GET /api/developer/stats` `monthly_usage` now counts the calendar
    month to date instead of a trailing 30-day window. The adjacent
    `/analytics` quota uses calendar-month math; the strip and charts
    now agree.
  - Response also exposes `period_start` and renames `period` from
    `'30_days'` → `'month'`. SDK consumers type `period` as a generic
    string, so this is non-breaking.
  - Frontend renames the middle card "Verifications" → "Verified",
    since the underlying value has always been
    `status === 'verified'` only — excluding failed, pending, and
    manual_review verifications.
  - Frontend hides the "Limit remaining" card when `isCommunity` is
    true (Community/self-hosted has no quota). Adds a new
    `.stats.cols-2` CSS modifier mirroring the existing `.cols-4`
    pattern, plus matching ≤760px responsive collapse.

### Added
- **SEO foundation** (`frontend/index.html`, `frontend/public/robots.txt`,
  `frontend/public/sitemap.xml`) — site previously had no
  crawl/indexing signals, no link-preview metadata, and no
  structured data.
  - New `robots.txt` allowing public marketing/docs surfaces and
    disallowing auth, per-session, capture-only, and programmatic
    routes; references absolute sitemap URL.
  - New `sitemap.xml` covering 11 public URLs (/, /demo, /pricing,
    five `/docs/*` routes, /legal, /status). Vercel serves these as
    static files from `public/` before the SPA rewrite — verified
    against the existing `/llms.txt` behavior; no `vercel.json`
    change needed.
  - `index.html` head now carries Open Graph and Twitter
    `summary_large_image` meta tags so links shared on Slack,
    Discord, LinkedIn, Twitter, and iMessage produce preview cards.
  - JSON-LD blocks for `Organization` and `SoftwareApplication`
    schema unlock rich-result eligibility.
  - Improved `<title>` and meta description target the open-source /
    self-host / Docker / GDPR-friendly / deterministic-decisions
    keywords that match the platform's positioning.
  - The `<link rel="alternate">` AI-search hooks (`llms.txt`,
    `llms-full.txt`) remain unchanged. Removed a redundant
    non-standard `<meta name="llms.txt">` tag — the alternate link
    is the proper mechanism.

### Notes
- No `<link rel="canonical">` or per-route `<title>`/description
  yet — those require react-helmet-async or vite-ssg. Tracked as a
  follow-up.
- Open Graph image is currently `vc-identity-card.png` (880×554) as
  an interim. Below LinkedIn's 1200×627 large-card threshold and
  Twitter's 1.91:1 recommendation, so previews will render but with
  reduced fidelity. A proper 1200×630 social card design is tracked
  separately.

## [1.12.2] - 2026-04-30

Frontend dev-portal redesign — adopts the spatial composition + component
vocabulary from the Anthropic AIDesigner handoff while keeping the cyan
brand accent. No backend changes.

### Changed
- **Dev portal** (`frontend/src/pages/DeveloperPage.tsx`) — sticky
  sidebar (Developers + Account groups, status/region/plan footer),
  topbar with breadcrumbs + theme toggle + mobile-only Settings/Sign-out
  fallbacks, IntersectionObserver tracking which section is in view.
  Outer `.app` is `max-width: 1320px` to align with the cloud-edition
  navbar.
- **API Keys section** — flat-bordered `.stats` strip, `.card` + `.tbl`
  table with `.pill` type badges, `.code-block` Quick Start.
- **Analytics** (6 charts: Volume, Rejection Reasons, Response Time,
  Quota Usage, Funnel, Webhook Deliveries) — moved to a 3-col flat
  `.analytics` grid; chart cells reduced to ~16:10 ratio for visual
  rhythm. Recharts internals unchanged.
- **Webhooks section** — config form uses the handoff `.field-grid`
  pattern; events use `.events-list` 2-col with sev pills; active
  webhooks render as `.endpoint` cards with `.led` status dots and
  expandable `.ep-body` containing secret + events + delivery log.
- Forge-pattern banner reused for the team-setup CTA + Page Builder card.

### Added
- **`frontend/src/styles/dev-portal.css`** — CSS variables for both
  themes scoped under `.dev-portal`, plus all handoff component
  classes. Native `<select>` chrome force-reset (`appearance: none` +
  custom inline-SVG chevron + explicit `<option>` colors) so dark mode
  doesn't fall back to OS-light dropdowns on Chromium.
- Theme toggle button in the dev-portal topbar.

### Known limitation
- Light theme renders correctly for the shell + main content
  (~80% of the visual surface) but inline-styled regions still
  reference `C.*` JS constants in `frontend/src/theme.ts` that resolve
  to fixed dark hex values at module load. Affected: Create Key /
  API Call Debug / Verification Detail modals, expanded session-grouped
  log subtable, webhook delivery rows, payload `<details>`,
  SettingsModal. Filed as a follow-up to migrate those to `var(--*)`
  strings.

## [1.12.1] - 2026-04-29

Security hardening — RLS on `public._migrations`.

### Fixed
- **Supabase lint "RLS Disabled in Public" on `_migrations`** —
  the migration tracking table was created by `migrate.ts`
  directly (not via a migration file), so it never received the
  RLS treatment that migration 57 applied to every other
  `public.*` table. Without RLS, anon/authenticated PostgREST
  requests could potentially read or modify migration-tracking
  state, which could cause real migrations to re-run or be
  silently skipped.

### Added
- **Migration 59** — `ENABLE ROW LEVEL SECURITY` +
  `service_role_all_migrations` policy + `REVOKE ALL` on
  `anon`/`authenticated`. Mirrors the migration-57 pattern.
  service_role bypasses RLS so the migrate.ts runner is
  unaffected; PostgREST anon/authenticated requests are denied.

### Changed
- **`backend/src/scripts/migrate.ts`** — extends the
  `CREATE TABLE IF NOT EXISTS _migrations` block with the same
  ALTER/CREATE POLICY/REVOKE statements, so a fresh DB
  initialization never has the lint warning. Idempotent
  (DROP POLICY IF EXISTS / CREATE POLICY pattern).

### Operational notes
- No env-var changes needed.
- Migration 59 already applied to the shared Supabase DB
  (single-DB across staging+production) before this version
  was tagged.
- Supabase Studio's lint UI will refresh on next scan.

## [1.12.0] - 2026-04-29

Platform webhook surface for service keys + payload enrichment.

Closes the architectural gap from the GatePass spec review:
verifications driven by `isk_*` keys reference shadow developer
rows, so existing webhook lookup (`WHERE developer_id = X`)
returned 0 rows for service-key calls — no webhooks fired.
Resolution: register webhooks directly on shadow developer rows
via a new platform endpoint surface, and enrich the payload +
headers with service-key context so receivers like GatePass
can route to the correct internal workspace.

### Added

- **`POST/GET/POST :id/rotate/DELETE /api/platform/webhooks`** —
  cloud-only endpoints (`X-Platform-Service-Token` auth) that
  register/list/rotate/delete webhooks against shadow developer
  rows. Mounted via dynamic import + top-level `await` in
  `server.ts`. Stripped from community mirror via
  `.community-ignore`.
  - SSRF guard (HTTPS-only, mirrors dev-portal flow)
  - Duplicate guard (same URL + sandbox + product)
  - One-time plaintext signing secret returned on register/rotate
  - Rotate/delete restricted to known shadow developer UUIDs (set
    populated lazily on first use, fail-closed on lookup error)
- **Webhook payload enrichment** (additive, backwards-compatible):
  - `is_service: boolean`
  - `service_product: 'gatepass' | 'idswyft-internal' | null`
  - `service_environment: 'production' | 'staging' | 'development' | null`
- **Webhook headers** emitted only when `is_service=true`:
  - `X-Idswyft-Is-Service`
  - `X-Idswyft-Service-Product`
  - `X-Idswyft-Service-Environment`
- **CLI subcommands** (`backend/scripts/mint-service-key.ts`):
  - `sk -e <env> webhook register --product <p> --url <https-url>`
  - `sk -e <env> webhook list [--product <p>]`
  - `sk -e <env> webhook rotate <id>`
  - `sk -e <env> webhook delete <id>`
  - One-time plaintext signing secret rendered in a yellow
    highlighted box, plus saved to `~/.idswyft-keys/<timestamp>-
    <product>-webhook-secret.json` (chmod 0600).
  - Audit log appends `webhook-register`, `webhook-rotate`,
    `webhook-delete` events (no plaintext).

### Fixed

- **`GET /api/platform/webhooks` 500** — initial implementation
  selected `last_attempted_at` from the `webhooks` table, which
  doesn't exist (timestamp lives on `webhook_deliveries`). Caught
  during live staging smoke test; unit tests had passed because
  the mock returned the field. Lesson logged in the
  service-key-deferred-ui memory note.

### Tests

- 14 → 18 platform-webhook router tests (rotate happy path
  added)
- 11 → 16 `buildWebhookHeaders` tests (4 cases for service-key
  context: absent on `ik_*`, present on `isk_*`, partial when
  fields are null)
- Suite total: 84 files / 1151 tests / 0 failures

### Operational notes

- Production already has `IDSWYFT_EDITION=cloud` and
  `IDSWYFT_PLATFORM_SERVICE_TOKEN` set from v1.11.0, so no
  env-var work needed on this deploy.
- Migration 58 (service-key columns + shadow developers) is
  already applied to the shared Supabase DB. No DB work needed.

### Spec / docs

- `docs/specs/2026-04-28-gatepass-service-key.md` updated with
  shipped status + corrections (gitignored, internal)
- `docs/onboarding.md` Service Keys section already covers the
  shadow-developer pattern; webhook subsection deferred until
  this lands

### Code review

`superpowers:code-reviewer` audit on the feature branch flagged
HTTPS-only, UUID-set guard, and 3 test-coverage gaps (rotate
happy path, `buildWebhookHeaders` Phase 2 unit tests). All
addressed in commit `eeb56e3` before merge.

## [1.11.1] - 2026-04-29

Operational tooling — TypeScript CLI for service-key management.
No runtime behavior change.

### Added
- **`backend/scripts/mint-service-key.ts`** — `tsx`-runnable CLI
  wrapping the `/api/platform/api-keys/service` endpoints with
  safety rails the curl recipes leave to operator discipline:
  - Plaintext keys never printed to stdout — written to
    `~/.idswyft-keys/<timestamp>-<product>-<env>.json` with
    `chmod 0600` (operator copies from file into Railway env vars).
  - Production operations require typing `production` to confirm
    (prevents `up-arrow + enter` accidental prod mints).
  - All operations append to `~/.idswyft-keys/audit.jsonl`
    (timestamp, event, id, prefix, product, env, file path —
    no plaintext).
  - After mint/rotate, automatically calls list to verify the
    operation landed.
  - Color-coded env labels (red production / yellow staging /
    green development).
  - Token length sanity check (warns on < 32 chars).
  - Subcommands: `mint`, `list`, `rotate`, `revoke`,
    `launch-gatepass` (one-shot mint of dev + staging + prod
    GatePass keys), `help`.

### Changed
- **`backend/scripts/mint-service-key.md`** restructured to point
  at the script as the preferred path. Curl recipes retained as
  fallback for environments without Node.js or for debugging the
  underlying HTTP behavior.

### Cloud-only
- Both files (`.ts` and `.md`) are in `.community-ignore` and
  stripped from the public mirror via `sync-community.yml`. The
  `.gitignore` exception list now permits both to land in the
  private repo (alongside the existing
  `rotate-encryption-key.ts` + `encryption-key-rotation.md`
  pattern).

## [1.11.0] - 2026-04-29

Service keys (`isk_*`) — a new class of API key for internal Idswyft
products to call the verification API without hitting customer-facing
rate limits, quotas, or plan-tier gates. First consumer: GatePass.

Cloud-only feature: schema + middleware bypasses ship to both
editions (inert in community), but the minting endpoints + auth
middleware + curl recipe are stripped from the public mirror via
`.community-ignore`. Gated at runtime via `IDSWYFT_EDITION=cloud`.

### Added
- **Migration 58 — service-key schema** — adds `is_service`,
  `service_product`, `service_environment`, `service_label` columns
  to `api_keys` with CHECK constraints (service-key fields are
  all-or-nothing; product enum: `gatepass`/`idswyft-internal`;
  env enum: `production`/`staging`/`development`). Adds
  `is_service` + `service_product` to `api_activity_logs` for
  dashboard filterability without joins. Inserts two shadow
  developer rows (`service+gatepass@idswyft.app`,
  `service+internal@idswyft.app`) so service keys can populate
  the existing `developer_id` FK without 41-site code changes.
- **`isk_*` key resolution in `authenticateAPIKey`** — surfaces
  `is_service`, `service_product`, `service_environment` on
  `req.apiKey`; sets `req.isService` convenience flag.
- **Sandbox/premium short-circuits for service keys** —
  `checkSandboxMode` skips the production/sandbox validation
  (service keys are scoped via `service_environment`, not
  `is_sandbox`); `checkPremiumAccess` marks service keys as
  `isPremium=true` (internal principal full access).
- **Rate-limit + verification-cap bypass** — `rateLimitMiddleware`
  and `verificationRateLimit` short-circuit when
  `req.apiKey?.is_service === true`. Bypass is BEFORE the DB
  lookup so service keys never increment counters.
- **Global IP rate limiter skip for `X-API-Key` callers** —
  `server.ts:112` `express-rate-limit` now skips when an API key
  header is present (per-key throttling takes over downstream).
  Closes the GatePass-egress-IP-saturation gap that would
  otherwise trip the IP bucket in seconds.
- **Audit log denormalization** — `apiActivityLogger` stamps
  `is_service` + `service_product` on every row so dashboards
  can query "GatePass calls last 24h" with a single
  `WHERE service_product='gatepass'` filter, no join required.
- **Platform service-key endpoints** (cloud-only,
  `/api/platform/api-keys/service*`) — POST mint, GET list,
  POST :id/rotate, DELETE :id. Auth via new
  `authenticatePlatformServiceToken` middleware that validates
  `X-Platform-Service-Token` with `crypto.timingSafeEqual` and
  fails closed when `IDSWYFT_PLATFORM_SERVICE_TOKEN` is unset.
  Rotate preserves product/env/label and revokes the old key in
  one operation; partial-failure path returns 207.
- **`generatePrefixedAPIKey()` shared helper** — refactored from
  `generateAPIKey()` so both `ik_*` (developers) and `isk_*`
  (services) share entropy + HMAC-SHA256 hashing. 32-byte random
  bytes, formatted as `<prefix>_<hex>`, hashed under
  `config.apiKeySecret`.
- **`backend/scripts/mint-service-key.md`** — curl recipe for
  the platform team. Operating procedure until the platform-admin
  UI ships in `idswyft-vaas`. Includes telemetry SQL queries and
  rotation procedure.

### Operational requirements

Cloud production + staging Railway env vars (must be set before
service-key minting works):

- `IDSWYFT_EDITION=cloud` — gates platform endpoint mounting
- `IDSWYFT_PLATFORM_SERVICE_TOKEN=<openssl rand -hex 32>` — token
  validated by `authenticatePlatformServiceToken`. Stored in
  1Password under "Idswyft / Platform Service Token".

Without these, the platform endpoints don't mount (silent skip)
and any platform request returns 503. The migration runs
regardless and is harmless when no rows have `is_service=true`.

### Tests
- 30 new test cases across 4 files (auth resolver, rate-limit
  bypass, platform endpoints, end-to-end integration). Suite
  total: 83 files / 1129 tests / 0 failures (was 79/1099
  baseline).
- Code review via `superpowers:code-reviewer` flagged 1 HIGH
  (IP rate limiter throttling service keys), 2 MEDIUM (loose
  500 acceptance, missing rotate tests). All addressed in
  follow-up commit `6948639`.

### Deferred
- **Admin UI** — lives in `idswyft-vaas/platform-admin/` (separate
  repo, not deployed yet). Curl recipe replaces UI until vaas
  ships. See memory `service-key-deferred-ui.md`.
- **Mint-endpoint rate limit** (10/hr fat-finger guard from spec
  Phase 5) — only callers with the platform service token can hit
  the endpoint, narrow attack surface. Deferred to follow-up.
- **`developer_id=null` for service-key audit rows** (plan said
  null; implementation uses shadow developer ID). Deviation is
  correct — preserves FK integrity. Plan phase-4 acceptance
  criterion will be updated.

### Spec
- `docs/features/2026-27-04-idswyft-service-key`
- Plan: `docs/plans/2026-04-28-idswyft-service-key.md` (gitignored)

## [1.10.2] - 2026-04-28

Vulnerability triage of the GitHub Dependabot backlog (89 advisories
flagged after Sprint 3 ship). Local `npm audit` showed zero
high/critical findings — all moderate. Auto-fixed every runtime
advisory that had a non-breaking patch path; documented the rest
with mitigations. Triage notes in
`docs/security/2026-04-27-vulnerability-triage.md` (gitignored,
internal).

### Security
- **Runtime dep bumps via `npm audit fix`** — applied non-breaking
  patches across all workspaces:
  - `follow-redirects` 1.15.11 → 1.16.0 (cross-domain auth-header
    leak; reachable via axios + outbound webhooks/AML lookups)
  - `fast-xml-parser` 5.5.8 → 5.7.2 (XML/CDATA injection;
    transitive via `@aws-sdk/client-s3`)
  - `postcss` 8.5.6 → 8.5.12 (XSS via unescaped `</style>`)
  - `dompurify` 3.3.3 → 3.4.1 (sanitizer bypasses; transitive
    optional via `jspdf`, vulnerable features unused)
- **`backend/file-type` 19.6.0 → 21.3.4** — closes the ASF parser
  infinite-loop DoS (GHSA-9vrp-r5w4-94mj). Reachable via the upload
  validation middleware, which calls `fileTypeFromBuffer` on every
  uploaded image. v22 was the cleanest fix per `npm audit fix --force`
  but ships pure-ESM types incompatible with our `moduleResolution:
  node`. v21.3.4 is the minimum patched version per the GHSA's
  `vulnerableVersionRange: <21.3.1` and ships compatible types.
- **`engine/file-type` direct dep removed** — declared in
  `engine/package.json` but never imported in `engine/src`. The
  transitive `file-type@16.5.4` via `jimp@0.x` still has the same
  ASF advisory, but is unreachable in practice: the API edge
  (`backend/src/middleware/fileValidation.ts`) validates uploads
  with patched file-type@21.3.4 BEFORE forwarding to engine, and
  the engine listens only on Railway's private network.

### CI
- **`npm audit` job flipped to blocking** — the S3.4 advisory job
  (`.github/workflows/ci.yml`) had `continue-on-error: true` so the
  pre-existing 89-advisory backlog wouldn't block all PRs. After
  this triage there are zero high/critical findings at production
  scope (`--omit=dev`), so the gate is safe to enforce. Threshold
  stays at `--audit-level=high`; moderate findings still surface in
  the log without blocking.

### Deferred (with mitigations documented)
- `jimp` v0 → v1 in engine — major API rewrite. Backend's
  patched file-type validates uploads before the engine sees them.
- `vite` v4 → v8 in frontend, `vitest` v1 → v4 in backend — dev-only,
  separate migrations.
- `node-cron` v3 → v4 — transitive `uuid` advisory only affects
  `v3()`/`v5()`/`v6()` with a `buf` argument, which our code never
  uses (we use `crypto.randomUUID()` and Postgres `gen_random_uuid()`).

### Tests
- `backend/src/tests/middleware/fileValidation.test.ts`: 5/5 passing
  on file-type@21.3.4. `fileTypeFromBuffer` API stable across
  19.x → 21.x.

## [1.10.1] - 2026-04-27

Sprint 3 of the production-readiness remediation plan
(`docs/plans/2026-04-27-prod-readiness-remediation.md`). 10 items
hardening operational, supply-chain, and reliability surfaces from
the 2026-04-25 audit. Single patch release (no breaking changes).
All branches went through code review; review findings are folded
into the same commits as the original work.

### Security
- **Per-route admin login rate limiter (S3.5)** — 5 failed attempts
  per IP per 15 minutes on `/api/admin/login` and
  `/api/admin/login/verify-totp`. `skipSuccessfulRequests` and
  `skip: requiresTotp` so the budget tracks only true credential
  failures, not the protocol-step 401 that signals "TOTP required".
- **Tighter `.env` permissions in `install.sh` (S3.3)** — chmod 600
  on every code path that writes the secrets file (initial
  generation, `_set_env_var`, `_remove_env_var`, "keep existing"
  branch). Limits exposure if the install dir is later relaxed.

### Reliability
- **Migration runner advisory lock (S3.1)** — `pg_advisory_lock`
  on key `0x1d59f73b` prevents two API instances racing the
  `_migrations` table during a rolling deploy. Lock auto-releases
  on connection close; partial migrations roll back cleanly.
- **Idempotency keys cleanup cron (S3.8)** — daily at 01:15 UTC,
  removes `idempotency_keys` rows past `expires_at`. Closes the
  long-running gap where the table grew unbounded.
- **catchAsync returns its inner promise (S3.7)** — Express ignored
  return values, but the test harness awaits the middleware. Adding
  the return lets tests await async middleware directly and removes
  7 `setImmediate` polling workarounds in `idempotency.test.ts`.
- **Engine breaker semantics clarified (S3.11)** — added inline
  comment that `BREAKER_FAILURE_THRESHOLD` counts physical attempts
  (post-retry), not logical requests. Test added for `SyntaxError`
  on malformed JSON response — defaults to retryable per catch-all.

### Operational
- **Self-hosted backup + restore runbook (S3.6)** — new
  `backend/scripts/self-hosted-backups.md` covers Postgres `pg_dump`
  + uploads volume snapshot, S3 offsite, retention pruning, restore
  verification. Volume name derived portably from `$PWD` (works
  regardless of install directory). `pg_isready` precheck,
  `--no-tablespaces`, `ON_ERROR_STOP=1` on restore.
- **Docker resource limits + Renovate digest pinning (S3.2)** —
  `mem_limit` and `cpus` on every service in `docker-compose.yml`
  (postgres 1g, engine 2g/2.0, api 1g/1.0, frontend 256m/0.5,
  caddy 256m/0.5). New `renovate.json` pins Dockerfile and compose
  base images by digest; vulnerability alerts ship at any time, npm
  patch+minor grouped weekly.

### CI
- **CI on direct push to main/dev (S3.9)** — workflow now runs on
  both `pull_request` and `push: branches: [main, dev]`. Catches
  rebases, merge commits, and direct pushes that bypassed PR.
- **`npm audit` advisory job (S3.4)** — new CI job runs
  `npm audit --audit-level=high --omit=dev` on backend, engine,
  frontend, shared. `continue-on-error: true` for now to surface
  findings without blocking; flip to blocking once backlog is clean.

## [1.10.0] - 2026-04-27

Sprint 2 of the production-readiness remediation plan
(`docs/plans/2026-04-27-prod-readiness-remediation.md`). 8 items
addressing compliance, reliability, and security gaps from the
2026-04-25 audit. All branches went through code review; review
findings are addressed in the same commits as the original work.

### Security
- **Envelope encryption for local file storage (S2.4 + S2.4a)** —
  opt-in via `STORAGE_ENCRYPTION=true`. Per-file AES-256-GCM with
  DEK wrapped under a master key derived from `ENCRYPTION_KEY`.
  Format: 4-byte "IDSW" magic + 1-byte version + DEK envelope + file
  envelope (93-byte overhead). Read path always decrypts on detection;
  legacy plaintext files pass through unchanged. Multi-key candidate
  decrypt enables online key rotation via `ENCRYPTION_KEY_PREVIOUS` +
  the new `backend/scripts/rotate-encryption-key.ts` script (see
  `backend/scripts/encryption-key-rotation.md` runbook). Hard guard:
  fails startup if `STORAGE_ENCRYPTION=true` with default placeholder
  key.
- **Webhook X-Idswyft-Sandbox header (S2.3)** — adds
  `X-Idswyft-Sandbox` (boolean string) and `X-Idswyft-Verification-Mode`
  (sandbox/production string) to every delivery so receivers can route
  or alert without parsing the JSON body. Drive-by fix: corrected
  long-standing `X-Webhook-Signature` → `X-Idswyft-Signature` doc
  drift in apiDocsMarkdown.ts and llms-full.txt.

### Reliability
- **Engine retry + circuit breaker (S2.5)** — 2 retries (3 total
  attempts) with exponential backoff (500ms, 1s) on transient failures
  (5xx, network, timeout). 4xx and `success:false` are not retried.
  Circuit breaker opens after 5 consecutive retryable failures, half-
  open recovery after 30s. Configurable via `ENGINE_BACKOFF_BASE_MS`
  and `ENGINE_BREAKER_OPEN_MS` env vars.
- **Idempotency keys on verify endpoints (S2.6)** — wired the
  existing `idempotencyMiddleware` onto `/verify/initialize`,
  `/verify/:id/front-document`, `/verify/:id/back-document`,
  `/verify/:id/live-capture`. Accepts both `Idempotency-Key` (RFC
  draft / Stripe) and `X-Idempotency-Key` (legacy) headers. Cache
  hits replay the original response with `Idempotent-Replayed: true`
  header. 24h TTL per migration 09; cleanup cron deferred to Sprint 3.
- **SIGTERM drain + uncaught handlers (S2.7)** — refactored to
  `utils/gracefulShutdown.ts` factory: drain HTTP server, close DB
  pool (community-mode PgClient only), exit with configurable force-
  exit timeout. SIGTERM uses 25s (Railway gives 30s before SIGKILL);
  `uncaughtException` uses 2s emergency timeout (process state may be
  corrupt — don't ship potentially-bad responses for 25s).
  `unhandledRejection` policy is env-configurable via
  `UNHANDLED_REJECTION_POLICY=log|crash` (default `log` for backward
  compat).

### Changed
- **GDPR erasure now covers `aml_screenings` (S2.1)** — added the
  table to `dataRetention.ts:deleteUserData` and `runDemoCleanup`.
  Closes the audit's "GDPR erasure covers all tables" partial-falsity.
- **Audit log claim clarification (S2.2)** — corrected CLAUDE.md
  to enumerate the exact tables in `deleteUserData` scope and to
  distinguish "verification audit trail" (anonymized, retained) from
  "request telemetry" (`api_activity_logs`, hard-deleted on 7-day
  cron). `DATA_RETENTION_DAYS` default documented as 90 days.
- **LLM docs sync (cross-cutting)** — `frontend/public/llms.txt` and
  `llms-full.txt` updated with Webhook Headers table, Idempotency
  section, and the same X-Idswyft-Signature drift fix.

### Tests
- 70+ new test cases across the 8 items. Backend full suite:
  1093/1093 passing on merged dev (was 1025 baseline post-Sprint 1).

### Sprint 3 follow-ups (filed, none merge-blocking)
1. catchAsync return-promise refactor (test ergonomics, broad benefit)
2. idempotency_keys cleanup cron (table grows unbounded)
3. Idempotency concurrent-first-time race + in-flight lock (schema
   change required)
4. Decide 5xx caching policy for idempotency
5. Backoff jitter + per-request deadline budget vs proxy timeouts
6. Redis-backed shared breaker state for horizontal scale
7. Document SyntaxError handling in engineClient retry path
8. Optional comments in dataRetention re: cascade redundancy and
   hard-delete vs anonymize for AML

## [1.9.0] - 2026-04-27

Sprint 1 of the production-readiness remediation plan
(`docs/plans/2026-04-27-prod-readiness-remediation.md`). Addresses ship-blockers
from the 2026-04-25 audit: Sentry PII leak path, storage-encryption claim drift,
and CI not running tests.

### Security
- **Sentry PII scrubber (S1.1)** — set `sendDefaultPii: false` and add a
  `beforeSend` scrubber that strips request body, sensitive headers, and known
  PII field values from Sentry events. Closes a GDPR Article 9 leak path that
  could have shipped document images, names, DOBs, and OCR text to Sentry on
  any thrown error during verification. Scrubber lives in
  `shared/src/utils/sentryScrub.ts` and is applied to BOTH the backend API and
  the engine worker (engine had the same vulnerability untouched). 32 unit
  tests cover redaction, free-text patterns, header case-insensitivity,
  circular refs, stack-frame vars, fingerprint/transaction scrubbing, and
  scrubber-failure fallback.

### Changed
- **CI runs the test suite (S1.2)** — `.github/workflows/ci.yml` now runs
  `npm test -- --run` after the existing `tsc --noEmit` step on the
  `typecheck-backend` job. Closes the audit finding that CI gave false
  confidence by never executing the 1022-case vitest suite. Verified clean
  baseline (1022/1022 passing) before flipping the gate.
- **Storage encryption claims corrected (S1.3)** — `CLAUDE.md`, `README.md`,
  and `install.sh` no longer claim a blanket "encryption at rest" for all
  uploaded files. The claim was true only for `STORAGE_PROVIDER=s3`
  (server-side AES-256); the local provider writes plaintext. Updated docs
  scope the claim correctly and recommend filesystem-level encryption (LUKS,
  dm-crypt, EBS) for local-storage operators. `install.sh` now warns when
  local storage is selected. Customer-facing pages corrected too:
  `LegalPage.tsx` privacy policy now distinguishes Idswyft Cloud (S3 + AES-256
  SSE) from self-hosted deployments, and the retention default was corrected
  from 30 to 90 days. `PricingPage.tsx` Community-tier "Encryption at rest"
  flipped from Y to N (relabeled "Encryption at rest (managed)") to align
  with the existing "managed by Idswyft" pattern. `ENCRYPTION_KEY`
  description corrected to reflect actual usage (encrypts stored
  third-party provider credentials and Identity Vault records — not files).

### Fixed
- Removed false claims that "API keys" and "webhook signing keys" are
  encrypted at rest. API keys are stored as HMAC-SHA256 hashes (not
  encryption); webhook secret storage is in mixed state (`secret_token`
  encrypted, legacy `secret_key` plaintext).

## [1.8.52] - 2026-04-24

### Changed
- **LLM docs audit** — added 7 missing sections to llms-full.txt (face age estimation, velocity detection, IP geolocation, voice auth, PEP screening, compliance rules, verifiable credentials, identity vault), fixed version v1.8.2 → v1.8.52

## [1.8.51] - 2026-04-24

### Fixed
- **redirect_url not working on hosted page** — thread redirect_url through mobile auto-redirect flow, add redirect support to MobileVerificationPage with 3-second auto-redirect after completion (fixes idswyft-community#28)

### Security
- **Open redirect prevention** — validate redirect_url protocol (http/https only), rejecting javascript:, data:, and other dangerous schemes

### Changed
- Extract shared `buildRedirectUrl` utility to `frontend/src/utils/redirect.ts`
- Add `verification_mode` and `age_threshold` to hosted page URL parameters documentation

## [1.8.50] - 2026-04-24

### Added
- **Mobile auto-redirect** — when a user opens the verification URL on a mobile device, automatically redirects to the native mobile verification page instead of showing the desktop/mobile choice screen with a pointless QR code

## [1.8.49] - 2026-04-24

### Fixed
- **Duplicate verification in QR handoff** — when a verification was initialized via API (session token flow) and the user chose mobile QR handoff, the mobile page created a second verification instead of reusing the original; the developer's verification stayed stuck at `AWAITING_FRONT` while the duplicate completed silently

## [1.8.48] - 2026-04-21

### Added
- **Voice auth toggle in Settings Modal** — developers can now enable/disable voice authentication (Gate 7) from the Integrations tab in both cloud and community editions

## [1.8.47] - 2026-04-21

### Fixed
- **Intermittent CORS failures on staging** — Railway's Fastly CDN was caching OPTIONS preflight responses with stale `Access-Control-Allow-Origin` headers; added `Cache-Control: private, no-store` and `Surrogate-Control: no-store` to all preflight responses, and set `maxAge: 600` for browser-side preflight caching

## [1.8.46] - 2026-04-21

### Fixed
- **Demo hard rejection dead-end** — OCR polling HARD_REJECTED now advances to Results step with retry/new demo options instead of leaving the user stuck on the processing screen
- **Sign-in "Continue with Email" unresponsive** — added explicit JS email validation with inline error message instead of relying solely on browser-native required validation
- **File picker intermittently unresponsive** — replaced `document.getElementById` with React `useRef` for file input triggering in front and back document upload steps
- **"View on GitHub" link dead clicks** — fixed clickable area on pricing page Community tier CTA by correcting display mode from block to flex

## [1.8.45] - 2026-04-21

### Fixed
- **Address verification OCR routing** — address document OCR now routes through the engine worker (`POST /extract/ocr`) instead of directly importing `ppu-paddle-ocr` in the API container, which crashed on Railway staging; falls back to local OCR in dev mode when `ENGINE_URL` is not set

## [1.8.44] - 2026-04-20

### Added
- **IP geolocation risk** — analyzes verification IP addresses to detect geographic fraud signals: country mismatch (IP vs document issuing country), Tor exit nodes, datacenter/VPN IPs (AWS, GCP, Azure, etc.), and high-risk jurisdictions; flags (`country_mismatch`, `tor_exit_node`, `datacenter_ip`, `high_risk_country`) contribute 7% weight to composite risk score; flagged sessions route to `manual_review`; Tor exit list auto-refreshes every 24 hours; sandbox verifications excluded

## [1.8.43] - 2026-04-20

### Added
- **Velocity checks** — fraud velocity detection analyzes IP reuse, user frequency, and step timing to detect bots and rapid resubmissions; flags (`rapid_ip_reuse`, `burst_activity`, `high_user_frequency`, `bot_like_timing`) contribute 8% weight to composite risk score; flagged sessions route to `manual_review`; sandbox verifications excluded from analysis

## [1.8.42] - 2026-04-20

### Added
- **PEP screening** — screens against Politically Exposed Persons databases via OpenSanctions `/match/peps` endpoint; PEP matches always produce `potential_match` (never `confirmed_match`) since PEP status is a risk signal for enhanced due diligence; configure with `AML_PROVIDER=pep` or combine with sanctions via `AML_PROVIDER=opensanctions,pep`

## [1.8.41] - 2026-04-19

### Changed
- **v2 frontend design overhaul** — new technical editorial aesthetic: Geist + JetBrains Mono fonts, oklch green accents, sharp borders, light/dark theme toggle, sticky nav, grid-based layouts across all pages
- **Hero section** — interactive demo panel with specimen ID images, v2 typography and copy, subtle guilloche security pattern background
- **Developer portal** — guilloche security pattern background on auth gate and dashboard
- **Security fixes** — XSS prevention in JSON syntax highlighter, stabilized React hook dependencies, removed duplicate font loading

## [1.8.40] - 2026-04-15

### Fixed
- **External database SSL** — `install.sh` now defaults to `DATABASE_SSL_REJECT_UNAUTHORIZED=false` for BYOD databases, fixing `SELF_SIGNED_CERT_IN_CHAIN` errors with Railway, Supabase, and other cloud providers; SSL prompt defaults to yes and both env vars are always set

## [1.8.39] - 2026-04-15

### Added
- **Self-hosting guide** on the docs page (`/docs/guides#self-hosting`) — prerequisites, three install options, external database (BYOD) troubleshooting, and useful commands reference

## [1.8.38] - 2026-04-15

### Fixed
- **Handoff restart after verification failure** — mobile users clicking "Try Again" after a failed verification no longer get 401; `authenticateHandoffToken` now allows `'failed'` sessions for the `/restart` endpoint only, and the restart handler resets the handoff session to `'pending'` (with atomic guard) so the next `PATCH /complete` cycle succeeds

## [1.8.36] - 2026-04-15

### Added
- **Secure session tokens** — `POST /api/v2/verify/initialize` now returns a short-lived `session_token` and `verification_url`; end users load `/user-verification?session=<token>` instead of exposing the raw API key in the URL
- **`GET /api/v2/verify/session-info`** — public endpoint to resolve session token to verification metadata and developer branding
- **`authenticateSessionToken` middleware** — reuses HMAC handoff pattern; `X-Session-Token` header accepted on all verification endpoints
- **Session token scope enforcement** — `requireOwnedVerification` ensures a session token can only access its bound verification

### Changed
- Address verification routes now accept session token and handoff token auth (not just API key)
- Handoff creation accepts `X-Session-Token` header as alternative to `api_key` body field
- SDK `InitializeResponse` type includes `session_token` and `verification_url` fields
- Old `?api_key=` URL flow still works (backward compatible) with console deprecation warning

## [1.8.35] - 2026-04-14

### Added
- **Watchtower auto-update** — optional sidecar for automatic container updates via Docker Compose `--profile autoupdate`; checks for new images daily at 4 AM UTC with rolling restarts, never touches the database
- **`install.sh` auto-update step** — interactive prompt to enable Watchtower during installation, generates API token, appends to `.env`
- **Watchtower probe in `/api/system/version`** — checks Watchtower metrics endpoint (2s timeout) and returns `configured`, `running`, `containers_scanned/updated/failed` status
- **Auto-Update card** in community Settings modal System tab — three-state display (running with metrics, configured but stopped, not configured)
- **`update.sh` / `uninstall.sh`** — detect and include `--profile autoupdate` in compose commands when Watchtower is running

## [1.8.34] - 2026-04-14

### Added
- **`update.sh`** — safe upgrade script for community edition; pulls latest images and recreates containers without touching `.env` or database volumes
- **`GET /api/system/version`** — version check endpoint with GitHub API integration, 1-hour cache, and semver comparison (requires developer JWT)
- **System tab** in community Settings modal — shows current version, update available badge, click-to-copy update/uninstall commands

### Fixed
- Health and root endpoints now return actual version from `package.json` instead of hardcoded `1.0.0`

## [1.8.11] - 2026-04-13

### Added
- **Passport back-skip** — passports are single-sided; when front OCR detects a passport, the verification flow dynamically skips the back-document upload and cross-validation steps
- `applyPassportOverride()` in shared package — single source of truth for the flow override, used by session state machine and route handler
- `requires_back` field in front-document response — signals to clients whether the back-document step is needed (reflects passport detection)
- Passport-specific 400 error message on the back-document endpoint ("A passport was detected — passports are single-sided")
- 9 unit tests covering all verification mode + passport combinations (full, document_only, identity, liveness_only, age_only)

### Fixed
- **PaddleOCR `detected_document_type` not set** — when user explicitly selected a document type (e.g. "passport"), auto-classification was skipped and `detected_document_type` was never populated; now set from user-provided type with confidence 1.0
- **`mapStatusForResponse` null cross-validation** — `document_only` and `full` mode branches now handle null `cross_validation` (expected for passport flows that skip cross-validation)

## [1.8.10] - 2026-04-10

### Added
- **Haiti CIN (Carte d'Identification Nationale) OCR support** — PaddleOCR extraction for Haitian national ID cards with bilingual French/Kreyòl label handling, DMY date format, and compass-rose watermark resilience. Benchmark: 5/6 fields (name, DOB, doc#, expiry, nationality).
- **Date-format hint threading** — `standardizeDateFormat`, `findAllDates`, `findDateField`, `extractDate`, and `findLastDateField` now accept an optional `DMY`/`MDY`/`YMD` hint for country-specific date disambiguation.
- **`stripTrailingLabelNoise` / `stripLeadingLabelNoise` helpers** — clean bilingual OCR artifacts (French + Haitian Creole label fragments concatenated with extracted values).
- **59 unit tests** for `BaseExtractor` helpers (`stripLeadingLabelNoise`, `stripTrailingLabelNoise`, `findLastDateField`, `extractDate`, `isLabelOrNoise`).

### Fixed
- **JS `\b` word-boundary bug** — trailing `\b` in French label regexes silently failed after non-ASCII chars like `é` (JavaScript's `\b` is ASCII-only). Replaced with `(?![A-Za-z])` negative lookahead in both backend and engine.
- **`findLastDateField` window/hint coupling** — search window size and date-format hint are now independent parameters (`options.windowSize` vs `hint`).

## [1.8.3] - 2026-04-09

### Fixed
- **Compliance auth recursion bug** — `authenticateComplianceRequest` self-recursed on the `X-API-Key` branch instead of calling `authenticateAPIKey`, which would have stack-overflowed any request actually sending an API key. The bug was latent because the previous UI only ever hit the JWT branch.

### Changed
- **Compliance ruleset auth model** — `/api/v2/compliance/*` now accepts exactly two paths: `X-API-Key` (developer SDK/automation) **or** an organization-admin reviewer session cookie (Admin Dashboard UI). Regular reviewers and platform admins are rejected — compliance is a per-dev-organization concern owned by the org admin, not by individual developers or Idswyft platform operators.
- **Developer-portal JWT path removed** from compliance endpoints — compliance management has moved out of the Developer Portal entirely.
- **`getComplianceDeveloperId` helper** consolidates developer-scope resolution across the two auth paths (formerly 9 inline `(req as any).developer.id` casts).

### Security
- **CSRF enforced on compliance routes** — `/api/v2/compliance` is now mounted with `conditionalCsrf`, matching the pattern used by `/api/developer`, `/api/admin`, and `/api/auth`. The middleware no-ops for `X-API-Key` callers (no `idswyft_token` cookie present) and enforces `x-csrf-token` for the reviewer cookie path.

## [1.8.2] - 2026-04-02

### Added
- **Verification page branding** — developers can white-label the hosted verification page with a custom logo, accent color, and company name
- `GET/PUT /api/developer/settings/branding` — configure branding settings (logo URL, hex accent color, company name)
- `POST /api/developer/branding/logo` — upload branding logo (JPEG/PNG, max 2 MB, magic byte validated)
- `GET /api/v2/verify/page-config?api_key=...` — public endpoint returning developer branding for the hosted page (cached 5 min, rate limited)
- Live preview panel in Developer Portal Settings modal
- Branding applied to desktop, mobile, and embedded verification flows
- "Powered by Idswyft" attribution when custom branding is active

## [1.8.1] - 2026-04-02

### Added
- **Custom verification flows** — `verification_mode` parameter now supports `'document_only'` and `'identity'` presets
- `document_only`: Front → Back → CrossVal (3 steps, no biometric)
- `identity`: Front → Liveness → FaceMatch (3 steps, no back document or cross-validation)
- Endpoint guards: back-document returns 400 for identity/age_only flows; live-capture returns 400 for document_only/age_only flows

## [1.8.0] - 2026-04-02

### Added
- **Role-based access control** — `verification_reviewers` now has a `role` column (`'admin'` or `'reviewer'`), enabling Organization Admins with elevated privileges distinct from regular Reviewers
- **Organization Admin role** — org admins can access analytics, GDPR data deletion (scoped to their developer), and override verification decisions; regular reviewers are limited to approve/reject
- **Role-aware reviewer invitations** — `POST /api/developer/reviewers/invite` accepts optional `role` parameter (`'admin'` | `'reviewer'`, defaults to `'reviewer'`)
- **Role in reviewer JWT** — `role` field included in reviewer token payload and OTP verify response
- **`requireOrgAdminOrPlatformAdmin` middleware** — gates analytics, GDPR delete, and override endpoints to org admins and platform admins only
- **Team setup banner** — Developer Portal shows a dismissible banner prompting developers to invite an Organization Admin when none exists
- **Role badges in Settings** — reviewer list in Settings modal displays purple "Admin" or gray "Reviewer" badges, with role selector in the invite form

### Changed
- **Developer escalation removed** — `POST /api/auth/admin/escalate` now returns `410 Gone`; developers no longer auto-escalate to admin access
- **Analytics endpoints opened to org admins** — all 5 analytics routes (`/analytics`, `/analytics/funnel`, `/analytics/rejections`, `/analytics/fraud-patterns`, `/analytics/risk-distribution`) now accept reviewer JWTs with `role: 'admin'`, scoped by `developer_id`
- **GDPR delete opened to org admins** — `DELETE /api/admin/user/:userId/data` accessible to org admins with ownership verification (user must belong to their developer's verifications)
- **Override restricted** — verification override decision requires org admin or platform admin role; regular reviewers get 403
- **DevelopersList platform-admin only** — `GET /api/admin/developers` restricted to platform admins (`admin_users` table), no longer accessible to reviewer tokens
- **Admin frontend aligned with design system** — AdminLogin, VerificationManagement, and DevelopersList pages now use CSS pattern backgrounds (`pattern-shield`, `pattern-crosshatch`), monospace breadcrumbs, and `C.mono` heading font consistent with the rest of the site
- **Review Dashboard docs updated** — role hierarchy documented, override marked as admin-only, stats bar reflects 5-card layout, gate analysis and risk assessment in detail panel

### Security
- **Override guard fix** — platform admins were incorrectly blocked from override due to `req.reviewer?.role !== 'admin'` evaluating truthy when `req.reviewer` is undefined; fixed with explicit null check
- Developer escalation path fully removed — no route exists to promote a developer session to admin access

### Migration
- `39_admin_restructure.sql` — adds `role` column to `verification_reviewers` (default `'reviewer'`), CHECK constraint, index, and TOTP columns on `admin_users`

## [1.7.2] - 2026-03-30

### Added
- **AML screening auto-trigger** — AML now runs automatically on all non-sandbox verifications when providers are configured (`AML_PROVIDER` env var). No longer requires `addons.aml_screening: true` per session. Developers can opt out via `aml_enabled` column on `developers` table.
- **Multi-provider AML screening** — `AML_PROVIDER` supports comma-separated values (e.g., `opensanctions,offline`). All providers run in parallel; matches are deduplicated and the highest risk level wins.
- **AML result persistence** — full screening results (matches, risk level, lists checked, screened name/DOB) are now stored in the `aml_screenings` DB table for audit trail
- **Expanded AML session state** — `aml_screening` in verification status now includes `matches` array (listed_name, list_source, score, match_type), `screened_name`, and `screened_dob`
- **AML risk scoring integration** — risk score now includes `aml_screening` factor (weight 0.10): `clear` → 0, `potential_match` → 60, `confirmed_match` → 100
- **Address cross-validation** — front OCR address is now compared against back PDF417/barcode address as a supplementary signal (weight 0, does not affect verdict). Uses word-overlap scoring with address normalization (abbreviation expansion). Thresholds: ≥0.70 PASS, ≥0.40 REVIEW, <0.40 FAIL.
- `address` field added to `qr_payload` in back extraction (both engine worker and local fallback)
- `address_validation` field in `cross_validation_results` (score, verdict, front/back addresses)
- `aml_enabled` developer column (migration 35) — defaults to true, set false to opt out
- `createAMLProviders()` factory function replacing `createAMLProvider()`
- `screenAll()` multi-provider orchestrator with `Promise.allSettled` and match deduplication

### Changed
- Risk scoring weights rebalanced: `ocr_confidence` 0.20→0.18, `face_match` 0.25→0.22, `cross_validation` 0.20→0.18, `liveness_proxy` 0.20→0.17, `document_expiry` 0.15 (unchanged), `aml_screening` 0.10 (new). Total: 1.00
- `AMLScreeningSessionResult` type expanded with `matches`, `screened_name`, `screened_dob` fields
- `CrossValidationResult` type expanded with optional `address_validation` field

## [1.7.0] - 2026-03-27

### Added
- **Reviewer invitation system** — developers can invite external reviewers to access the Verification Management page, scoped to that developer's data only
- **Passwordless reviewer auth** — reviewers authenticate via email OTP (same flow as developer portal), no passwords or admin accounts needed
- `POST /api/developer/reviewers/invite` — invite a reviewer by email
- `GET /api/developer/reviewers` — list all reviewers for the authenticated developer
- `DELETE /api/developer/reviewers/:id` — revoke a reviewer's access
- `POST /api/auth/reviewer/otp/send` — send OTP to reviewer email
- `POST /api/auth/reviewer/otp/verify` — verify OTP and issue scoped reviewer JWT (24h, developer-scoped)
- `authenticateAdminOrReviewer` middleware — admin routes accept either admin JWT or reviewer JWT
- Reviewer management UI in developer portal Settings modal (invite, list, revoke, copy login link)
- `verification_reviewers` database table with global email uniqueness

### Changed
- Admin verification endpoints now scope queries by `developer_id` when accessed with a reviewer token
- `AdminLogin.tsx` rewritten as passwordless OTP flow (replaces legacy password form)
- `VerificationManagement.tsx` accepts both `adminToken` and `reviewerToken`, with Sign Out button
- Reviewers cannot use the `override` decision on verification reviews (admin-only)
- Rate limiting on reviewer OTP send endpoint (5 per 15 min per IP)

### Security
- HTML escaping in reviewer invitation emails to prevent injection
- JWT `developer_id` cross-checked against database in both reviewer auth middlewares
- Timing-safe token comparison inherited from existing OTP infrastructure

## [1.6.0] - 2026-03-26

### Added
- **Batch verification processing** — `POST /api/v2/batch/upload` now runs the full verification pipeline: downloads documents from provided URLs, processes through engine (OCR, barcode/MRZ extraction), runs quality gates and cross-validation, sets final status to `manual_review` (no live capture in batch mode)
- **Admin status override** — `PUT /api/admin/verification/:id/review` accepts `decision: 'override'` with `new_status` field to set any valid status (verified, failed, manual_review, pending)
- **Webhook forwarding on admin actions** — approve, reject, and override decisions now fire webhooks to the developer's registered endpoints using their scoped API key (same events as the automated pipeline)
- **Verification Management page** — new dark-themed admin UI at `/admin/verifications` with stats bar, filterable/searchable table, expandable detail view with document images, and approve/reject/override actions with confirmation dialogs
- **Enhanced verification detail endpoint** — `GET /api/admin/verification/:id` now returns all documents (front + back) from the documents table, not just the FK-linked document

### Changed
- Batch items that fail quality gates are correctly marked as `failed` with rejection reason instead of always ending at `manual_review`

## [1.5.4] - 2026-03-26

### Fixed
- **CSRF token endpoint 503** — `/api/auth/csrf-token` returned 503 because `cookie-parser` was not installed. The `csrf-csrf` library requires `req.cookies` to be populated. Added `cookie-parser` middleware and wrapped the route in `catchAsync` for proper error handling.

### Added
- `cookie-parser` dependency for CSRF double-submit cookie support

## [1.5.3] - 2026-03-26

### Fixed
- **CORS blocks Docker setup wizard** — `http://localhost` (port 80) was missing from the CORS allowlist, causing the setup form POST to fail with 500. Added `http://localhost` as a hardcoded origin in config and prepended it to `CORS_ORIGINS` in docker-compose.yml.

## [1.5.2] - 2026-03-26

### Fixed
- **Docker setup wizard not loading** — `.env.production` had `VITE_API_URL=https://api.idswyft.app` baked in, causing Docker builds to route API calls to the cloud instead of the local nginx proxy. Dockerfile now removes `.env.production` before `vite build`.
- **Port collision in docker-compose** — renamed `${PORT}` to `${IDSWYFT_PORT}` so dev `.env` (`PORT=3001`) no longer hijacks the frontend container port mapping
- **Setup redirect on API error** — DeveloperPage now redirects to `/setup` when API is unreachable (common during Docker startup) instead of silently showing the login form
- **Setup wizard layout** — vertically centered form, block-centered logo
- **Mobile responsive grids** — DemoPage and DocsPage grids now stack to single column on viewports < 768px
- **Step indicator overflow** — shrunk step circles/labels on mobile to prevent horizontal overflow on DemoPage

### Changed
- **OCR modular architecture** — refactored `PaddleOCRProvider.ts` (2,141 → 120 lines) into 12 focused modules using facade + strategy pattern. Zero behavior change, same benchmark accuracy (63.6%)
- **US DL name extraction** — improved name scoring, sanitization, and multi-line parsing

## [1.5.1] - 2026-03-24

### Fixed
- **Mobile handoff desktop notification** — desktop no longer stays stuck on "Waiting for phone..." when mobile PATCH fails
  - Added exponential backoff retry (3 attempts: 1s/2s/4s) with `keepalive` on mobile completion PATCH
  - Extended handoff session timeout from 10 to 30 minutes for complex verifications
  - New `verification_id` linkage: mobile links verification to handoff session early, desktop dual-polls both handoff status and verification API as fallback
  - DemoPage transitions to full results view on handoff completion
  - UserVerificationPage gets dark-themed completion screen with distinct verified/failed/review states
- Fixed `face_match_results.score` → `.similarity_score` in mobile handoff result payload
- Added UUID format validation on `/link` endpoint

### Added
- `PATCH /api/verify/handoff/:token/link` — links a verification_id to a handoff session
- `verification_id` column on `mobile_handoff_sessions` table (migration 32)
- `verification_id` returned in handoff status poll response for desktop fallback

## [1.5.0] - 2026-03-24

### Changed
- **Extracted ML verification engine into separate microservice** (`engine/`)
  - Core API image reduced from ~2GB to ~250MB — no longer bundles TensorFlow, ONNX, PaddleOCR, or canvas
  - Engine Worker runs as a standalone container (~1.5GB) handling OCR, face detection, liveness, and deepfake analysis
  - API calls engine via HTTP (`ENGINE_URL` env var) during verifications; falls back to local extraction when unset
- Docker Compose architecture: postgres + engine + api + frontend (4 containers)
- Backend `package.json` stripped of `@tensorflow/tfjs`, `@vladmandic/face-api`, `onnxruntime-node`, `ppu-paddle-ocr`, `canvas`, `jimp`, `tesseract.js`, `@zxing/*`
- Backend Dockerfile no longer needs native build tools (python3, make, g++, libcairo2-dev, etc.)
- CI workflow builds 3 images in parallel: api, engine, frontend

### Added
- `engine/` directory with its own `package.json`, `tsconfig.json`, `Dockerfile`, and Express server
- `backend/src/services/engineClient.ts` — HTTP client for the engine worker using native `fetch` + `FormData`
- `ENGINE_URL` environment variable for engine service discovery

## [1.4.0] - 2026-03-24

### Added
- Community edition first-run setup wizard — `GET /api/setup/status` and `POST /api/setup/initialize`
- Auto-detects zero-developer state, creates first account + API key without OTP
- Rate-limited setup endpoint (5 req/15min) with input sanitization
- Docker auto-migration on boot via entrypoint script
- Migration runner: configurable directory (`MIGRATIONS_DIR`), conditional SSL, lenient mode

### Removed
- VaaS-specific migrations (06, 08, 10) that don't belong in community edition

### Fixed
- Docker frontend double `/api/api/` URL prefix — production builds now use same-origin (empty base URL)

## [1.3.0] - 2026-03-24 (superseded by 1.4.0)

_Initial setup wizard implementation, replaced by the clean rewrite in 1.4.0._

### Added
- Setup wizard endpoints (initial version)
- Version bump and changelog entry

## [1.3.1] - 2026-03-24 (superseded by 1.4.0)

_Docker integration fixes for 1.3.0, folded into 1.4.0._

### Fixed
- API URL prefix, auto-migration entrypoint, removed VaaS migrations

## [1.2.0] - 2026-03-20

### Changed
- Liveness system cleanup: removed dead MediaPipe/ActiveLiveness code path, renamed MultiFrame → HeadTurn
- Malformed `liveness_metadata` now returns HTTP 400 (`VALIDATION_ERROR`) instead of silently falling back to passive mode
- Removed legacy `multi_frame_color` challenge type alias — only `head_turn` is accepted
- `color_sequence` field is now optional (clients no longer need to send it)
- Removed deprecated `HeuristicProvider` — only `EnhancedHeuristicProvider` remains

## [1.1.0] - 2026-03-19

### Added
- Visual authenticity checks — FFT analysis, color distribution, zone validation, deepfake detection
- Webhook resend endpoint (`POST /api/developer/webhooks/:id/deliveries/:did/resend`)
- Per-API-key scoping for webhook endpoints
- Developer-configurable LLM fallback for OCR with date disambiguation
- Account deletion endpoint (`DELETE /api/developer/account`)
- Email OTP + GitHub OAuth authentication (replaced insecure password login)
- Webhook delivery logs endpoint (`GET /api/developer/webhooks/:id/deliveries`)
- Webhook test endpoint with timeout handling
- AML/sanctions screening opt-in addon
- US driver's license format validator
- `/health` endpoint for Railway health checks + `/api/health` for API consumers

### Fixed
- NULL events column silently filtering out webhook deliveries
- Per-provider metrics now derived from results JSONB instead of session-level aggregates
- Missing `webhook_deliveries` table migration
- OTP security hardening — atomic verify, timing-safe comparison, fail-closed
- Trailing AAMVA field markers stripped from space-separated DLN
- CORS origins always include production domains

## [1.0.0] - 2025-12-01

### Added
- Initial release — document OCR, face matching, verification pipeline
- RESTful API for verification workflows
- API key management system
- Webhook notification system with HMAC-SHA256 signing
- Sandbox environment for developer testing
- Rate limiting and abuse protection
