| AC | Criterion | Verdict | Evidence |
|---|---|---|---|
| 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. |
/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.
=== 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"}
$ 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]
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:
/api/test/session is not enabled — FIXTURE_EMAIL /
FIXTURE_PASSWORD / MAGIC_LINK_SECRET are not set in the local env, so /api/test/session returns 404.web-echoai-pr-366.up.railway.app) is not built:
pr-preview-bootstrap.yml workflow run #25595244813 failed at the [rootDirectory] just-bash-mcp
step — pre-existing infra issue unrelated to this PR. Manual web-service redeploy also failed
(PUBLIC_CONVEX_URL is not exported — bootstrap was supposed to propagate alpha env vars but didn't).Affected ACs and their non-live coverage:
identity.email.searchParams.set('_auth', tok) logic is independent of email source.