Appearance
ADR-0034 · The admin origin, its session and its edge gate
Status: 🟡 Proposed · Raised: 2026-09-28, from the Admin Panel MVP plan the owner approved that day · Amends: ADR-0011 (admin shares the monorepo, not the merchant console's app) and ADR-0028 (the admin address) · Depends on: ADR-0035 (the operator check behind every admin read and write) · Tracker: QRS-1416 (this decision) · QRS-1404 (programme) · QRS-1405 (sign-in design) · QRS-1419 (handbook) · QRS-1420 (admin outside the ledgers) · QRS-1421 (observability)
The decision, in one sentence
The platform admin is its own app,
apps/admin, on its own origin,admin.qrsetu.com, served by its own Cloudflare Worker behind Cloudflare Access, with a Supabase email and password session that never shares an origin with anything a merchant writes.
Context
The approved plan first placed admin at /admin on qrsetu.com, as ADR-0011 (line 38) and ADR-0028 (line 48) prescribe. The Principal Architect review of 2026-09-28 found that placement unsafe for four measured reasons:
| # | Finding | Evidence |
|---|---|---|
| 1 | qrsetu.com renders content merchants author (the public Setu Card), and the same origin hosts the merchant desktop console and the /app product | apps/web/src/app/routes.ts:28, :105 (the card and the catch-all), :83-95 (the merchant console); .github/workflows/deploy-web.yml:202-259 (/app) |
| 2 | The web session lives in localStorage on that origin, where every script the origin runs can read it | apps/web/src/app/supabase.browser.ts:40-79 |
| 3 | MFA for operators is declined, so one stolen staff session is read access to every tenant | proposal lines 446-455, QRS-899 |
| 4 | ADR-0028 rejected subdomains on session, CSP, zone and search grounds, and never weighed the script-injection blast radius | ADR-0028 lines 85-90 |
A fifth finding made the old gate unbuildable, not merely weak. ADR-0011 requires "two authorization checks in two separate loaders" (line 38), and a loader cannot see a session that lives in localStorage: apps/web has no @supabase/ssr and no cookie session (apps/web/package.json:25-33).
The owner chose admin.qrsetu.com on 2026-09-28, with the structure, topology, headers and edge gate below.
The browser reaches Supabase directly, on Supabase's own origin, so Access guards the panel and the handbook and never the data. The data boundary is the operator check (ADR-0035).
Decision
D1 · A new workspace, apps/admin, rendered in the browser
- A React app rendered in the browser, with no server-rendered data, served as Workers Static Assets. Every admin read and write goes through an operator-checked RPC or Edge Function called with the caller's own JWT (ADR-0035 D7), so nothing on the server needs the session. No
@supabase/ssr, no cookie session. - It is a workspace by the existing glob (
package.json:7-11,apps/*). It sharespackages/*andtooling/*, and never importsapps/web. The ESLint boundaries rule intooling/eslint-configenforces that, as it already enforces the package graph (ADR-0012 line 119). - Open inside this ADR: the DOM primitives.
apps/web/src/uiholds five (Button,Icon,Select,TextField,cn), and admin needs about ten more that the DOM layer does not have (Table, Dialog, Tabs, Menu, Checkbox, Badge, Toast, Command, Pagination, DatePicker). Recommended: extract the DOM primitive layer into one shared workspace that both apps consume, now, while it is five files. A separate admin set would be a third implementation of every primitive, where ADR-0011 accepts two, one per idiom. The trade:packages/*deliberately carry no DOM (ADR-0012 line 23), so the shared layer needs an ADR-0012 amendment saying where DOM-allowed shared code lives, and it moves a design-first systemic surface (ADR-0015). Until that amendment is accepted, this item stays undecided.
D2 · Six Workers, single-level hostnames
| Environment | apps/web (exists) | apps/admin (new) | Supabase project |
|---|---|---|---|
| Dev | qrsetu-web-dev → devv.qrsetu.com | qrsetu-admin-dev → admin-devv.qrsetu.com | Dev |
| UAT | qrsetu-web-uat → uatt.qrsetu.com | qrsetu-admin-uat → admin-uatt.qrsetu.com | Dev |
| Prod | qrsetu-web-prod → qrsetu.com | qrsetu-admin-prod → admin.qrsetu.com | Prod |
- The existing three are measured (
deploy-web.yml:107-122,apps/web/wrangler.jsonc:81-96), and UAT runs on the Dev project (deploy-web.yml:113-117).admin-devvandadmin-uattare the names this ADR proposes. - Single-level on purpose. Universal SSL covers the apex and first-level subdomains only, and a deeper name such as
admin.devv.qrsetu.com"will not serve a valid certificate" (Cloudflare, Universal SSL limitations). A Worker custom domain has Cloudflare issue a certificate itself (wrangler.jsonc:33-35), so a deeper name might work; that is unverified, and it is avoided rather than tested in production. - Custom domains, and whether they can be code: measured in increment 1, not assumed. For
apps/web, domains are bound by hand in the dashboard becausewrangler.jsonccannot express them: the Vite plugin dropsenv.*and deploys a flattened, generated config (wrangler.jsonc:11-21,:28-31), so the deploy passes--nameexplicitly (deploy-web.yml:267-271).- That reason belongs to the SSR build.
apps/adminrenders nothing on the server (D1), so it may not need@cloudflare/vite-pluginat all: a Vite client build deployed by plainwrangler deployas a static-assets Worker. - If so, its
env.*blocks androuteswithcustom_domain: truetake effect and the domains become code (expected, unverified). - Increment 1 measures it on Dev: deploy, then read the Worker's bound domains back.
- Until measured, the binding is a runbook step and a Change Record.
- That reason belongs to the SSR build.
- Cost: Workers bill per request, and a request that matches a static asset never invokes the Worker (
wrangler.jsonc:6-9). Expected to be negligible for internal traffic; not measured on the account.
D3 · Headers
| Header | Value | Why |
|---|---|---|
Content-Security-Policy | default-src 'self'; connect-src 'self' plus the environment's Supabase origin and the observability ingest origin; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'self' | no third-party script on the one origin whose session reads every tenant, and the admin can never be framed |
X-Robots-Tag | noindex, nofollow on every response | admin is never indexed (ADR-0011, amendment of 2026-08-12, item 5) |
Cache-Control | no-store on HTML; long-lived only for content-hashed assets | no shared cache ever holds an admin page |
- Any further source, for example the R2 media origin for a merchant's logo, is added as a named origin with the first screen that needs it, never as a wildcard.
- The handbook needs its own policy. VitePress inlines three scripts in every built page:
check-dark-mode,check-mac-os, and the page-hash map (window.__VP_HASH_MAP__), measured in the dev portal's local build of 2026-09-24 (the build output is gitignored,documentation/portal/.gitignore:3). A host-widescript-src 'self'blocks all three.- Recommended: a per-path policy for
/handbook/whosescript-srclists the SHA-256 hash of each inline script, computed by the handbook build, because the hash map changes with every build. - The alternative is a build step that moves the three scripts into files.
- Never
'unsafe-inline'. Inline styles, should the built Mermaid diagrams need them, are measured with the first handbook build (expected, unverified). - The panel at
/keeps the strict policy unchanged.
- Recommended: a per-path policy for
- The headers are proven on the served HTML, never read off config. A static asset is served without invoking the Worker (
wrangler.jsonc:6-9), so the headers come either from the asset layer or from routing HTML through the Worker. Which of the two works is measured with a probe against the deployed hostname. - Edge Function CORS stays
*(supabase/functions/_shared/cors.ts:7): the bearer token is the boundary, not the origin. Restricting the new operator functions to the admin origins is optional hardening.
D4 · Cloudflare Access is REQUIRED in front of admin.qrsetu.com
- One Access application covers the whole host, the panel at
/and the handbook at/handbook/, and admits identities on theqrsetu.comdomain. - Why required rather than optional: the handbook (D6) is static HTML served straight from the asset layer (
wrangler.jsonc:6-9), so the app's own sign-in cannot protect it, and anything compiled into the panel's JavaScript is fetchable by anyone who reaches the host. Access is also the compensating control, at the network edge, for the declined MFA (QRS-899): a leaked password alone no longer reaches the origin. - What Access does not do: it never sees the Supabase calls, which go to Supabase's own origin (the diagram above). Access admits a person to the host; an operator assignment admits them to data (ADR-0035).
- Cost: Cloudflare's own announcement states that the Zero Trust free plan covers up to 50 users; the current terms on this account are unverified.
- The login method at Access (a one-time PIN to the work mailbox, or an identity provider) is recorded nowhere in the repo and is decided in the setup runbook.
- Automated tests pass Access with a service token, never a person's identity. An Access service token (the
CF-Access-Client-IdandCF-Access-Client-Secretrequest headers) is admitted by a policy rule on the Dev and UAT applications only; Prod admits people only. The token is a secret held outside the repo, and its creation and rotation are runbook steps. The mechanism is Cloudflare's documented one; this account's setup is expected, unverified until increment 1 proves a Playwright run through it.
D5 · Staff sign in with email and password; the session belongs to the admin origin
- Supabase Auth, email and password, in the same project and the same
auth.usersas everyone else. The email provider is enabled on Dev (external.email = true, measured 2026-08-26, proposal line 299). The UI offers no sign-up, no phone sign-in and no self-serve reset (prototype/admin-panel/admin-shell.js:827,:842-844). A reset is a Super Admin action (ADR-0035 D6), although the design's copy also names the Platform Administrator, which its own role note contradicts (admin-shell.js:154). The sign-in screen's own states are still to be designed (QRS-1405). - Cost, confirmed by the owner: a staff sign-in sends nothing. Merchants and consumers sign in by WhatsApp OTP, a charged authentication template per sign-in, and staff sign in several times a day.
- Isolation is the decisive reason for the separate origin. supabase-js keeps the session in the origin's own storage (
supabase.browser.ts:40-79for the web app), and a browser never lets one origin read another's storage. A script injected anywhere onqrsetu.com, including through a merchant-authored card, therefore cannot read a staff session onadmin.qrsetu.com. On one shared origin it could. - Invite and reset are set-password links (owner decision Q7). Their redirect targets must be on the Supabase Auth redirect allowlist of both projects. The repo holds only the local stack's allowlist (
supabase/config.toml:49-50, localhost only, and the file governs the local stack,:3-4); the hosted allowlists are dashboard state, unverified.- The links must survive Cloudflare Access. They carry a token hash as query parameters and are redeemed with
verifyOtp, never a session in the URL fragment, which an Access login round trip may drop (expected, unverified; ADR-0035 D12). Spike (a) passes only if a fresh browser with no Access session completes the invite.
- The links must survive Cloudflare Access. They carry a token hash as query parameters and are redeemed with
- ADR-0018 is still unwritten (the ADR index's numbering note). This ADR decides the admin origin's session only and does not stand in for the platform auth architecture.
- The staff session time-box cannot be a Supabase Auth setting. Supabase's session time-box and inactivity timeout are project-wide settings (expected, unverified: dashboard state, plan-dependent), so a staff limit set there would also end every merchant's and consumer's session. The time-box is therefore enforced where only staff pass: the operator check refuses a session older than the staff limit (ADR-0035 D7). It is the same check that refuses a revoked session: both read the access token's session row through
is_session_live(), the definer that ADR-0036 D3 puts in every SQL helper, so a staff session that has ended for either reason is refused by every admin RPC at once. The admin app then shows the designed "session ended" state. A merchant or consumer session is unaffected by the staff limit. - The password policy is project-wide (ADR-0035 D13). The design currently states two contradictory rules, at least six characters at sign-in and at least ten at change (
prototype/admin-panel/admin-shell.js:872against:907-908), which the sign-in design round settles.
D6 · The Operations Handbook lives in apps/admin/handbook/
- VitePress, written by Claude, read-only for the operations team. It covers how each desk works and the product's end-to-end flows. It is its own package, outside the React app's dependency graph, as the dev portal is (
documentation/portal/package.jsonis a package of its own, and the root workspaces are onlyapps/*,packages/*andtooling/*:package.json:7-11). It builds into the admin Worker's static output under/handbook/, so there is one deploy. - One brand theme, shared. The dev portal's theme (
documentation/portal/.vitepress/theme/:index.ts,portal.css, the generatedbrand.generated.css,mermaidViewer.ts,sidebarSync.ts) and its Mermaid font rule (.vitepress/config.mjs:4-15,:60-66) move into one shared package, for exampletooling/vitepress-theme, which both sites use. ⚠ A package undertooling/is a root workspace by that same glob (package.json:10), so the extraction must show that Vue and VitePress stay out ofapps/admin's graph. - Stricter than the dev portal: dead links fail the build (the dev portal runs with
ignoreDeadLinks: true,config.mjs:34). Copy follows the product rules: no em or en dash, and no internal ids. - Sensitive runbooks stay in the dev portal: break-glass, staff-compromise response and first-operator seeding. The handbook links to none of them.
- Keeping it current: a proposed
check:handbookgate. Every desk and every ops-facing feature has a page; a change to a feature's route or Edge Function without a handbook edit fails unless the commit carries aHandbook-Impact:trailer; it prints a summary line on every run. Proposed here, not built (QRS-1419). - Not
documentation/portal/(a different audience, with architecture, security and credential content), and not a new folder underdocumentation/, where everything exceptportal/is the archive.
D7 · The admin origin's paths, and what qrsetu.com/admin does
- Inside
admin.qrsetu.com:/is the panel;/sign-inis the sign-in page (QRS-1405);/set-passwordis the single target of every invite and reset link, and the only admin path on the Auth redirect allowlists (D5);/handbook/is the handbook (D6);- desk paths are decided with the first desk. The earlier plan's
/admin/sign-inand/admin/set-passwordpredate the move to a subdomain and are withdrawn.
qrsetu.com/adminmust be declared, never left to the catch-all (QRS-1428).apps/web301s any undeclared first segment into a card address (apps/web/src/app/routes.ts:105, the:slugredirect module), so todayqrsetu.com/adminwould redirect to/admin/setu-cardeven thoughadminis a reserved slug.- Recommended: declare
/admininapps/webas a single 301 tohttps://admin.qrsetu.com/. That is a convenience for staff who type the old path, and it reveals nothing the host's DNS and certificate transparency records do not. - The alternative is a plain 404.
- Either way it is an ADR-0028 top-level route and is added with increment 1.
- Recommended: declare
Alternatives rejected
| Option | Why not |
|---|---|
/admin on qrsetu.com, ADR-0028's original row and ADR-0011's co-location | It shares an origin, and so its storage, with merchant-authored content, the merchant console and /app. With MFA declined, one script injection anywhere on that origin reaches every tenant. Its documented gate, a loader check, also needs a cookie session that does not exist |
| One Worker serving both hostnames | One artifact for two blast radii: every public-card deploy would also ship admin code, which ADR-0011's "separate deploy from public so the public bundle ships no admin code" forbids (lines 248-250). The admin's CSP would depend on a host check in code never regressing, where two Workers make two header sets structural |
Server rendering with a cookie session (@supabase/ssr, the plan's first web task) | Admin renders no server-side data, so SSR buys nothing, while a cookie session adds CSRF surface and a second session model. The operator check stays in the RPC or Edge Function either way. Retracted by the review |
| Cloudflare Access alone, with no app sign-in | Access never sees the Supabase calls (D4), so it cannot be the data boundary. Authority must come from the operator record |
Consequences
- The deploy pipeline is a dependency of increment 1 reaching Prod. The admin deploy job sits beside
deploy-web, which has never completed a green run: its only run to reach the deploy step (run34575509242, 2026-09-11) put the Worker live and then failed its smoke test on a Cloudflare 403 (QRS-1258, QRS-1260 still open). GitHub Actions has been billing-blocked since 2026-08-21 and was still blocked on 2026-09-24 (QRS-790). The manual path refuses to run after 2026-09-30 (apps/web/scripts/deploy-manual.mjs:61,:81-85). - Auth configuration is a production change class. The redirect allowlist entries for the admin hostnames, on both projects, each need a Change Record (R-06), as do the two Prod-side Cloudflare objects: the
qrsetu-admin-prodWorker with its custom domain, and the Access application. No tool measures any of these classes today (R-06). - Observability from day one:
@qrsetu/observabilityis a dependency ofapps/adminfrom its first commit, whereapps/webstill lacks it (apps/web/package.json:25-30, QRS-1421). - Ledgers: the screen ledger excludes the Admin Panel (
design-system/screen-conformance.json:31-32) andcheck:desktop-paritycovers merchant and marketplace only, so admin screens need a ledger of their own (QRS-1420). - Parity: admin is outside the Android, iOS and Web PWA triad by ADR-0011's own surface table (the admin row needs no native app, line 189). Each admin feature README records that as the sanctioned scope, not as an exception.
adminstays a reserved slug (supabase/migrations/20260808110000_v2_reserved_slugs.sql:263), so no vendor can ownqrsetu.com/admin, a lookalike address for phishing staff.apps/webserves no admin screen; its only admin route is the D7 redirect.- Amended with this ADR, both Proposed: ADR-0011 (amendment of 2026-09-28) and ADR-0028 (amendment A1).
Evidence
Measured for this ADR, 2026-09-28:
| Claim | Where |
|---|---|
| The card route, the catch-all and the merchant console live on the public origin | apps/web/src/app/routes.ts:28, :83-95, :105 |
The web session is stored in localStorage | apps/web/src/app/supabase.browser.ts:40-79 |
No @supabase/ssr, no @qrsetu/observability in apps/web | apps/web/package.json:25-33 |
Workers, not Pages; assets skip the Worker; env.* dropped; domains bound in the dashboard | apps/web/wrangler.jsonc:1-9, :6-9, :11-21, :28-31, :81-96 |
The three Workers, UAT on the Dev project, --name on deploy | .github/workflows/deploy-web.yml:107-122, :113-117, :267-271 |
| The one deploy run and its smoke-test failure; Actions billing block | tracker QRS-1258, QRS-1260, QRS-790 |
| The manual deploy's expiry | apps/web/scripts/deploy-manual.mjs:61, :81-85 |
Edge Function CORS is * | supabase/functions/_shared/cors.ts:7 |
| The repo's redirect allowlist is the local stack's | supabase/config.toml:3-4, :49-50 |
| The root workspaces; the portal is its own package | package.json:7-11; documentation/portal/package.json |
The portal theme files, the Mermaid font rule, ignoreDeadLinks | documentation/portal/.vitepress/theme/; .vitepress/config.mjs:4-15, :34, :60-66 |
| The five DOM primitives | apps/web/src/ui/ (Button.tsx, Icon.tsx, Select.tsx, TextField.tsx, cn.ts) |
| Admin is outside the screen ledger | documentation/portal/design-system/screen-conformance.json:31-32 |
admin is a reserved slug | supabase/migrations/20260808110000_v2_reserved_slugs.sql:263 |
| The design's sign-in copy, its two password rules, and its role note on who manages admins | prototype/admin-panel/admin-shell.js:154, :827, :842-844, :872, :907-908 (Claude Design project "QR setu prototype", pulled 2026-09-28; line numbers are that copy's) |
| VitePress inlines three scripts in every built page | the dev portal's local build of 2026-09-24 (documentation/portal/.vitepress/dist/index.html, gitignored by documentation/portal/.gitignore:3) |
| Universal SSL covers the apex and first-level subdomains only | Cloudflare, Universal SSL limitations, read 2026-09-28 |
Expected, unverified:
- The Zero Trust free plan's 50-user allowance on this account today (Cloudflare's announcement states it; the account's terms were not read).
- The Worker cost for internal traffic (not measured on the account).
- The hosted Auth redirect allowlists and the hosted JWT expiry on both projects (dashboard state).
- Whether HTML headers can be set from the asset layer or need the Worker in front (D3 measures it).
- Whether the built handbook needs inline styles as well as inline scripts (D3).
- That a Cloudflare Access login round trip drops a URL fragment (D5; spike a measures it).