How the DualMind Arena frontend is built, served and organised while it moves from static HTML and vanilla JavaScript to a React app, page by page. The plan and its slices are in docs/redesign/migration-plan.md; the decisions behind this setup are recorded as ADRs.
arena.dualmindlab.tech (Cloudflare)
│
┌──────────▼──────────┐
│ worker.js │ dualmind-arena Worker, run_worker_first
│ 1. waitlist gate │ FORCE_WAITLIST + ?preview= cookie
│ 2. /api/* proxy │ -> api.dualmindlab.tech (server/api-proxy.js)
│ 3. redirects │ table in server/redirects.js
│ 4. SPA fallback │ client-only routes (server/routing.js)
│ 5. static assets │ env.ASSETS = dist/, server/caching.js
│ + security headers │ every response; CSP for HTML
└──────────┬──────────┘
│
dist/ (composed by build.js at build time)
┌─────────────────────────┴─────────────────────────┐
legacy files (repo root) web/build/client (React app)
legacy-app.html (copy of index.html), index.html (/ landing, prerendered),
leaderboard/, about/, js/, css/, design/index.html (prerendered),
components/, assets/*.png ... assets/<name>-<hash>.js|css|woff2,
__spa-fallback.html, og/, favicon.svg
-
One Worker, one
dist/. Old and new pages live side by side. Each slice moves some URLs to the React app; the others keep serving the legacy files until their slice lands (strangler migration, ADR 0002). -
Static output only. The React app uses React Router in framework mode with
ssr: false: listed pages are prerendered to HTML at build time and hydrate in the browser; client-only routes use the SPA fallback page. There is no server rendering at request time (ADR 0001). -
Pages the web app serves today (the route table is
web/src/app/routes.ts; every other URL is still a legacy file):URL Route module Feature Rendering Replaces /design/routes/design.tsxdesign-systemprerendered, noindex(new) /leaderboard/routes/leaderboard.tsxleaderboardprerendered shell; the board loads in the browser from the public GET /api/arena/model-stats(no sign-in) and refreshes every minuteleaderboard/index.html/models/routes/models.tsxmodels(onleaderboard)prerendered shell; same live data as the leaderboard models/index.html -
The pre-launch gate stays.
FORCE_WAITLISTinworker.jssends every visitor to the waitlist;?preview=dualmind-previewsets adm_previewcookie that lets you in. Slices can therefore merge tomasterwithout exposing half-migrated states.
DualMind_UI/
├── web/ # The React app (npm, its own package-lock.json)
│ ├── src/
│ │ ├── app/ # root.tsx (HTML shell), routes.ts (the route table)
│ │ │ └── routes/ # thin route modules, one per URL
│ │ ├── features/ # one folder per domain, each with an index.ts public API
│ │ ├── components/ # shared: ui/ primitives, layout/ shell, motion/ wrappers, vendor/ third-party code
│ │ ├── hooks/ # shared hooks (use-theme, use-reduced-motion)
│ │ ├── lib/ # framework-free code: utils/, theme/, api/ (S5), supabase/ (S6)
│ │ ├── styles/ # tokens.css (single source of truth), base.css, app.css
│ │ ├── config/ # env.ts: typed, validated Vite env
│ │ └── types/ # shared declarations (env.d.ts)
│ ├── public/ # copied as is (favicon.svg)
│ ├── tests/ # unit/ (Vitest + Testing Library), worker/ (Vitest, Node), e2e/ (Playwright)
│ ├── react-router.config.ts # ssr: false + the prerender list
│ └── vite.config.ts, vitest.config.ts, playwright.config.ts, eslint.config.mjs, tsconfig.json
├── worker.js # the Arena Worker (entry)
├── server/ # Worker modules, each with its own tests in web/tests/worker/
│ ├── api-proxy.js # hardened same-origin /api/* proxy
│ ├── problem-rewrite.js # opt-in problem+json errors (BE-1)
│ ├── audio-alias.js # deprecated /api/audio/tts alias (BE-2)
│ ├── rate-limit.js # per-user rate limits with standard headers (BE-3)
│ ├── caching.js # the Cache-Control table
│ ├── redirects.js # the legacy redirect table
│ ├── routing.js # hashed-asset pattern, SPA fallback routes, noindex paths
│ ├── security-headers.js # security headers, web and legacy CSP
│ └── web-manifest.js # reads dist/__web-manifest.json (pages, CSP script hashes)
├── build.js # composes dist/ (see section 4)
├── wrangler.jsonc # Worker config: assets.directory = dist, run_worker_first
├── .node-version # Node 22, for Workers Builds and CI
├── waitlist-worker/ # the separate waitlist app and Worker (unchanged)
└── (legacy html/css/js) # removed slice by slice until the cutover (S13)
Every top-level folder of web/src has a README with its purpose and rules.
| Route | Page | Notes |
|---|---|---|
/design/ |
Style guide | noindex |
/about/, /how-it-works/, /faq/ |
Info pages (features/info) |
Indexed. How it works has #rating; the FAQ has a deep link per question and FAQPage JSON-LD |
/privacy/, /terms/, /cookies/ |
Legal pages (features/info) |
Indexed, printable, no effects |
| everything else | 404 page (* route, prerendered as /404) |
build.js moves 404/index.html to dist/404.html; the Worker serves it with status 404, noindex and the web CSP |
build.js also writes dist/sitemap.xml and dist/robots.txt from these pages (a page is listed unless its robots meta says noindex; the SPA fallback and the 404 page never are), plus the legacy pages that are still static files (LEGACY_PUBLIC_PAGES). The Worker no longer generates either file.
- Rendering: a prerendered page arrives as complete HTML (readable without JavaScript), then React hydrates it. Route modules in
src/app/routes/only compose a feature's page component. - Theme:
src/lib/theme/theme.tsowns the preference (system,darkorlight, stored inlocalStorage['dm.theme']). An inline script in<head>applies it before the first paint (<html data-theme>);useTheme()reads and changes it withuseSyncExternalStore, so tabs stay in sync. Tokens insrc/styles/tokens.cssswitch ondata-themeand, for "system", onprefers-color-scheme. - Motion:
useReducedMotion()follows the OS setting live;base.cssalso stops CSS animations under reduced motion. - API and auth (from slices S5 and S6): the typed client in
src/lib/api/calls same-origin/api/*, which the Worker proxies to the backend (ADR 0006); server state goes through TanStack Query (ApiProviderinsrc/components/layout/, mounted byAppProviders, holds the one client and query cache; features read the client withuseApiClient()); one Supabase client insrc/lib/supabase/. Battles run on an own state machine (ADR 0004). - Landing page and the signed-in hand-off (slice S3):
/is the prerendered landing page (src/features/marketing/). Until the arena slice (S8) renders the signed-in arena at/, the legacy arena lives on as/legacy-app.html(build.js copies the legacyindex.htmlto the root ofdist/, so its relative script paths still resolve). The landing route'shandleasks the root layout for a tiny inline script in<head>(src/lib/session/handoff.ts, allowed by its CSP hash like every inline script): whenlocalStorageholds a Supabase session (dualmind.auth.supabaseor a supabase-jssb-<ref>-auth-tokenkey), or the URL carries Supabase auth parameters (#access_token=,#refresh_token=,?code=,?token_hash=,type=recoveryand the other email link types), it callslocation.replace('/legacy-app.html' + search + hash)before the first paint, so OAuth returns, email links and signed-in visitors still land in the working app. Workers static assets serve that file at its clean URL/legacy-app(a 307 that keeps the query; the browser keeps the hash). - The arena at
/(slice S8,src/features/arena/): the head script (src/lib/session/handoff.ts) also reads the visitor's arena choice:?arena=newor?arena=legacyon/stores it inlocalStorage['dm.arena']. Since the battle flow (slice S9)NEW_ARENA_BY_DEFAULTis true: a signed-in visitor (or a Supabase auth return, which supabase-js finishes in the new app) stays on/unless they choselegacy, and atype=recoverylink continues to/update-password.html. The script sets<html data-arena>instead of handing off, CSS (styles/base.css) hides the prerendered landing page and shows the arena's loading frame (ArenaBoot, part of the prerendered HTML), anduseHomeView()swaps in the lazily loadedArenaApponceuseAuth()confirms the session (or brings the landing page back when the session is gone). The URL carries the open thread (/?thread=<id>, React Router search params, so Back and Forward work); preferences live in localStorage (dm.arena.mode,dm.arena.sidebar,dm.arena.shortcuts, and the legacy model keysbattle.model.left,battle.model.right,direct.model). Server state goes through the typed client's TanStack queries (threads, thread, messages, models, leaderboard) and mutations (rename, delete). Answers render with Streamdown, hardened inmarkdown.tsx(no raw HTML, sanitize then rehype-harden, https-only links, no images). Battles run onuseBattle(hooks/use-battle.ts) over a pure reducer (lib/battle-machine.ts; ADR 0004):startBattleis never retried, the first prompt creates the thread (its message cache is seeded empty so the live turns are not fetched twice, and refreshed when the visitor leaves), votes go throughcreateVoteQueue()(localStoragedm.voteQueue.v1, flushed ononline), and Direct mode reads the SSE stream withstreamChat()and an abort signal. - Auth (slice S6): see section 3.1.
- Live data on the landing page: one TanStack Query request to the public leaderboard (
GET /api/arena/model-statsthrough the typed client, the leaderboard page's cache key) feeds the live strip, the model strip and the top-five preview. Battles and votes are derived from it (src/features/marketing/lib/live-stats.ts); when the request fails, the page says so and shows no numbers. - Configuration:
src/config/env.tsreadsimport.meta.env(onlyVITE_variables reach the bundle), validates it and exportsconfig. Public values only.
- One client, loaded only when needed.
src/lib/supabase/client.tscreates the app's one supabase-js client (pinned@supabase/supabase-js, its own lazy chunk) withpersistSession,autoRefreshToken,detectSessionInUrlandflowType: 'implicit'(email links already in inboxes carry tokens in the#hash, and PKCE email links only work in the browser that asked for them). The storage key stays supabase-js's defaultsb-<project ref>-auth-token, which the legacy pages also write, so old and new pages share one session. Pages fetch supabase-js only when there is a stored session, auth data in the URL, or a sign-in action. - Settings from the build, never from the repo.
src/config/supabase.tsreadsVITE_SUPABASE_URLandVITE_SUPABASE_ANON_KEY(public values; production and previews should get them from the Workers Builds build variables, CI uses a dummy project). Until those variables are set,build.jsreads the same public URL and anon key from the legacyconfig.jsand passes them to the web build through the environment (no new file holds them); that fallback must be replaced by the build variables beforeconfig.jsis deleted at the cutover (S13). Without them the app still builds and runs:supabaseConfigis null, the auth state isunavailableand the sign-in pages show a "Sign-in is unavailable here" notice instead of forms. Secret and service-role keys are refused. - State.
src/lib/supabase/auth-store.tsis a small external store around a pure reducer:loading(prerendered HTML),signed-in,signed-out,unavailable.AuthProvider(src/components/layout/auth-provider.tsx, insideApiProviderinAppProviders) starts it after hydration; components readuseAuth()(session,user,profile,status,client(),signOut()). The profile comes fromPOST /api/users/syncthrough the typed API client, once per user per tab (cached insessionStorage['dm.profile.v1']). - Session migration. When the default key is empty but
localStorage['dualmind.auth.supabase'](written by the old/login/page) has tokens, the store adopts them withsetSession(); a copy the auth server rejects is removed so the attempt runs once. Until the arena slice (S8) the store also keepsdualmind.auth.supabasein step with the session, because the legacy arena reads it. - API token.
ApiProvidergives the typed clientgetAccessTokenfromsrc/lib/supabase/access-token.ts(the session's access token, refreshed by supabase-js; null without a session, without loading supabase-js). - Sign-out calls Supabase (all devices, as before; local-only if that fails), then removes the legacy session keys (
dualmind.auth.supabase,dualmind.auth.token,dualmind.auth,supabase.auth.token), the offline vote queue (dm.voteQueue.v1) and the cached profile. - Redirects after sign-in.
?redirect=is honoured only for a same-origin path (safeRedirect()insrc/lib/supabase/redirect.ts: no other origins,//host, backslashes, control characters or auth pages), default/. The page then loads it as a document, so/runs the landing page's hand-off to the legacy arena. OAuth returns to the bare origin, as before.useRequireAuth()is the route guard for later slices (sign-in redirect with the legacyauth_redirect_attemptedloop guard). - Pages (
src/features/auth/):/login/(Google, GitHub, then password, email code or text-message code),/signup/(name, email, password with the strength rules and the k-anonymity breach check, confirmation-email state),/forgot-password/, and the two Supabase email targets, which render in place at their old addresses:/auth-callback.html(confirms the link, sends the welcome email through theemail-wel-safeedge function, moves on to?redirector/; atype=recoverylink is forwarded to/update-password.htmlwith its hash by the route'sclientLoader, before the client touches the URL) and/update-password.html(sets the new password from the recovery session). After reading the tokens, the store removes them from the address bar.
node build.js (also npm run build / pnpm build at the root) is self-contained, because Cloudflare Workers Builds runs it unchanged:
- Build the web app:
npm ciandnpm run buildinweb/(react-router build, Vite). Skipped withSKIP_WEB_BUILD=1when a CI job built it already. - Copy the legacy allowlist (the static Arena files) into
dist/. - Overlay
web/build/client: new pages at their paths, hashed files intodist/assets/next to the legacy images. With/prerendered (slice S3), React Router writes its SPA fallback as__spa-fallback.htmland the landing page asindex.html, which replaces the legacy home page (replacedByWeb); the legacy page is first copied todist/legacy-app.htmlfor the signed-in hand-off. The build fails if areplacedByWebfile was not written by the web app, or ifdist/index.htmlturns out to be the SPA fallback. - Write
dist/__web-manifest.jsonfor the Worker: the web app's pages, the sha256 hashes of their inline scripts (for the CSP, section 6) and extraconnect-srcorigins: the Supabase project from theVITE_SUPABASE_URLbuild variable (https and wss; a warning, not a failure, when it is unset) andhttps://api.pwnedpasswords.comfor the sign-up breach check. Since S6 the legacy sign-in pages (login-modern.html,signup-modern.html,forgot-password.html,auth-callback.html,update-password.htmland thelogin/folder) are no longer copied: their URLs redirect or are served by the web app. Since S11 the legacyshare/folder is not copied either:/share/<id>is the web app's share page. - Check the result and fail the build when a legacy file or a sitemap URL is missing, when a web file would replace a legacy one (unless its slice lists it in
replacedByWeb: todayleaderboard/index.htmlandmodels/index.html), or when a file inassets/has no content hash.
- Cloudflare Workers Builds is connected to the repository and stays the deploy path (ADR 0005). It deploys
masterto production and builds every branch as theWorkers Builds: dualmind-arenacheck, with a preview alias URLhttps://<branch-slug>-dualmind-arena.creharsh.workers.dev(add?preview=dualmind-previewonce to pass the gate). - GitHub Actions run every check and never deploy the Arena:
ci.yml(root lint and the full build),web.yml(see section 7) andweb-lockfile.yml(a helper that resolvesweb/package-lock.jsonand autofixes in CI, for machines that should not runnpm install).
worker.js handles every request (run_worker_first: true), in this order:
- Waitlist gate. While
FORCE_WAITLISTis on, visitors without thedm_previewcookie get a 302 to the waitlist. - Rate limits (BE-3). Every
/api/*request first passescheckRateLimit()(server/rate-limit.js); over the limit it is answered with a 429 right there (see "Rate limits" below). Then/api/v2and/api/v2/*go to the Worker's own API, a Hono app inserver/v2/loaded with a dynamicimport()(see "The/api/v2router" below); they never fall through to the proxy. Other/api/*goes to the hardened proxy (server/api-proxy.js). - Legacy redirects from the table in
server/redirects.js(301). - Client-only routes of the web app get the SPA fallback page with status 200 (
SPA_FALLBACK_ROUTES:/update-password.htmland/auth-callback.htmlsince S6,/shareand/share/*since S11). Share links get it never cached and with their link-preview tags (see "Share page and link previews" below). og-image.png, then static files fromdist/(including the builtrobots.txtandsitemap.xml) with Cache-Control from the caching table.- Anything else gets the web app's 404 page (
dist/404.html, see below) with status 404; plain textNot Foundif that file is missing.
Every response then leaves through withSecurityHeaders(): the security headers below, and the Content-Security-Policy for HTML.
BE-0 (design). A Hono app with @hono/zod-openapi: routes are declared once with zod schemas (contract/, shared with the web app), which validate input, type handlers and generate docs/api/openapi.v2.json (CI fails when it is stale). Middleware: request context (request id, timer, one JSON log line per request), problem+json error handling (server/problem-types.js is the type registry, also usable by v1), a 1 MiB body limit, then per route auth() (Supabase JWT behind a verifier interface, jose) and guard() (the not_enabled guard: 501 when a binding or secret is missing). If the router cannot be loaded, worker.js answers a 503 problem and v1 and static files keep working.
- Production vs preview bindings:
server/v2/bindings.tspicksDB,CACHE,EVENTSandPUBLIC_BUCKETonly when the request host is exactlyarena.dualmindlab.tech, and the*_PREVIEWtwin for every other host, never falling back to production. - Configuration:
SUPABASE_URLis a publicvarsentry inwrangler.jsonc. Optional secrets, not set yet:SUPABASE_JWT_SECRET(legacy HS256 tokens) andPRIVACY_SALT(salted hashes of user ids in logs). Without them the code still works. - Dependencies:
hono,@hono/zod-openapi,joseandzodare declared inweb/package.json(the one lockfile CI and Workers Builds resolve).scripts/link-worker-deps.mjslinks them into the rootnode_modules, where wrangler's bundler looks;build.jsruns it. - Tests and checks (
api.yml):tscoverserver/,contract/andscripts/; Vitest in workerd through@cloudflare/vitest-plugin(vitest.api.config.mts,tests/api/) against the realworker.js;npm run api:openapi:check. The Node tests of the JS modules stay inweb/tests/worker(web.yml).
BE-3 (design, table of limits in endpoints.md). worker.js calls checkRateLimit() for every /api/* request (v1 and v2) before the proxy or the router runs. The request is classified into a group (battle, vote, speech, write, read; health checks and OPTIONS are exempt), keyed, and counted with that group's Workers Rate Limiting binding (RL_BATTLE, RL_VOTE, RL_SPEECH, RL_WRITE, RL_READ, declared under ratelimits in wrangler.jsonc with namespace_id 1001 to 1005, which are account-wide: do not reuse them in other Workers; no account resource is needed, and a test fails when wrangler.jsonc and RATE_LIMIT_GROUPS disagree). Allowed responses carry RateLimit-Policy; over the limit the Worker answers 429 with Retry-After, before any upstream call.
- Key:
u:<user id>when the Bearer token verifies through the BE-0 verifier (the token is never decoded otherwise, so forgedsubvalues cannot reset a counter); elseip:plus the first 32 hex characters of HMAC-SHA-256(PRIVACY_SALT,ip:<CF-Connecting-IP>).PRIVACY_SALTis an optional Worker secret that Harsh sets withwrangler secret put PRIVACY_SALT(not done yet). Until it is set, the rawCF-Connecting-IPis the in-memory key handed to the binding; this code never logs or stores it, andratelimit_unsaltedis logged once per isolate as a reminder. - Fail open: a missing, throwing or slow (over 50 ms) binding lets the request through with
X-RateLimit-Status: skippedand aratelimit_unavailablelog line. Kill switch: remove a binding fromwrangler.jsoncor raise its limit. - Logs:
rate_limited(requestId,group,keyKind,keyHash8,route;keyHash8is null without the salt) andratelimit_unavailable. No address, token or user id is logged. - Client: the typed client reads the 429 into
ApiErrorkindrate_limitedwithretryAfterMs;ApiProvidershows one toast ("Too many requests. Try again in N seconds."); the vote queue waits at leastRetry-Afterbefore it re-sends.
One table; the first matching row wins, and without a match the Workers static assets default stays. API responses are not touched: /api/* keeps the backend's headers.
| Row | Applies to | Cache-Control |
|---|---|---|
| hashed-build-file | Vite output, /assets/<name>-<hash>.<ext> (pattern HASHED_ASSET_RE, checked against the build by build.js) |
public, max-age=31536000, immutable |
| share | /share and everything below it: the share page, set by server/share.js |
no-store, no-cache, must-revalidate |
| web-page | HTML pages of the web app (from the manifest) and the SPA fallback | no-cache: revalidated on every visit, cheap because Workers static assets send an ETag (a matching If-None-Match gets a 304) |
| legacy-code | legacy HTML (also directory URLs and clean URLs such as /legacy-app or /login-modern), JS, CSS and JSON |
no-store, no-cache, must-revalidate, max-age=0, until the cutover |
| image | .png .jpg .jpeg .webp .gif .svg .ico |
public, max-age=31536000, immutable, as before |
/design/ and /__spa-fallback also send X-Robots-Tag: noindex, nofollow.
Slice S11; fixes KNOWN_ISSUES #3, where the legacy rewrite to /share/index.html answered with a 307 to /share/ and lost the id.
- Routes:
GETorHEADof/share,/share/<id>and the old forms/share/?id=<id>,?thread=<id>,?threadId=<id>(also under/share/index.html) answer the SPA fallback page with status 200 andCache-Control: no-store, no-cache, must-revalidate, never a redirect. The web app's routeshare/:threadId?reads the id with the same rules (web/src/lib/site/share.ts; a Worker test checks both parsers against one table), renders the page and replaces old forms with/share/<id>. The legacyshare/folder is no longer copied todist/, andbuild.jsandweb.ymlfail ifdist/share/comes back. - Link-preview tags: chat apps and search engines do not run JavaScript, so the Worker writes the tags into the fallback HTML with
HTMLRewriter: the<title>is filled, any description, robots, canonical, Open Graph or Twitter tag already in the page is removed, and a fresh set goes at the end of<head>(og:typearticle,og:title,og:description,og:urland canonical on/share/<id>,og:image/og/share.png1200 x 630,twitter:cardsummary_large_image). - Where the words come from: the Worker calls
GET /api/threads/{id}and/messageson the backend like any visitor: noAuthorization, no cookie, whatever the visitor sent. Only a thread the API returns aspublicorunlistedgets its own tags: the title is the first prompt (one line, cut at about 90 characters), the description says what kind of thread it is ("Two anonymous AI models answered. See which one won." for a voted battle). Model names are never written, so a battle stays blind in its preview. Private, unknown, slow or failing look-ups get generic tags, so the page never fails because of the preview. - Search engines: public threads are indexable; unlisted threads and the generic tags carry
robotsnoindex, nofollowand the sameX-Robots-Tag. - Budget and cache: one look-up has 1.5 s; its answer stays in the edge cache (Cache API, keyed by id) for 2 minutes, or 30 s when the thread is not shared; timeouts and errors are not cached. The log line
share_meta_fallbackhas the outcome and the time, never the id (an unlisted link is the key to its thread). - Tests:
web/tests/worker/share.test.ts(Node: ids, tags, look-up, cache, routing, headers),tests/api/share.test.ts(workerd: the realHTMLRewriterand Cache API throughworker.js),web/tests/e2e/share.spec.ts(the tags fromwrangler dev, the page and the dialog in Chromium). - Later: a per-battle preview image (BE-19) replaces the static card.
| Header | Value | Sent on |
|---|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains |
everything |
X-Content-Type-Options |
nosniff |
everything |
Referrer-Policy |
strict-origin-when-cross-origin |
everything |
Permissions-Policy |
camera=(), geolocation=(), payment=(), usb=(), microphone=(self) (the microphone stays available to the Arena itself for voice input later) |
pages and files |
Cross-Origin-Opener-Policy |
same-origin |
pages and files |
X-Frame-Options |
DENY (old browsers; frame-ancestors 'none' is the modern form) |
pages and files |
X-XSS-Protection |
removed: deprecated, and harmful in old browsers | never |
- Web app pages: enforced, no
'unsafe-inline'for scripts. React Router's output has inline scripts (the theme script, the router context, the module loader).build.jsreads every web HTML file indist/, takes the sha256 of each inline<script>'s exact text and writes the hashes todist/__web-manifest.json. The Worker reads that file once per isolate throughenv.ASSETS(server/web-manifest.js) and sendsscript-src 'self' 'sha256-…'. The hashes and the HTML come from the same build and travel in the samedist/, so they cannot drift apart; there is no generated source file to commit. - The full web policy:
default-src 'self'; script-src 'self' <hashes>; style-src 'self' 'unsafe-inline'(React style attributes); img-src 'self' data: blob:; font-src 'self'; connect-src 'self' <Supabase origin from the build env> https://api.pwnedpasswords.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'. The API is same-origin, soconnect-srcneeds no API origin. - If the manifest is missing or invalid, web pages get the same policy as report-only, so a broken build cannot block the page's own scripts. A test covers this fallback.
- Legacy pages:
Content-Security-Policy-Report-Onlywith a policy that matches what they load today: scripts and styles from jsDelivr, cdnjs and unpkg (with'unsafe-inline'), Google Fonts, images from any https origin, andconnect-srcfor the API, Supabase (https and wss), the Pwned Passwords range API and the local API onlocalhost:5079. Violations show in the browser console; the end-to-end tests load the main legacy pages and require zero violations. At the cutover (S13) the legacy pages go away with their policy.
Permanent (301), same origin, query string kept (the browser keeps the #hash). A folder row matches the path, the path with a trailing slash and anything below it.
| From | Match | To | Why |
|---|---|---|---|
/index.html |
exact | / |
One URL for the home page |
/about/about |
folder | /about/ |
Duplicate copy of the about page |
/how-it-works/how-it-works |
folder | /how-it-works/ |
Duplicate copy |
/careers |
folder | /about/ |
The copied careers page was removed |
/auth-verify.html |
exact | / |
Developer diagnostics |
/verify.html |
exact | / |
Developer diagnostics |
/login-modern.html, /login-modern |
exact | /login/ |
Old sign-in page (S6) |
/login/index.html |
exact | /login/ |
One URL for sign-in |
/login/login |
folder | /login/ |
Third copy of the sign-in page and its redirect.html |
/signup-modern.html, /signup-modern |
exact | /signup/ |
Old sign-up page |
/forgot-password.html |
exact | /forgot-password/ |
Old reset request page |
/auth-callback |
exact | /auth-callback.html |
Clean URL of the email callback |
/update-password |
exact | /update-password.html |
Clean URL of the reset page |
/auth-callback.html and /update-password.html themselves are never redirected: they are the Supabase email targets and render in place. Every row has a Worker test and an end-to-end check.
- Forwards only
Authorization,Content-Type,Accept,Accept-Language,X-Request-IdandIdempotency-Key(safe retries of a battle); cookies, client hints and every other header stay on the Arena origin. - Request ids: the caller's
X-Request-Idwhen it is 8-128 characters of[A-Za-z0-9._:-], otherwise a new UUID. It is sent to the backend and echoed on the response. - No CORS. The Arena calls its own origin, so the Worker sends no
Access-Control-*headers and strips the backend's (other sites cannot read API responses through the Arena origin). The old blanketOPTIONS204 is gone;OPTIONS /api/*is proxied like any other method. If a cross-origin caller ever needs the proxy, add an explicit allow-list of origins here instead of*. - No
x-dualmind-proxy-*debug headers. - Opt-in problem+json (BE-1): a request whose
Acceptnamesapplication/problem+jsongets an RFC 9457 body for every non-2xx answer, including the Worker's own 502 and 504 (server/problem-rewrite.js, mapping table in endpoints.md). Streams and success bodies are untouched; callers without the header (the legacy app) see the v1 bodies unchanged. At most 64 KiB of an upstream error body is read; 5xx text is logged, never forwarded. Error statuses addVary: Accept. - Text-to-speech alias (BE-2):
POST /api/audio/ttsis validated and forwarded to/api/speech/generateas{ text, voice? }, with a guaranteed audio content type,no-store, andDeprecation/Linkheaders (server/audio-alias.js). Deprecated; the typed client calls the real route. - Errors: an upstream failure answers
502 {"error":"bad_gateway","requestId":"…"}; no response within the route's timeout (30 s; 65 s for the model callsPOST /api/arena/dualchatandPOST /api/arena/chat) answers504 {"error":"gateway_timeout","requestId":"…"}. No error text leaks. - Streaming: bodies stream through untouched. A
text/event-streamresponse is never cut once it has started; other bodies must finish within the route's timeout. /api/healthmaps to the backend's/health;BACKEND_URL(Worker variable) overrides the backend origin.
| Layer | Tool | Where |
|---|---|---|
| Types | react-router typegen + tsc (strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes) |
web.yml check |
| Lint | ESLint flat config: typescript-eslint (type-checked), react-hooks, jsx-a11y, sorted imports, structure rules | web.yml check |
| Format | Prettier with the Tailwind plugin | web.yml check |
| Unit | Vitest + Testing Library (jsdom): theme, hooks, tokens and contrast, components, conventions | web.yml check |
| Worker | Vitest in Node with a fake ASSETS binding and a fake upstream: gate, every redirect row, the caching table, security headers and CSP, the manifest, the proxy (header allow-list, request ids, 502, 504, streaming, the problem+json rewrite per mapping row, the text-to-speech alias) |
web.yml check |
| End to end | Playwright (Chromium) against the composed dist/ served by the real Worker in wrangler dev (with a local fake backend for /api that answers the leaderboard with a recorded response): headers and CSP, redirects, the proxy, hydration, theme without flash, keyboard, overflow at 320 px, axe in both themes, zero report-only violations on legacy pages; on /: the prerendered hero and SEO tags, the page without JavaScript, the live strip and its fallback, the demo battle by keyboard, the signed-in hand-off |
web.yml e2e |
| Visual | Screenshots of / and /design/ at 1440x900 and 390x844 in dark, light and reduced motion, the landing demo after the reveal, and the rendered Open Graph card (web-screenshots artifact) |
web.yml e2e |
| Performance | Lighthouse CI on / (mobile, three runs, median): accessibility 1, best practices >= 0.95 and SEO 1 fail the job; performance >= 0.9 and at most 180 KB of gzipped JavaScript loaded up front by / are the targets and warn for now (the shared shell, React Router, Motion and Zod load about 290 KB up front). web/lighthouserc.json, lighthouse artifact, scores in the job summary |
web.yml e2e |
npm run check in web/ runs the first four locally on a machine that can.
- Naming: files and folders in kebab-case; React components are PascalCase named exports, one per file; hooks are
use-*.tsexportinguseX; tests are*.test.ts(x)(Vitest) and*.spec.ts(Playwright). - Imports: the only alias is
@/(web/src). A feature is imported only through itsindex.ts; inside a feature, relative imports.components/uihas no barrel. ESLint enforces all three. - Exports: named exports everywhere; only route modules,
root.tsxandroutes.tsexport a default. - Styling: token utilities only (
bg-surface,text-mind-a,border-line...). The default Tailwind palette is switched off, so a straybg-blue-500does not exist. No hex values in components. - Raw HTML:
dangerouslySetInnerHTMLis banned by ESLint; the inline theme script is the one reviewed exception, and model output will go through the sanitised Markdown renderer. - Tests: all tests live under
web/tests/(unit/mirrorssrc/,worker/,e2e/); no tests next to source files.tests/unit/conventions.test.tschecks the naming and folder rules. - Third-party code: in
components/vendor/<source>/with its licence file and a header comment per file; never paid, Pro or AGPL code (ADR 0003). - No new window globals in the web app: state lives in modules, hooks and providers.