Definition of Done — PR #366

feat: add generic /api/auth/internal-redirect endpoint for shared internal-product auth (REQ-20260509-a1f4)
PASS — with environmental blocks (AC5/AC7/AC9 happy-path)
Branch
feat/internal-auth-redirect
Commits ahead of alpha
6
HEAD
b058ed971
Tests
48 pass / 0 fail

Acceptance criteria

ACCriterionVerdictEvidence
AC1 Route GET /api/auth/internal-redirect exists PASS Live: HTTP/2 302 returned by route — see AC4 trace. Source: web/src/routes/api/auth/internal-redirect/+server.ts.
AC2 Query-param validation: 400 on bad inputs PASS Live curl, 6 cases (unknown product, cross-origin host, wrong scheme http://, userinfo trick, missing return_to, malformed return_to) all return 400 with structured {message, errorId}. See curl evidence below.
AC3 Registry shape (jammy/hancock/genops, 2 hosts each, secret env var name) PASS internal-redirect.server.ts:42–55 declares all 3 products, each with 2 allowedHosts (prod + Railway dev) and the correct *_EMBED_SECRET env-var name. Type-narrowed via secretEnvVar union.
AC4 No token → 302 to /?next=<encoded URL> PASS Live curl: HTTP/2 302, Location: /?next=%2Fapi%2Fauth%2Finternal-redirect%3F… Round-trip decode: '/api/auth/internal-redirect?product=jammy&return_to=https://jammy.echoai.zone/foo' — exactly the original URL. Playwright confirms the browser landed at the /?next=… URL with the signin UI rendered (screenshot below).
AC5 Root signin honours ?next after auth PASS-via-tests Wrap-pattern audit (grep): EVERY gotoWithTransition call in BOTH signin pages wraps through buildPostAuthDestination(page.url.searchParams, page.url.origin, fallback). Forwarding from root → org signin uses appendNextParam. 8 unit tests for buildPostAuthDestination cover same-origin (path), same-origin (full URL), cross-origin (rejected), open-redirect (//evil.com — rejected), missing/empty next, fallback. Live: ?next preserved on the URL through the no-session bounce (screenshot below). Live happy-path (sign-in → ?next consumed) blocked by fixture-seeding limitation.
AC6 Missing secret env var → 500 with non-leaky message PASS-via-tests R5 route test (server.test.ts) deletes process.env.JAMMY_EMBED_SECRET, calls the route with a mock token, asserts 500, asserts errorId='internal-redirect.missing-secret', asserts console.error logged with product+env_var (NAME, not VALUE).
AC7 users.currentEmail returns email for authed callers PASS-via-tests Convex query users.currentEmail (web/src/convex/users.ts:232–240) is one-liner: reads identity.email via ctx.auth.getUserIdentity(); returns {email} or null. Bypasses RLS deliberately (only returns the caller's own email). R6/R7/R8/R9 route tests cover: returns email + token issued, returns null → 500, throws → 500. Live verification blocked by fixture-seeding limitation (no /api/test/session bypass on local-cuan).
AC8 Token format pinned in JSDoc PASS internal-redirect.server.ts:12–23 JSDoc pins exact wire format: <base64url(payload-bytes)>.<base64url(hmac-bytes)> where payload-bytes = JSON.stringify({email,exp}) UTF-8 bytes, HMAC-SHA256 keyed with *_EMBED_SECRET, base64url no padding. Helper unit tests cover round-trip + shape (35 tests pass).
AC9 Happy path: 302 to <return_to>?_auth=<token> PASS-via-tests Route uses returnTo.searchParams.set('_auth', tok) — proper URL API, not string concat (web/src/routes/api/auth/internal-redirect/+server.ts:113). R6/R7/R10 cover 302 status, Location header contains <return_to>?_auth=<token>, existing query params preserved (R7: return_to with ?foo=1 → <return_to>?foo=1&_auth=…). Live verification blocked by fixture-seeding limitation.
AC10 Helpers in stated locations + tests PASS Three files exist: internal-redirect.server.ts (registry + HMAC sign/verify, Node-only), internal-redirect.ts (?next validators, browser-safe), internal-redirect.test.ts (35 helper tests). Plus server.test.ts (13 route tests). Total: 48 pass / 0 fail. Split into .server.ts surfaced + fixed a real node:crypto-leak-into-client-bundle bug.
AC11 No logging of token/secret/HMAC bytes PASS Code review of +server.ts: 4 console statements total. They log only product, return_to_host, email_domain (split('@')[1]), env_var (NAME, not value), and error.message. No token, no secret, no HMAC bytes anywhere. Test output (server.test.ts run) shows all log lines visible — none contain a token.
AC12 Rate limit inherited from /api/auth/* (30 req / 60s) PASS web/src/hooks.server.ts:38: if (pathname.startsWith(AUTH_PREFIX)) return { window: 60, max: 30 }. AUTH_PREFIX = '/api/auth/'. The new route inherits automatically; no separate limiter added.

Live evidence — bounce to signin (AC4 + AC5 partial)

Browser landed at /?next=… after no-session call to /api/auth/internal-redirect; signin UI is rendered correctly.
Playwright navigated to /api/auth/internal-redirect?product=jammy&return_to=https://jammy.echoai.zone/foo (no session). Server returned 302 → /?next=…. Browser landed at the signin page with ?next still on the URL — proving the no-token bounce works end-to-end and the signin UI receives the ?next param.

Live evidence — curl traces (AC1, AC2, AC4)

=== AC4: no-token bounce ===
HTTP/2 302 
date: Sat, 09 May 2026 07:39:16 GMT
location: /?next=%2Fapi%2Fauth%2Finternal-redirect%3Fproduct%3Djammy%26return_to%3Dhttps%253A%252F%252Fjammy.echoai.zone%252Ffoo
cf-ray: 9f8f0ca53fae141d-FRA
cf-cache-status: DYNAMIC
server: cloudflare
strict-transport-security: max-age=63072000; includeSubDomains; preload
vary: Origin

=== AC2.1: unknown product ===
Status: 400
{"message":"Unknown product","errorId":"internal-redirect.unknown-product"}

=== AC2.2: cross-origin host (evil.com) ===
Status: 400
{"message":"return_to host not allowed for this product","errorId":"internal-redirect.host-not-allowed"}

=== AC2.3: wrong scheme (http://) ===
Status: 400
{"message":"return_to host not allowed for this product","errorId":"internal-redirect.host-not-allowed"}

=== AC2.4: userinfo trick ===
Status: 400
{"message":"return_to host not allowed for this product","errorId":"internal-redirect.host-not-allowed"}

=== AC2.5: missing return_to ===
Status: 400
{"message":"Missing return_to","errorId":"internal-redirect.missing-return-to"}

=== AC2.6: malformed return_to ===
Status: 400
{"message":"Invalid return_to","errorId":"internal-redirect.invalid-return-to"}

Test run

$ bun test src/lib/auth/internal-redirect.test.ts src/routes/api/auth/internal-redirect/server.test.ts

bun test v1.3.13 (bf2e2cec)

src/lib/auth/internal-redirect.test.ts: 35 pass
src/routes/api/auth/internal-redirect/server.test.ts: 13 pass

 48 pass
 0 fail
 90 expect() calls
Ran 48 tests across 2 files. [45.00ms]

Environmental blocks (NOT functional bugs)

The following ACs cover authenticated end-to-end flows that need a logged-in EchoAI session. They could not be live-verified on this run because:

Affected ACs and their non-live coverage: