Skip to content

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:

#FindingEvidence
1qrsetu.com renders content merchants author (the public Setu Card), and the same origin hosts the merchant desktop console and the /app productapps/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)
2The web session lives in localStorage on that origin, where every script the origin runs can read itapps/web/src/app/supabase.browser.ts:40-79
3MFA for operators is declined, so one stolen staff session is read access to every tenantproposal lines 446-455, QRS-899
4ADR-0028 rejected subdomains on session, CSP, zone and search grounds, and never weighed the script-injection blast radiusADR-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 shares packages/* and tooling/*, and never imports apps/web. The ESLint boundaries rule in tooling/eslint-config enforces that, as it already enforces the package graph (ADR-0012 line 119).
  • Open inside this ADR: the DOM primitives. apps/web/src/ui holds 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 ​

Environmentapps/web (exists)apps/admin (new)Supabase project
Devqrsetu-web-dev → devv.qrsetu.comqrsetu-admin-dev → admin-devv.qrsetu.comDev
UATqrsetu-web-uat → uatt.qrsetu.comqrsetu-admin-uat → admin-uatt.qrsetu.comDev
Prodqrsetu-web-prod → qrsetu.comqrsetu-admin-prod → admin.qrsetu.comProd
  • 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-devv and admin-uatt are 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 because wrangler.jsonc cannot express them: the Vite plugin drops env.* and deploys a flattened, generated config (wrangler.jsonc:11-21, :28-31), so the deploy passes --name explicitly (deploy-web.yml:267-271).
    • That reason belongs to the SSR build. apps/admin renders nothing on the server (D1), so it may not need @cloudflare/vite-plugin at all: a Vite client build deployed by plain wrangler deploy as a static-assets Worker.
    • If so, its env.* blocks and routes with custom_domain: true take 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.
  • 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 ​

HeaderValueWhy
Content-Security-Policydefault-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-Tagnoindex, nofollow on every responseadmin is never indexed (ADR-0011, amendment of 2026-08-12, item 5)
Cache-Controlno-store on HTML; long-lived only for content-hashed assetsno 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-wide script-src 'self' blocks all three.
    • Recommended: a per-path policy for /handbook/ whose script-src lists 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.
  • 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 the qrsetu.com domain.
  • 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-Id and CF-Access-Client-Secret request 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.users as 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-79 for the web app), and a browser never lets one origin read another's storage. A script injected anywhere on qrsetu.com, including through a merchant-authored card, therefore cannot read a staff session on admin.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.
  • 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:872 against :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.json is a package of its own, and the root workspaces are only apps/*, packages/* and tooling/*: 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 generated brand.generated.css, mermaidViewer.ts, sidebarSync.ts) and its Mermaid font rule (.vitepress/config.mjs:4-15, :60-66) move into one shared package, for example tooling/vitepress-theme, which both sites use. ⚠ A package under tooling/ is a root workspace by that same glob (package.json:10), so the extraction must show that Vue and VitePress stay out of apps/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:handbook gate. 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 a Handbook-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 under documentation/, where everything except portal/ is the archive.

D7 · The admin origin's paths, and what qrsetu.com/admin does ​

  • Inside admin.qrsetu.com:
    • / is the panel;
    • /sign-in is the sign-in page (QRS-1405);
    • /set-password is 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-in and /admin/set-password predate the move to a subdomain and are withdrawn.
  • qrsetu.com/admin must be declared, never left to the catch-all (QRS-1428). apps/web 301s any undeclared first segment into a card address (apps/web/src/app/routes.ts:105, the :slug redirect module), so today qrsetu.com/admin would redirect to /admin/setu-card even though admin is a reserved slug.
    • Recommended: declare /admin in apps/web as a single 301 to https://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.

Alternatives rejected ​

OptionWhy not
/admin on qrsetu.com, ADR-0028's original row and ADR-0011's co-locationIt 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 hostnamesOne 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-inAccess 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 (run 34575509242, 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-prod Worker with its custom domain, and the Access application. No tool measures any of these classes today (R-06).
  • Observability from day one: @qrsetu/observability is a dependency of apps/admin from its first commit, where apps/web still 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) and check:desktop-parity covers 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.
  • admin stays a reserved slug (supabase/migrations/20260808110000_v2_reserved_slugs.sql:263), so no vendor can own qrsetu.com/admin, a lookalike address for phishing staff. apps/web serves 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:

ClaimWhere
The card route, the catch-all and the merchant console live on the public originapps/web/src/app/routes.ts:28, :83-95, :105
The web session is stored in localStorageapps/web/src/app/supabase.browser.ts:40-79
No @supabase/ssr, no @qrsetu/observability in apps/webapps/web/package.json:25-33
Workers, not Pages; assets skip the Worker; env.* dropped; domains bound in the dashboardapps/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 blocktracker QRS-1258, QRS-1260, QRS-790
The manual deploy's expiryapps/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'ssupabase/config.toml:3-4, :49-50
The root workspaces; the portal is its own packagepackage.json:7-11; documentation/portal/package.json
The portal theme files, the Mermaid font rule, ignoreDeadLinksdocumentation/portal/.vitepress/theme/; .vitepress/config.mjs:4-15, :34, :60-66
The five DOM primitivesapps/web/src/ui/ (Button.tsx, Icon.tsx, Select.tsx, TextField.tsx, cn.ts)
Admin is outside the screen ledgerdocumentation/portal/design-system/screen-conformance.json:31-32
admin is a reserved slugsupabase/migrations/20260808110000_v2_reserved_slugs.sql:263
The design's sign-in copy, its two password rules, and its role note on who manages adminsprototype/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 pagethe 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 onlyCloudflare, 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).