Skip to content

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:

  1. No application subdomain (owner, 2026-08-18, after being asked to challenge it). Everything lives under qrsetu.com.
  2. The vendor slug namespace is FLAT and sits at the ROOT — /<slug>/setu-card. setu_cards.slug is 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.

pathaudienceserved by
/public — landingapps/web (SSR)
/<slug>/setu-card, /<slug>/order/…public — the vendor namespaceapps/web (SSR)
/marketplace/…public — discovery, indexableapps/web (SSR)
/legal/…, /og/…public — policy pages, share imagesapps/web
/app/…merchant + consumer productapps/mobile web export (RNW SPA)
/merchant, /consumervanity entries301 into /app/…
/admin/…platform adminapps/web (SSR, authenticated)
/org/…enterprise org adminapps/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 /app once the artifact is mounted. Until then it targets the in-page #create section — 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/client with apps/mobile/dist placed at /app (a correction, see A1). Two artifacts, one origin. Needs experiments.baseUrl set 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.txt must ALLOW crawling of /app, /admin, /org while those pages carry noindex. A Disallow prevents the crawler from ever seeing the noindex, 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 is admin.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.com renders merchant-authored Setu Cards and hosts the merchant console and /app, and the web session lives in localStorage on 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 on qrsetu.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.
  • /admin and /org are now on different origins, the strongest form of this ADR's own rule that they are different trust boundaries. /org is unchanged.
  • admin stays a reserved slug (supabase/migrations/20260808110000_v2_reserved_slugs.sql:263), so no vendor can own qrsetu.com/admin. apps/web serves no admin screen. Its one admin route is a declared redirect to admin.qrsetu.com, added as a top-level route under this ADR (QRS-1428, ADR-0034 D7): left undeclared, /admin would fall to the :slug catch-all and 301 to a card address.
  • The robots note in Consequences no longer concerns admin. admin.qrsetu.com sends noindex on 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.