diff --git a/README.md b/README.md index c769a8b..e2c5aaf 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Waymark is a schema-driven journey orchestration engine for onboarding and compl - [`getting-started.md`](./getting-started.md) — prerequisites, local setup, and contribution workflow - [`user-guide.md`](./user-guide.md) — persona model, session lifecycle, and webhook integration - [`docs/journey-json.md`](./docs/journey-json.md) — journey JSON notation, structure, frontend linkage, and visualization -- [`feature-gaps.md`](./feature-gaps.md) — known gaps tracked as GitHub issues +- [`docs/issues/`](./docs/issues/) — known gaps tracked as GitHub issues ## Repository structure diff --git a/docs/issues/11-browser-api-key-grants-operator.md b/docs/issues/11-browser-api-key-grants-operator.md new file mode 100644 index 0000000..b52711e --- /dev/null +++ b/docs/issues/11-browser-api-key-grants-operator.md @@ -0,0 +1,68 @@ +--- +title: "Frontend API key grants full Operator access to every browser visitor" +labels: ["security", "authentication", "frontend"] +--- + +## Summary + +The SPA authenticates to the backend with a **shared API key baked into the browser bundle** (`VITE_API_KEY`), and `ApiKeyAuthenticationHandler` maps any valid API key to a **full `Operator` principal**. Vite inlines `VITE_*` variables at build time, so the key ships as a literal string in the published JavaScript and is readable by anyone who loads the public onboarding page. + +Any visitor can extract the key and call every Operator-scoped endpoint. + +**Affected files:** +- `src/backend/OpenOnboarding.Api/Authentication/ApiKeyAuthenticationHandler.cs` (lines 34–39) +- `src/frontend/.env.example` +- `src/frontend/src/onboarding/api/workflow-api-client.ts` (line 19) +- `src/frontend/src/onboarding/hooks/useOnboarding.ts` (line 14), `useFlow.ts` (line 4) +- `src/frontend/src/App.tsx` (line 107), `builder/AdminJourneyBuilderPage.tsx` (line 6), `builder/FlowAuthoringPanel.tsx` (line 13) +- `src/frontend/src/sessions/SessionList.tsx`, `sessions/SessionDetail.tsx`, `webhooks/WebhookDeliveries.tsx`, `analytics/FlowAnalytics.tsx`, `flows/FlowVersionHistory.tsx` + +### The handler grants Operator unconditionally + +```csharp +var claims = new[] +{ + new Claim(ClaimTypes.NameIdentifier, "api-key-user"), + new Claim(ClaimTypes.Name, "api-key-user"), + new Claim(ClaimTypes.Role, AppRoles.Operator), +}; +``` + +### What the leaked key unlocks + +With the key alone (no SSO, no cookie) a caller can reach, among others: + +| Endpoint | Policy | Exposure | +|---|---|---| +| `GET /api/workflow/sessions` | `OperatorOnly` | Every applicant session across every flow | +| `GET /api/workflow/sessions/{id}/submissions` | `OperatorOnly` | All submitted form data (PII) | +| `GET /api/workflow/flows/{id}/stats` | `OperatorOnly` | Business metrics | +| `POST`/`PUT`/`DELETE /api/flows` | Operator | Create, rewrite or delete journey definitions | +| `POST /api/webhooks` | Operator | Register an attacker-controlled webhook and exfiltrate future events | + +The resource-based `SessionOwnershipHandler` short-circuits on `IsInRole(Operator)`, so per-session ownership checks provide no defence either. + +The `Applicant` role and the `customerProfileId` ownership path already exist in the backend, but the shipped frontend has no way to obtain an applicant credential — it uses the Operator API key for applicant traffic. + +--- + +## Requirements + +1. Stop shipping a privileged credential to the browser. The public onboarding app must authenticate as an **applicant**, not an operator. +2. Introduce a per-session applicant credential issued by the backend at `POST /api/workflow/sessions/start` (e.g. a short-lived JWT carrying `role=Applicant` and `customerProfileId`), and have the SPA send that instead of `X-Api-Key`. +3. Treat `X-Api-Key` as a **server-to-server** credential only: document it as such, and consider binding it to a named integration principal rather than a blanket Operator role. +4. Operator-scoped UI must use the existing SAML `AdminSession` cookie, not an API key. +5. Remove `VITE_API_KEY` from `.env.example` and from all frontend source, or restrict it to a local-development-only escape hatch that is absent from production builds. + +--- + +## Acceptance Criteria + +- [ ] A production frontend build contains no API key string: `grep -r "$VITE_API_KEY" dist/` returns no matches. +- [ ] Starting a session and submitting a step succeed without any `X-Api-Key` header being sent from the browser. +- [ ] An applicant credential issued for session A returns `403 Forbidden` when used against session B's `GET /api/workflow/sessions/{id}` and `.../submit`. +- [ ] An applicant credential returns `403 Forbidden` from `GET /api/workflow/sessions`, `GET /api/workflow/sessions/{id}/submissions` and `GET /api/workflow/flows/{id}/stats`. +- [ ] Operator-scoped frontend views authenticate with the `AdminSession` cookie; removing the cookie makes them return `401`/`403` rather than succeeding via an API key. +- [ ] `src/frontend/.env.example` no longer instructs developers to place an Operator-equivalent API key in the browser bundle. +- [ ] Integration tests cover: applicant credential accepted for its own session; rejected for another session; rejected for every `OperatorOnly` endpoint. +- [ ] `docs/runbook.md` documents the applicant credential lifetime and the server-to-server-only status of `Authentication:ApiKey`. diff --git a/docs/issues/12-operator-ui-on-public-route.md b/docs/issues/12-operator-ui-on-public-route.md new file mode 100644 index 0000000..df8a784 --- /dev/null +++ b/docs/issues/12-operator-ui-on-public-route.md @@ -0,0 +1,52 @@ +--- +title: "Operator-only UI is rendered on the unauthenticated applicant route" +labels: ["security", "frontend"] +--- + +## Summary + +`App.tsx` guards **only** `/admin/journey-builder` behind the SSO session check. The root route `/` — the page an applicant loads to complete onboarding — renders the full operator toolset below the step renderer with no authentication gate at all. + +**Affected file:** `src/frontend/src/App.tsx` + +### The guard covers one route + +```tsx +if (isAdminJourneyBuilderRoute && adminAuthState !== 'authorized') { ... } // line 223 +if (isAdminJourneyBuilderRoute && adminAuthState === 'authorized') { ... } // line 231 +``` + +Everything after that falls through to the applicant view, which renders: + +| Component | Line | What it exposes | +|---|---|---| +| `FlowAuthoringPanel` | 320 | Create / edit / publish journey definitions | +| `FlowVersionHistory` | 328 | Version history and rollback controls | +| `FlowAnalytics` | 372 | Aggregate conversion and drop-off metrics | +| `SessionList` / `SessionDetail` | 384 | Every applicant's session, including submitted field data | +| `WebhookDeliveries` | 390 | Webhook endpoints and delivery payloads | + +This is a separate defect from the credential problem in the API-key issue: even after the backend stops trusting a browser-held Operator key, these views would still render and issue operator API calls on the applicant's page. Their requests would then fail with `401`/`403`, leaving broken UI in front of applicants. + +The applicant page also renders a "Journey" and "Persona" picker plus the Visual Journey Builder canvas, which let an applicant switch themselves onto any seeded flow. + +--- + +## Requirements + +1. Move every operator-facing section off the applicant route onto an authenticated route (e.g. extend `/admin` to host flow authoring, version history, analytics, sessions and webhook deliveries). +2. Apply the existing `adminAuthState === 'authorized'` check — which already requires an `Operator` role from `GET /api/auth/me` — to those routes. +3. Reduce the applicant route to the onboarding experience: the step renderer and its progress indication. +4. Replace the ad-hoc `window.location.pathname` routing with a single place that maps route → required role, so a new admin surface cannot be added without a guard. + +--- + +## Acceptance Criteria + +- [ ] Loading `/` with no `AdminSession` cookie renders only the onboarding step renderer — none of `FlowAuthoringPanel`, `FlowVersionHistory`, `FlowAnalytics`, `SessionList`, `SessionDetail` or `WebhookDeliveries` appear in the DOM. +- [ ] Loading `/` issues no request to any `OperatorOnly` endpoint (`/api/workflow/sessions`, `/api/workflow/flows/{id}/stats`, `/api/webhooks`). +- [ ] The journey and persona selectors are not present on the unauthenticated applicant route. +- [ ] Operator sections are reachable on an authenticated admin route and render there for a user whose `/api/auth/me` response includes the `Operator` role. +- [ ] A user without the `Operator` role who navigates directly to an admin route is redirected to `/login` and never sees operator data. +- [ ] Route → required-role mapping lives in one module covered by unit tests. +- [ ] Vitest tests assert the unauthenticated root render excludes each operator component; a Playwright spec covers the redirect for an unauthenticated admin route. diff --git a/docs/issues/13-frontend-unit-tests-not-in-ci.md b/docs/issues/13-frontend-unit-tests-not-in-ci.md new file mode 100644 index 0000000..1fbd6fa --- /dev/null +++ b/docs/issues/13-frontend-unit-tests-not-in-ci.md @@ -0,0 +1,64 @@ +--- +title: "npm test fails on a clean checkout and frontend unit tests never run in CI" +labels: ["bug", "testing", "frontend"] +--- + +## Summary + +Two defects compound each other: + +1. `npm test` (vitest) **fails** on a clean checkout — it collects the Playwright specs in `e2e/` and every one of them errors. +2. The `frontend-ci` job never runs `npm test`, so the failure is invisible and the 108 frontend unit tests added for the frontend-unit-tests issue are **not enforced on any pull request**. + +**Affected files:** +- `src/frontend/vitest.config.ts` +- `.github/workflows/ci.yml` (`frontend-ci` job, lines 93–120) +- `src/frontend/playwright/onboarding-journeys.spec.ts` (orphaned duplicate) + +### Reproduction + +``` +$ cd src/frontend && npm test + Test Files 6 failed | 14 passed (20) + Tests 108 passed (108) +``` + +Each failure is the same: + +``` +Error: Playwright Test did not expect test.describe() to be called here. + ❯ e2e/step-renderer.spec.ts:41:6 +``` + +### Root cause + +`vitest.config.ts` excludes the wrong directory: + +```ts +exclude: [...configDefaults.exclude, 'playwright/**'], +``` + +The Playwright specs live in `e2e/` — `playwright.config.ts` sets `testDir: './e2e'`. The `playwright/` directory still contains `onboarding-journeys.spec.ts`, byte-identical to `e2e/onboarding-journeys.spec.ts`, and is outside `testDir`, so that copy is never executed by Playwright either. Vitest excludes the dead directory and collects the live one. + +### Why CI did not catch it + +`frontend-ci` runs `npm ci` → `npm run lint` → `npm run build`. There is no test step. The other jobs run `npm run test:pact` and `npm run test:e2e`, so Pact and Playwright are covered, but the vitest suite is not. + +--- + +## Requirements + +1. Fix the vitest exclude so it matches the real Playwright directory (`e2e/**`), or scope vitest's `include` to `src/**`. +2. Delete the orphaned `src/frontend/playwright/` directory — it is a stale duplicate that no runner executes. +3. Add a test step to `frontend-ci` so a failing unit test blocks the pull request. +4. Consider uploading vitest coverage as a build artifact, matching the backend job's behaviour. + +--- + +## Acceptance Criteria + +- [ ] `npm test` exits `0` on a clean checkout of `main`. +- [ ] `npm test` reports `20 passed (20)` test files — no Playwright spec is collected by vitest. +- [ ] `src/frontend/playwright/` no longer exists, and `npm run test:e2e` still runs the full `e2e/` suite. +- [ ] `frontend-ci` runs `npm test` and the job fails when a unit test fails (demonstrate with a deliberately broken test on a scratch branch). +- [ ] Introducing a failing vitest assertion causes a red CI check on a pull request. diff --git a/docs/issues/14-sse-single-instance.md b/docs/issues/14-sse-single-instance.md new file mode 100644 index 0000000..39b69d2 --- /dev/null +++ b/docs/issues/14-sse-single-instance.md @@ -0,0 +1,50 @@ +--- +title: "SSE session progress events break when the API runs more than one replica" +labels: ["bug", "reliability", "infrastructure"] +--- + +## Summary + +`ISessionEventEmitter` has exactly one implementation, `InMemorySessionEventEmitter`, registered unconditionally as a singleton. It holds a `ConcurrentDictionary>` in **process memory**, so an event written on one API instance can only be read by a `GET /api/workflow/sessions/{id}/events` stream attached to that same instance. + +Both CD workflows deploy to platforms that routinely run more than one replica — Azure Container Apps (`cd-azure.yml`) and Amazon ECS (`cd-aws.yml`). At two or more replicas, an applicant whose SSE stream is held by instance B silently receives nothing for a step submitted through instance A. + +**Affected files:** +- `src/backend/OpenOnboarding.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs` (line 67) +- `src/backend/OpenOnboarding.Infrastructure/Services/InMemorySessionEventEmitter.cs` +- `src/backend/OpenOnboarding.Api/Controllers/WorkflowController.cs` (`GET sessions/{sessionId:guid}/events`, line 269) + +### The registration has no distributed alternative + +```csharp +services.AddSingleton(); +``` + +Unlike `IEventBus` — which already selects `RabbitMqEventBus` when `RabbitMq:Host` is configured and falls back to `InMemoryEventBus` otherwise — there is no configuration-driven swap for the event emitter. + +### Failure mode + +The failure is silent. The stream stays open, the client sees no error, and progress simply never arrives; the applicant appears stuck until they reload and re-fetch state over HTTP. Sticky sessions would mask it only while the assigned instance survives — a scale-in or rolling deploy re-breaks it mid-journey. + +RabbitMQ is already a dependency in `docker-compose.yml` and the infrastructure layer, so a fan-out adapter has a natural home. + +--- + +## Requirements + +1. Add a distributed `ISessionEventEmitter` adapter that fans an event out to every API instance — a RabbitMQ fanout exchange (reusing the existing connection management) or Redis pub/sub. +2. Select it by configuration the same way `IEventBus` is selected, keeping `InMemorySessionEventEmitter` as the single-instance and test default. +3. Preserve the existing semantics: per-session channels, the 100-event capacity bound, the completion/abandonment terminal events, and the stale-channel cleanup. +4. Document the deployment requirement — running more than one replica without a distributed emitter configured must be called out in the runbook. +5. Consider failing startup (or logging a prominent warning) when the in-memory emitter is active in a non-Development environment. + +--- + +## Acceptance Criteria + +- [ ] A distributed `ISessionEventEmitter` implementation exists and is registered when its transport is configured; `InMemorySessionEventEmitter` remains the default otherwise. +- [ ] An integration test starts two API hosts sharing one transport, opens the SSE stream on host B, emits an event through host A, and asserts host B's stream receives it. +- [ ] Terminal events (`session-completed`, `session-abandoned`) close the stream on every instance, not just the emitting one. +- [ ] Channel capacity limits and stale-channel cleanup behave identically for both implementations, covered by tests. +- [ ] A non-Development environment running the in-memory emitter logs a warning naming the scale-out limitation. +- [ ] `docs/runbook.md` documents the configuration keys and states that multi-replica deployments require the distributed emitter. diff --git a/docs/issues/15-saml-assertion-encryption.md b/docs/issues/15-saml-assertion-encryption.md new file mode 100644 index 0000000..7fb363e --- /dev/null +++ b/docs/issues/15-saml-assertion-encryption.md @@ -0,0 +1,65 @@ +--- +title: "SAML metadata advertises assertion encryption the ACS cannot decrypt" +labels: ["bug", "security", "authentication"] +--- + +## Summary + +`GET /auth/saml/metadata` publishes a `` containing the SP certificate, telling every identity provider that Waymark accepts encrypted assertions. The callback never configures a decryption certificate, so an IdP that honours the advertised key will produce `EncryptedAssertion` responses that the ACS cannot read — every login fails with a generic `saml_invalid_assertion` redirect. + +Azure AD, Okta and Auth0 all offer to encrypt assertions when the SP metadata advertises an encryption key, and some tenants enforce it by policy. + +**Affected file:** `src/backend/OpenOnboarding.Api/Controllers/SamlAuthController.cs` + +### Metadata advertises the capability (line 45) + +```xml + + + + {certBase64} + + + +``` + +### `BuildSamlConfiguration` never sets a decryption certificate + +```csharp +var config = new Saml2Configuration +{ + Issuer = issuer, + SingleSignOnDestination = new Uri(idpSsoUrl), + SigningCertificate = spCert, + CertificateValidationMode = X509CertificateValidationMode.None, + RevocationMode = X509RevocationMode.NoCheck +}; +``` + +`Saml2Configuration.DecryptionCertificate` is required for ITfoxtec to unwrap an `EncryptedAssertion`. The SP certificate is already loaded with its private key (`X509Certificate2.CreateFromPem(certPem, keyPem)`), so the material needed is in hand. + +### Related loose end + +`src/frontend/src/App.tsx` defines login error messages for `saml_assertion_not_encrypted` and `saml_certificate_expired`, but the controller emits only `saml_invalid_assertion`, `saml_access_denied` and `saml_csrf_failed`. Those two messages are unreachable, and an encrypted-assertion failure surfaces to the operator as the generic "Invalid SAML assertion" with nothing in the logs distinguishing it. + +--- + +## Requirements + +1. Set `DecryptionCertificate` on the `Saml2Configuration` from the configured SP certificate and private key so `EncryptedAssertion` responses decrypt. +2. Add an optional `Authentication:Saml:RequireEncryptedAssertion` setting. When `true`, reject an unencrypted assertion and redirect with `saml_assertion_not_encrypted`. +3. If encryption will not be supported, remove the `use="encryption"` `KeyDescriptor` from metadata instead — the current state advertises a capability that does not exist and must not stay as-is. +4. Distinguish decryption and signature failures in the logs so an operator can tell them apart from the generic redirect. +5. Reconcile the frontend error-code table with the codes the controller actually emits (including `saml_certificate_expired`, which is currently unreachable). + +--- + +## Acceptance Criteria + +- [ ] A `POST /auth/saml/callback` carrying an `EncryptedAssertion` encrypted to the SP certificate decrypts successfully and issues an `AdminSession` cookie. +- [ ] Both a signed unencrypted assertion and a signed encrypted assertion are accepted when `RequireEncryptedAssertion` is unset or `false`. +- [ ] With `RequireEncryptedAssertion=true`, an unencrypted assertion redirects to `/login?error=saml_assertion_not_encrypted`. +- [ ] An assertion encrypted to the wrong key redirects to `/login?error=saml_invalid_assertion` and logs a decryption-specific message. +- [ ] Tests cover the encrypted happy path, the wrong-key failure, and the `RequireEncryptedAssertion` rejection, using self-signed test certificates in the style of the existing `SamlAuthControllerTests`. +- [ ] Every error code in the frontend `LOGIN_ERROR_MESSAGES` table is reachable from the controller, or is removed. +- [ ] `docs/runbook.md` documents the encryption configuration and the IdP-side setup it implies. diff --git a/docs/issues/16-analytics-no-durable-sink.md b/docs/issues/16-analytics-no-durable-sink.md new file mode 100644 index 0000000..22cf6d6 --- /dev/null +++ b/docs/issues/16-analytics-no-durable-sink.md @@ -0,0 +1,55 @@ +--- +title: "Journey analytics events have no durable sink and browser events never reach the backend" +labels: ["enhancement", "observability"] +--- + +## Summary + +The analytics pipeline is fully built on both sides but terminates in a log statement on the backend and in `console` on the frontend. No journey analytics event is ever persisted or exported, and browser-side events never leave the browser at all — there is no ingestion endpoint to receive them. + +**Affected files:** +- `src/backend/OpenOnboarding.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs` (lines 79–82) +- `src/backend/OpenOnboarding.Infrastructure/Services/ConsoleAnalyticsProvider.cs` +- `src/frontend/src/analytics/consoleAnalyticsSink.ts` +- `src/frontend/src/analytics/JourneyAnalyticsContext.tsx` + +### Backend: one sink, and it writes to the logger + +```csharp +if (configuration.GetValue("Analytics:ConsoleProvider:Enabled", true)) + services.AddSingleton(); + +services.AddSingleton(); +``` + +`TelemetryService` already fans out to every registered `IAnalyticsProvider` in parallel with per-provider error isolation, and `StartSessionCommandHandler` and `SubmitStepCommandHandler` already emit through it. The extension point works — nothing is plugged into it. + +### Frontend: events are dispatched to a console sink only + +`JourneyAnalyticsProvider` supports `registerSink` at runtime and `App.tsx` passes `initialSinks={[consoleAnalyticsSink]}`. Client-side events (step viewed, field interactions, journey abandonment) are therefore observable only in an open devtools console on the applicant's own machine. + +### What still works + +This is not a total absence of analytics: `SessionAnalyticsService` computes aggregate flow statistics directly from persisted sessions and submissions, and `GET /api/workflow/flows/{id}/stats` backs the `FlowAnalytics` view. What is missing is the **event stream** — per-step timing, drop-off points, and any client-side signal. + +--- + +## Requirements + +1. Add at least one durable `IAnalyticsProvider` — persist `AnalyticsEvent` rows to the database, or forward to an OpenTelemetry/OTLP collector — selected by configuration in the same style as `IEventBus` and `IVirusScanService`. +2. Add an ingestion endpoint (e.g. `POST /api/analytics/events`) that accepts batched client events, validates and rate-limits them, and dispatches them through `ITelemetryService`. +3. Ship an HTTP sink for the frontend that batches events and posts them to that endpoint, keeping the console sink for development. +4. Attribute client events to the authenticated session so they cannot be forged for arbitrary sessions. +5. Define a retention policy for stored events, consistent with the existing document-cleanup background service. + +--- + +## Acceptance Criteria + +- [ ] A durable analytics provider is registered by configuration and stores or forwards every event `TelemetryService` dispatches. +- [ ] Completing a journey produces a retrievable ordered event trail for that session, covering session start and each step submission. +- [ ] `POST /api/analytics/events` accepts a batch, rejects events whose session id does not match the caller's credential with `403`, and is rate-limited. +- [ ] The frontend HTTP sink batches events and survives endpoint failure without breaking the onboarding flow (failures are dropped or retried, never surfaced to the applicant). +- [ ] Disabling the durable provider by configuration leaves the application working with the console provider only. +- [ ] A retention policy is implemented and documented; stored events older than the configured window are removed. +- [ ] Unit tests cover provider registration by configuration, ingestion authorisation, and sink batching/failure handling. diff --git a/docs/issues/README.md b/docs/issues/README.md index 3749259..a437387 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -29,12 +29,20 @@ The workflow also fires automatically whenever a numbered issue markdown file (` | 8 | [08-frontend-app-error-boundary.md](08-frontend-app-error-boundary.md) | Frontend — add top-level error boundary for auth/routing failures | reliability, frontend | | 9 | [09-webhook-cancellation-token.md](09-webhook-cancellation-token.md) | WebhookService — CancellationToken not propagated through delivery retry loop | bug, reliability | | 10 | [10-cloud-document-storage.md](10-cloud-document-storage.md) | Add cloud/blob storage adapter for document storage (production readiness) | enhancement, infrastructure | +| 11 | [11-browser-api-key-grants-operator.md](11-browser-api-key-grants-operator.md) | Frontend API key grants full Operator access to every browser visitor | security, authentication, frontend | +| 12 | [12-operator-ui-on-public-route.md](12-operator-ui-on-public-route.md) | Operator-only UI is rendered on the unauthenticated applicant route | security, frontend | +| 13 | [13-frontend-unit-tests-not-in-ci.md](13-frontend-unit-tests-not-in-ci.md) | npm test fails on a clean checkout and frontend unit tests never run in CI | bug, testing, frontend | +| 14 | [14-sse-single-instance.md](14-sse-single-instance.md) | SSE session progress events break when the API runs more than one replica | bug, reliability, infrastructure | +| 15 | [15-saml-assertion-encryption.md](15-saml-assertion-encryption.md) | SAML metadata advertises assertion encryption the ACS cannot decrypt | bug, security, authentication | +| 16 | [16-analytics-no-durable-sink.md](16-analytics-no-durable-sink.md) | Journey analytics events have no durable sink and browser events never reach the backend | enhancement, observability | ## Priority summary | Priority | Issues | |----------|--------| -| 🔴 Critical | #1 (SAML placeholder is the only SSO implementation) | -| 🟠 High | #2, #5, #7, #9 (untested background service; security bug; untested frontend core; cancelled delivery not recorded) | -| 🟡 Medium | #3, #4, #6, #8 (observability; resilience; memory leak; UX safety net) | -| 🟢 Low / Enhancement | #10 (production storage — needed before cloud deploy) | +| 🔴 Critical | #11 (browser-held API key grants full Operator access), #12 (operator data on the public applicant route) | +| 🟠 High | #13 (`npm test` is red and unenforced in CI), #14 (SSE silently breaks above one replica) | +| 🟡 Medium | #15 (SAML metadata advertises encryption that does not work) | +| 🟢 Low / Enhancement | #16 (analytics event stream has no durable sink) | + +Items #1–#10 were the first backlog sweep and are all resolved; #11–#16 come from the September 2026 gap review.