Appearance
ADR-0028 — URL namespace and audience routing on one origin
- Status: 🟡 Proposed (approved-pending — owner asked for the recommendation and to adopt it, 2026-08-19)
- Date: 2026-08-19
- Supersedes: nothing. Constrains: ADR-0011 (surface-matched frontend), ADR-0019 (one card renderer)
- Tracker: QRS-763
🟡 Proposed amendment A1 · 2026-09-28 · platform admin moves to admin.qrsetu.com
The /admin/… row of the Decision table is superseded by the owner's choice of 2026-09-28: the platform admin is its own app on its own origin (ADR-0034). Every other audience stays on the one origin. Read A1 at the end of this page.
Context
qrsetu.com must serve five audiences that CLAUDE.md's three-user-categories principle says are genuinely different products, not variations of one: the public (landing, Setu Cards, marketplace), the merchant, the consumer, platform admin (QRSETU staff), and enterprise org admin (a customer's employee, powerful inside one organisation and powerless outside it — ADR-0006/0022).
Two prior decisions fix the shape of the problem and neither is up for revision here:
- No application subdomain (owner, 2026-08-18, after being asked to challenge it). Everything lives under
qrsetu.com. - The vendor slug namespace is FLAT and sits at the ROOT —
/<slug>/setu-card.setu_cards.slugis write-once at the database layer and a printed QR is on paper, so this is the least revisable URL in the product.
Those two together are the whole constraint: every first path segment we spend on an audience is a segment permanently removed from the vendor namespace, and it can only be spent safely if the slug can never be claimed. The trigger for deciding now is small and concrete — the landing hero's primary CTA has nowhere to point.
Decision
One origin, a small closed set of RESERVED first segments for audiences, and the vendor namespace keeping the rest of the root. Audience areas are composed at deploy time from the two build artifacts ADR-0011 already defines, not from a third.
| path | audience | served by |
|---|---|---|
/ | public — landing | apps/web (SSR) |
/<slug>/setu-card, /<slug>/order/… | public — the vendor namespace | apps/web (SSR) |
/marketplace/… | public — discovery, indexable | apps/web (SSR) |
/legal/…, /og/… | public — policy pages, share images | apps/web |
/app/… | merchant + consumer product | apps/mobile web export (RNW SPA) |
/merchant, /consumer | vanity entries | 301 into /app/… |
/admin/… | platform admin | apps/web (SSR, authenticated) |
/org/… | enterprise org admin | apps/web (SSR, authenticated) |
Every one of those first segments is already reserved. Verified against Dev on 2026-08-19: app, merchant, consumer, admin, org, enterprise, signup, login, api, legal, og, dashboard, partner, business, account all exist in reserved_slugs. The slug-governance pass (QRS-752/753/754, 2,572 rows) made this decision enforceable before it was taken, which is why it costs nothing to adopt.
Why the product app mounts ONCE at /app, and not at /merchant + /consumer
The merchant and consumer experiences are one universal Expo codebase (ADR-0011), with the split expressed as route groups inside it (src/app/(user)/… and src/app/consumer/…). Mounting that artifact twice under two base paths would ship the same bundle twice and give two service-worker scopes for one app. So the artifact mounts once, at /app, via Expo's experiments.baseUrl — verified to exist in this SDK: @expo/cli's exportApp.js reads getBaseUrlFromExpoConfig(exp) and logs Using (experimental) base path.
/merchant and /consumer remain reserved and become 301s into the app's own entry routes. That keeps the audience legible in a URL a human types or says aloud, without paying for it in bundles — and it leaves the door open: if the consumer tier ever justifies its own artifact, /consumer becomes a real mount and no other URL changes.
Why admin and org admin are apps/web, not the Expo app
ADR-0011 already puts admin on the DOM stack, and the reason generalises to org admin: both are desktop-first dense authoring surfaces (tables, filters, bulk actions, a feature-grant matrix), which is what shadcn/DOM is good at and what a mobile-first RN component set is not. Org admin is a distinct surface ADR-0011 does not yet contain — this ADR is where it gets its address.
⚠ /admin and /org are different trust boundaries and must never share a route group. Platform admin is QRSETU staff over all tenants (is_admin()); org admin is a customer's employee scoped to one organisation subtree. CLAUDE.md names conflating them as privilege escalation, so they are siblings with separate layouts and separate guards, never /admin/org/….
Options rejected
Subdomains (app.qrsetu.com, admin.qrsetu.com). The conventional answer, and the owner ruled it out; on examination that is also the better call for us. One origin means one cookie and session domain (no cross-subdomain auth handshake, which is where OAuth redirect bugs live — and this project has had four auth incidents already, QRS-261/273/276/285), one CSP, one Cloudflare zone configuration, and search authority consolidated on one host. The cost is real and is the reason this ADR exists: the root namespace is shared with vendor slugs forever, so audience roots must be reserved up front. They are.
A single /app umbrella with no vanity roots. Cheaper, and it loses the thing the owner actually asked for — legible separation. /merchant is a URL a vendor can be told over a phone; /app/(user)/dashboard is not.
Re-implementing merchant onboarding in the DOM so the CTA lands on a fast SSR page. Tempting because a signup page is the one place SSR would help conversion. Rejected: merchant onboarding already exists and works in the Expo app, and a second implementation is the duplicate-source-of-truth class that produced QRS-249. One onboarding, reached through /app.
Locale prefixes (/en/…, /hi/…). Out of scope by the English-only decision (2026-08-18), and actively harmful to add speculatively: a locale segment that exists but serves one language is the hreflang-pointing-at-nothing problem with a URL attached.
Consequences
- The landing CTA points at
/apponce the artifact is mounted. Until then it targets the in-page#createsection — a real anchor rather than a 404 — through a single named constant, so the switch is one line. This is the only part of this ADR not yet executed. - Deployment becomes a compose step, and that is new work: one Cloudflare Worker per environment, on Workers Static Assets, whose asset directory is
apps/web/build/clientwithapps/mobile/distplaced at/app(a correction, see A1). Two artifacts, one origin. Needsexperiments.baseUrlset for the web export only — it must NOT affect the native builds, so it is set in the export path rather than globally, and that has to be verified on a native build before it ships. robots.txtmust ALLOW crawling of/app,/admin,/orgwhile those pages carrynoindex. ADisallowprevents the crawler from ever seeing thenoindex, which is the mistake that leaves an app shell in the index permanently — the same reasoning already applied to/marketplace/search./app/**is a client-rendered SPA and gets no SEO benefit, by design. Nothing behind login needs it. The SEO surfaces stay on the SSR stack, which is exactly ADR-0011's split.- Adding a sixth audience later costs one reserved segment and a mount, not a URL migration. That is the property this ADR is buying.
What is NOT decided here
Authentication topology across the two artifacts (one Supabase session on one origin makes this tractable, but the handoff from a DOM login to the SPA is unspecified), the org-admin information architecture (needs a design pull — it has no design, per CLAUDE.md's missing-screen process), and whether /marketplace is SSR per city/category or a client-side search over one SSR shell.
A1 · Proposed amendment, 2026-09-28 · platform admin moves to admin.qrsetu.com
Status: 🟡 Proposed, with ADR-0034 · Raised by: the owner's decision of 2026-09-28 on the Admin Panel MVP review · Tracker: QRS-1416 · QRS-1428 (the /admin redirect)
What changes
- The
/admin/…row of the Decision table is superseded. The platform admin surface isadmin.qrsetu.com: its own Worker, built from its own workspace, behind Cloudflare Access (ADR-0034). - The reason is the one this ADR never weighed: the blast radius of an injected script.
qrsetu.comrenders merchant-authored Setu Cards and hosts the merchant console and/app, and the web session lives inlocalStorageon that origin (apps/web/src/app/supabase.browser.ts:40-79). With operator MFA declined (QRS-899), one script injected anywhere on the origin could read a staff session, and a staff session reads every tenant. On a separate origin the browser's same-origin rule keeps that session out of reach of anything running onqrsetu.com. - The owner's "no application subdomain" (Context, item 1) is amended for platform admin only. Every other audience stays on the one origin, and the subdomain rejection under Options rejected keeps its force for them: one session domain, one zone, consolidated search authority. For admin, its "one CSP" reason inverts, because the admin needs a policy the public card cannot carry.
/adminand/orgare now on different origins, the strongest form of this ADR's own rule that they are different trust boundaries./orgis unchanged.adminstays a reserved slug (supabase/migrations/20260808110000_v2_reserved_slugs.sql:263), so no vendor can ownqrsetu.com/admin.apps/webserves no admin screen. Its one admin route is a declared redirect toadmin.qrsetu.com, added as a top-level route under this ADR (QRS-1428, ADR-0034 D7): left undeclared,/adminwould fall to the:slugcatch-all and 301 to a card address.- The robots note in Consequences no longer concerns admin.
admin.qrsetu.comsendsnoindexon every response and sits behind Access (ADR-0034 D3 and D4).
A correction recorded here
Consequences said the compose step would deploy "one Cloudflare Pages project" until 2026-09-28. apps/web deploys as a Cloudflare Worker on Workers Static Assets, a deliberate correction the repo made after this ADR was written (apps/web/wrangler.jsonc:1-9; .github/workflows/deploy-web.yml:37-42). The RNW export is mounted into the Worker's asset directory (apps/web/scripts/mount-app.mjs:2-6) and published by wrangler deploy with an explicit --name (deploy-web.yml:258-275). The sentence now says so.