fix(frontend): stop SPA fallthrough on /privacy, /cookies, /opt-out #12

Open
opened 2026-09-05 15:53:55 +00:00 by PlasticDigits · 3 comments

Summary

Stop first-party SPAs from treating guessed legal URLs as product routes. Today /privacy, /cookies, /opt-out, and similar paths are rewritten to index.html (HTTP 200) and then painted as the marketing site, CL8Y DEX Swap, or CL8Y Bridge transfer shell. Crawlers and people who type those paths see product chrome and homepage Open Graph tags, not a missing page and not a Privacy Notice.

This issue is the shared spec. The implementer worktree for this repo is code/CL8Y-web (https://cl8y.com). Matching changes are required in code/cl8y-dex-terraclassic and code/cl8y-bridge-monorepo using the same reserved-path table; do not edit those trees from a CL8Y-web worktree.

Bundle (do not split):

  1. Reserved legal-guess paths — a finite, tested list (/privacy, /cookies, /opt-out, and documented aliases).
  2. Host layer before SPA fallback — those paths must 404 or 301/302 to the canonical Privacy Notice. They must not rewrite to index.html.
  3. Client layer — if the shell still loads (dev server, missed host rule), reserved paths must not render product chrome and must not Navigate to /.
  4. Sibling DEX + Bridge catch-alls — path="*" → / currently turns /privacy into Swap / Transfer.

Until a dedicated Privacy Notice exists as a first-party HTTPS document, 404 is the correct behavior. Do not invent notice copy in the marketing, DEX, or bridge shells. Do not treat T&Cs as the Privacy Notice.

Current codebase

Marketing (code/CL8Y-web) — SPA fallback is explicit

src/app/index.tsx is a BrowserRouter with only:

  • / homepage
  • legacy section redirects (/engine, /security, /tokenomics, /community, /institutional)
  • /blog, /blog/:slug
  • /cl8y_whitepaper

There is no /privacy (or cookies / opt-out) route and no catch-all 404 route. Unknown paths still mount SiteHeader + SiteFooter. DefaultHead applies marketing SEO (siteCopy.seo.title / homepage description and /opengraph.png) to every non-blog pathname, so /privacy is advertised as the product site.

Host fallback (order is the bug):

# public/_redirects
/*    /index.html   200

render.yaml repeats source: /* → /index.html. PROJECT_GUIDE.md §13 tells operators to keep SPA fallbacks. Blog prerender rules exist so /blog/* wins over that fallback; legal-guess paths do not.

Footer (SiteFooter.tsx + src/data/copy.ts) has disclaimer and law-enforcement copy. It does not link a Privacy Notice. Canonical product URLs in src/content/invariants.ts are only Bridge and DEX origins.

DEX (code/cl8y-dex-terraclassic) — unknown paths become Swap

docker/frontend/nginx.conf:

location / {
    try_files $uri $uri/ /index.html;
}

Hashed JS/CSS already 404 via @hashed_asset_miss (#706). HTML routes do not.

frontend-dapp/src/App.tsx ends with:

<Route path="*" element={<Navigate to="/" replace />} />

/ is Swap. A visitor (or crawler) on /privacy gets HTTP 200 + client replace to Swap. LegalFooterNotice.tsx links security / incidents / LP how-to / report — not privacy, cookies, or opt-out.

/swap alias preservation (SwapAliasRedirect.tsx, #711) must stay. Do not reserve /, /swap, /pool, /protocol, /portfolio, /trade, or other real dApp paths.

Bridge (code/cl8y-bridge-monorepo) — unknown paths become Transfer

packages/frontend/src/main.tsx is the same pattern:

  • Product routes under Layout (/, /transfer/:xchainHashId, /history, /verify, /settings)
  • <Route path="*" element={<Navigate to="/" replace />} />
  • When VITE_UNDER_CONSTRUCTION is true, path="*" renders UnderConstructionPage for every URL, including legal guesses

There is no in-tree nginx deny list for these paths. Client catch-all is enough to impersonate the bridge as /privacy.

web/src/main.ts switches /sign/evm|solana|terra-classic|telegram and defaults every other path to renderHome (T&Cs acceptance portal). If the canonical Privacy Notice is later hosted on that origin, /privacy must not keep rendering T&Cs home. Authoring the notice itself is out of scope here.

No existing issue in CL8Y-web, DEX, bridge, or legal tracks this fallthrough (searched privacy / cookies / opt-out / SPA / fallthrough). Not a duplicate of DEX #1202 (product events) or legal CORS #7.

Why this is needed

  1. Legal-guess URLs are a real user and crawler habit. People and scanners try /privacy, /cookies, and /opt-out on the apex and on product hosts. Serving the trading or bridge UI at those URLs looks like the product is the privacy policy.
  2. HTTP 200 + product OG is worse than 404. DefaultHead and DEX/bridge shells advertise the dApp. A 404 (or a real notice) is honest; a Swap page at /privacy is not.
  3. Client Navigate to / hides the miss. DEX and bridge catch-alls make /privacy indistinguishable from a successful product landing. Marketing at least leaves a blank main; DEX/bridge actively replace the URL.
  4. One path table. Splitting marketing vs DEX vs bridge produces drift (one host 404s, another still swaps). Bundle the list.

This is routing / static-host config. No wallet, no indexer, no contracts, no T&Cs rewrite.

Constraints and guardrails

  1. Finite reserved list (exact path, optional trailing slash, ASCII case-insensitive). Do not use a regex that can swallow real product routes (/protocol, /portfolio, /pool, /security legacy redirect, /blog/*).
  2. Default outcome is 404 until a canonical first-party Privacy Notice URL exists. Redirect is allowed only to that compile-time HTTPS URL (see direction below). Never 200 the product shell for a reserved path.
  3. Do not author a Privacy Notice, cookie policy, or opt-out form in CL8Y-web, DEX, or bridge. Do not paste T&Cs into /privacy.
  4. No open redirects. If redirecting, Location is a URL built from a hardcoded first-party origin + path. Never read the target from query, hash, Referer, or window.location. Reject javascript:, data:, //, and non-HTTPS.
  5. Host rule wins over SPA fallback. Reserved locations must be declared above /* → index.html / try_files … /index.html. Blog prerender rewrites on CL8Y-web must keep working.
  6. Client defense in depth. Reserved paths must not hit path="*" → Navigate to="/". Marketing must not wrap them in SiteHeader/SiteFooter + DefaultHead product SEO.
  7. Worktree isolation. CL8Y-web PRs cannot patch DEX/bridge. Copy the same path table into those repos in separate PRs. Do not submodule or fetch sibling working trees.
  8. Do not change clickjacking headers (#5 / #3 host headers), token directory, Swap query params (#711/#713), or TermsGate.
  9. Hashed assets stay 404. DEX @hashed_asset_miss must remain; reserved HTML paths are a new exact-location block, not a change to JS/CSS caching.
  10. No VITE_PUBLIC_ORIGIN / env-built redirect for the notice URL unless it is already the established first-party legal origin pattern; prefer a constant next to CANONICAL_PRODUCT_URLS.

Relevant files

Path Role
public/_redirects SPA /* → index.html 200 — reserved paths must precede this
render.yaml Same /* rewrite for the static host
src/app/index.tsx Router; add reserved handling; do not leave chrome-only unknown paths
src/app/DefaultHead.tsx Must not emit product OG for reserved paths
src/app/LegacyRedirect.tsx Legacy sections only — do not reuse for legal guesses
src/content/invariants.ts Canonical URL constants; optional notice URL later
src/content/invariants.test.ts Table-driven reserved-path tests
PROJECT_GUIDE.md Today documents SPA fallback as required; amend
e2e/*.spec.ts + playwright.config.ts Workers already 5; add reserved-path e2e
Sibling: code/cl8y-dex-terraclassic docker/frontend/nginx.conf Exact location = before location /
Sibling: code/cl8y-dex-terraclassic frontend-dapp/src/App.tsx Stop * → / for reserved paths
Sibling: code/cl8y-bridge-monorepo packages/frontend/src/main.tsx Same catch-all
Sibling (later): code/cl8y-ecosystem-legal web/src/main.ts Default-to-home must not swallow /privacy if the notice lives there
  1. Shared path table (pure helper + unit tests), e.g. src/lib/reservedLegalGuessPaths.ts:

    Exact (with/without trailing slash; match case-insensitively):

    • /privacy, /privacy-policy, /privacy_policy, /privacypolicy, /privacy-notice
    • /cookies, /cookie, /cookie-policy, /cookies-policy
    • /opt-out, /optout, /opt_out
    • /do-not-sell, /donotsell, /ccpa

    Match pathname only (strip query/hash). Do not treat /privacy-xyz or /blog/privacy as reserved unless the path is exactly a list entry.

  2. CL8Y-web host: In _redirects and render.yaml, put reserved rules before /* → /index.html. Prefer 404 (or 301 to the canonical notice once that URL is a real first-party document). Keep /blog and /blog/* prerender rewrites first.

  3. CL8Y-web client: If a reserved path still reaches React (Vite dev), render a minimal non-product 404 (no homepage hero, no DefaultHead product title) or window.location.replace only to the canonical notice URL constant. Do not Navigate to /.

  4. DEX nginx: location = /privacy { return 404; } (and the rest of the table, including /privacy/) before location /. Keep try_files for real dApp routes. In App.tsx, check reserved paths before the * → / route (render nothing / a 404 view, or rely on the host 404 so the JS never runs).

  5. Bridge: Same client exception to path="*". If the frontend image has a static server config in-tree, add the same exact locations; if not, client-side is still mandatory.

  6. Redirect mode (optional, only when the notice exists): Single hardcoded https://… Privacy Notice URL. 301/302. No query forwarding except maybe utm_ strip. Cookies and opt-out guesses may 301 to the same notice (or 404) — pick one and test it; do not invent extra pages.

Acceptance criteria

  • AC1. GET /privacy, /cookies, and /opt-out (and trailing-slash variants) on the marketing static host do not return index.html as 200 with product chrome. They return 404 or 301/302 to the canonical Privacy Notice.
  • AC2. Same reserved list is applied in DEX nginx and DEX App.tsx so those paths never Navigate to Swap.
  • AC3. Same reserved list is applied in bridge main.tsx so those paths never Navigate to Transfer (including under-construction path="*").
  • AC4. Real product routes stay SPA: /, /blog, /blog/:slug, DEX /, /swap, /pool, /protocol, /trade, bridge /, /history, /settings, /verify.
  • AC5. /privacy?next=https://evil.example does not redirect off-origin. Query is ignored for routing.
  • AC6. DefaultHead / product OG is not emitted for reserved paths if the client renders at all.
  • AC7. Path table is unit-tested (allow + deny). Docs (PROJECT_GUIDE.md and/or a short skill note) say SPA fallback must not cover reserved legal-guess paths.
  • AC8. No Privacy Notice prose added to this repo. No T&Cs substitution.

Test plan (functional paths)

# Path Expect
T1 /privacy 404 or 301/302 to canonical notice; body is not homepage/Swap/Transfer
T2 /privacy/ Same as T1
T3 /PRIVACY Same as T1 (case-insensitive)
T4 /cookies, /cookie-policy Same as T1
T5 /opt-out, /optout, /do-not-sell Same as T1
T6 / Unchanged marketing homepage 200
T7 /blog and a prerendered /blog/:slug Still 200 prerendered HTML, not reserved-path 404
T8 /security Existing legacy redirect to /#trust unchanged
T9 /cl8y_whitepaper Existing PDF redirect unchanged
T10 Vite yarn preview /privacy Client does not paint hero/CTAs; no product OG title
T11 DEX /privacy (preview or nginx fixture) Not Swap; URL must not be replaced to /
T12 DEX / and /swap?from= Unchanged
T13 Bridge /privacy Not Transfer; URL must not be replaced to /
T14 Bridge /history, /settings, /verify Unchanged
T15 /privacy-policy vs /blog/privacy First reserved; blog slug not reserved
T16 /privacy?foo=bar Reserved; query ignored

Playwright: yarn test:e2e already uses --workers=5. Add a dedicated spec that request.gets reserved paths (status + content-type / body must not be the homepage) and that product routes still 200. Unit tests for the matcher in src/content/invariants.test.ts or a new src/lib/reservedLegalGuessPaths.test.ts.

Test plan (attack / hack / abuse)

# Vector Expect
A1 Open redirect /privacy?redirect=https://phish No off-origin Location
A2 javascript: / data: / //evil as redirect target Impossible; target is a constant
A3 Path traversal /privacy/../ or /privacy/%2e%2e/ Must not skip the deny and serve SPA as “privacy”
A4 Prefix /privacy-not-a-policy Not reserved; existing unknown-path behavior (marketing chrome or DEX * — do not expand this issue to all 404s)
A5 DEX reserved path vs /protocol /protocol still the protocol page
A6 Encoded / %70rivacy vs /privacy Document: decode then match, or only exact decoded pathname; no double-decode surprises
A7 Reserved path that still gets index.html (host mis-order) Client must not Navigate to / and must not set product OG
A8 Crawler Accept HTML on /privacy Must not receive homepage og:title
A9 POST / PUT to /privacy 404/405; do not create an API
A10 Host rule after /* fallback Test file order so reserved wins
A11 Bridge under-construction path="*" Reserved paths still not presented as the product
A12 Header injection in Location Use URL / nginx return 301 https://…; no string concat of user input

Verification criteria

  1. CL8Y-web: yarn test, yarn typecheck, yarn test:e2e (5 workers) green. New matcher tests cover every reserved alias. yarn preview + curl -sI http://127.0.0.1:5173/privacy is 404 or 301, not 200 homepage.
  2. After static build: _redirects / render.yaml order verified by reading the built dist/_redirects (or equivalent) so reserved rules precede /*.
  3. DEX sibling PR: nginx configtest + a fixture request for /privacy is 404/301; / is still Swap; hashed .js miss still 404 (not HTML). RTL or router test: reserved path does not render SwapPage.
  4. Bridge sibling PR: router test: /privacy does not render TransferPage; / still does.
  5. Manual: open /privacy, /cookies, /opt-out on marketing, DEX, and bridge preview builds; confirm no product shell. Open /, /blog, DEX /pool, bridge /history and confirm no regression.

Out of scope

  • Writing or publishing a Privacy Notice (separate legal-doc work in code/cl8y-ecosystem-legal).
  • Footer links to that notice (do that when the document exists).
  • Cookie banners, CMP SDKs, or an interactive opt-out form.
  • Changing T&Cs / TermsGate / clickwrap.
  • Turning the whole SPA into real per-route HTTP 404s for arbitrary unknown URLs (only the reserved legal-guess list).
  • Vote / other product hosts unless they copy this table later.
  • Wallet, indexer, or contract changes.
## Summary Stop first-party SPAs from treating guessed legal URLs as product routes. Today `/privacy`, `/cookies`, `/opt-out`, and similar paths are rewritten to `index.html` (HTTP 200) and then painted as the marketing site, CL8Y DEX Swap, or CL8Y Bridge transfer shell. Crawlers and people who type those paths see product chrome and homepage Open Graph tags, not a missing page and not a Privacy Notice. This issue is the shared spec. The implementer worktree for **this** repo is `code/CL8Y-web` (https://cl8y.com). Matching changes are required in `code/cl8y-dex-terraclassic` and `code/cl8y-bridge-monorepo` using the same reserved-path table; do not edit those trees from a CL8Y-web worktree. Bundle (do not split): 1. **Reserved legal-guess paths** — a finite, tested list (`/privacy`, `/cookies`, `/opt-out`, and documented aliases). 2. **Host layer before SPA fallback** — those paths must 404 **or** 301/302 to the canonical Privacy Notice. They must not rewrite to `index.html`. 3. **Client layer** — if the shell still loads (dev server, missed host rule), reserved paths must not render product chrome and must not `Navigate` to `/`. 4. **Sibling DEX + Bridge catch-alls** — `path="*"` → `/` currently turns `/privacy` into Swap / Transfer. Until a dedicated Privacy Notice exists as a first-party HTTPS document, **404 is the correct behavior**. Do not invent notice copy in the marketing, DEX, or bridge shells. Do not treat T&Cs as the Privacy Notice. ## Current codebase ### Marketing (`code/CL8Y-web`) — SPA fallback is explicit [`src/app/index.tsx`](src/app/index.tsx) is a `BrowserRouter` with only: - `/` homepage - legacy section redirects (`/engine`, `/security`, `/tokenomics`, `/community`, `/institutional`) - `/blog`, `/blog/:slug` - `/cl8y_whitepaper` There is **no** `/privacy` (or cookies / opt-out) route and **no** catch-all 404 route. Unknown paths still mount [`SiteHeader`](src/components/chrome/SiteHeader.tsx) + [`SiteFooter`](src/components/chrome/SiteFooter.tsx). [`DefaultHead`](src/app/DefaultHead.tsx) applies marketing SEO (`siteCopy.seo.title` / homepage description and `/opengraph.png`) to every non-blog pathname, so `/privacy` is advertised as the product site. Host fallback (order is the bug): ``` # public/_redirects /* /index.html 200 ``` [`render.yaml`](render.yaml) repeats `source: /*` → `/index.html`. [`PROJECT_GUIDE.md`](PROJECT_GUIDE.md) §13 tells operators to keep SPA fallbacks. Blog prerender rules exist so `/blog/*` wins over that fallback; legal-guess paths do not. Footer ([`SiteFooter.tsx`](src/components/chrome/SiteFooter.tsx) + [`src/data/copy.ts`](src/data/copy.ts)) has disclaimer and law-enforcement copy. It does **not** link a Privacy Notice. Canonical product URLs in [`src/content/invariants.ts`](src/content/invariants.ts) are only Bridge and DEX origins. ### DEX (`code/cl8y-dex-terraclassic`) — unknown paths become Swap [`docker/frontend/nginx.conf`](https://git.cl8y.com/code/cl8y-dex-terraclassic/src/branch/main/docker/frontend/nginx.conf): ``` location / { try_files $uri $uri/ /index.html; } ``` Hashed JS/CSS already 404 via `@hashed_asset_miss` ([#706](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/706)). HTML routes do not. [`frontend-dapp/src/App.tsx`](https://git.cl8y.com/code/cl8y-dex-terraclassic/src/branch/main/frontend-dapp/src/App.tsx) ends with: ```tsx <Route path="*" element={<Navigate to="/" replace />} /> ``` `/` is Swap. A visitor (or crawler) on `/privacy` gets HTTP 200 + client replace to Swap. [`LegalFooterNotice.tsx`](https://git.cl8y.com/code/cl8y-dex-terraclassic/src/branch/main/frontend-dapp/src/components/legal/LegalFooterNotice.tsx) links security / incidents / LP how-to / report — not privacy, cookies, or opt-out. `/swap` alias preservation ([`SwapAliasRedirect.tsx`](https://git.cl8y.com/code/cl8y-dex-terraclassic/src/branch/main/frontend-dapp/src/components/common/SwapAliasRedirect.tsx), #711) must stay. Do not reserve `/`, `/swap`, `/pool`, `/protocol`, `/portfolio`, `/trade`, or other real dApp paths. ### Bridge (`code/cl8y-bridge-monorepo`) — unknown paths become Transfer [`packages/frontend/src/main.tsx`](https://git.cl8y.com/code/cl8y-bridge-monorepo/src/branch/main/packages/frontend/src/main.tsx) is the same pattern: - Product routes under [`Layout`](https://git.cl8y.com/code/cl8y-bridge-monorepo/src/branch/main/packages/frontend/src/components/Layout.tsx) (`/`, `/transfer/:xchainHashId`, `/history`, `/verify`, `/settings`) - `<Route path="*" element={<Navigate to="/" replace />} />` - When `VITE_UNDER_CONSTRUCTION` is true, `path="*"` renders `UnderConstructionPage` for **every** URL, including legal guesses There is no in-tree nginx deny list for these paths. Client catch-all is enough to impersonate the bridge as `/privacy`. ### Legal portal (`code/cl8y-ecosystem-legal`) — related, not this worktree [`web/src/main.ts`](https://git.cl8y.com/code/cl8y-ecosystem-legal/src/branch/main/web/src/main.ts) switches `/sign/evm|solana|terra-classic|telegram` and **defaults every other path to `renderHome`** (T&Cs acceptance portal). If the canonical Privacy Notice is later hosted on that origin, `/privacy` must not keep rendering T&Cs home. Authoring the notice itself is **out of scope** here. No existing issue in CL8Y-web, DEX, bridge, or legal tracks this fallthrough (searched privacy / cookies / opt-out / SPA / fallthrough). Not a duplicate of DEX [#1202](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1202) (product events) or legal CORS [#7](https://git.cl8y.com/code/cl8y-ecosystem-legal/issues/7). ## Why this is needed 1. **Legal-guess URLs are a real user and crawler habit.** People and scanners try `/privacy`, `/cookies`, and `/opt-out` on the apex and on product hosts. Serving the trading or bridge UI at those URLs looks like the product *is* the privacy policy. 2. **HTTP 200 + product OG is worse than 404.** [`DefaultHead`](src/app/DefaultHead.tsx) and DEX/bridge shells advertise the dApp. A 404 (or a real notice) is honest; a Swap page at `/privacy` is not. 3. **Client `Navigate` to `/` hides the miss.** DEX and bridge catch-alls make `/privacy` indistinguishable from a successful product landing. Marketing at least leaves a blank main; DEX/bridge actively replace the URL. 4. **One path table.** Splitting marketing vs DEX vs bridge produces drift (one host 404s, another still swaps). Bundle the list. This is **routing / static-host config**. No wallet, no indexer, no contracts, no T&Cs rewrite. ## Constraints and guardrails 1. **Finite reserved list** (exact path, optional trailing slash, ASCII case-insensitive). Do not use a regex that can swallow real product routes (`/protocol`, `/portfolio`, `/pool`, `/security` legacy redirect, `/blog/*`). 2. **Default outcome is 404** until a canonical first-party Privacy Notice URL exists. Redirect is allowed only to that compile-time HTTPS URL (see direction below). Never 200 the product shell for a reserved path. 3. **Do not author a Privacy Notice, cookie policy, or opt-out form** in CL8Y-web, DEX, or bridge. Do not paste T&Cs into `/privacy`. 4. **No open redirects.** If redirecting, `Location` is a `URL` built from a hardcoded first-party origin + path. Never read the target from query, hash, `Referer`, or `window.location`. Reject `javascript:`, `data:`, `//`, and non-HTTPS. 5. **Host rule wins over SPA fallback.** Reserved locations must be declared **above** `/* → index.html` / `try_files … /index.html`. Blog prerender rewrites on CL8Y-web must keep working. 6. **Client defense in depth.** Reserved paths must not hit `path="*" → Navigate to="/"`. Marketing must not wrap them in SiteHeader/SiteFooter + DefaultHead product SEO. 7. **Worktree isolation.** CL8Y-web PRs cannot patch DEX/bridge. Copy the same path table into those repos in separate PRs. Do not submodule or fetch sibling working trees. 8. **Do not change** clickjacking headers ([#5](https://git.cl8y.com/code/CL8Y-web/issues/5) / `#3` host headers), token directory, Swap query params (#711/#713), or TermsGate. 9. **Hashed assets stay 404.** DEX `@hashed_asset_miss` must remain; reserved HTML paths are a new exact-location block, not a change to JS/CSS caching. 10. **No `VITE_PUBLIC_ORIGIN` / env-built redirect** for the notice URL unless it is already the established first-party legal origin pattern; prefer a constant next to `CANONICAL_PRODUCT_URLS`. ## Relevant files | Path | Role | |------|------| | `public/_redirects` | SPA `/* → index.html 200` — reserved paths must precede this | | `render.yaml` | Same `/*` rewrite for the static host | | `src/app/index.tsx` | Router; add reserved handling; do not leave chrome-only unknown paths | | `src/app/DefaultHead.tsx` | Must not emit product OG for reserved paths | | `src/app/LegacyRedirect.tsx` | Legacy sections only — do not reuse for legal guesses | | `src/content/invariants.ts` | Canonical URL constants; optional notice URL later | | `src/content/invariants.test.ts` | Table-driven reserved-path tests | | `PROJECT_GUIDE.md` | Today documents SPA fallback as required; amend | | `e2e/*.spec.ts` + `playwright.config.ts` | Workers already 5; add reserved-path e2e | | Sibling: `code/cl8y-dex-terraclassic` `docker/frontend/nginx.conf` | Exact `location =` before `location /` | | Sibling: `code/cl8y-dex-terraclassic` `frontend-dapp/src/App.tsx` | Stop `*` → `/` for reserved paths | | Sibling: `code/cl8y-bridge-monorepo` `packages/frontend/src/main.tsx` | Same catch-all | | Sibling (later): `code/cl8y-ecosystem-legal` `web/src/main.ts` | Default-to-home must not swallow `/privacy` if the notice lives there | ## Recommended direction 1. **Shared path table** (pure helper + unit tests), e.g. `src/lib/reservedLegalGuessPaths.ts`: Exact (with/without trailing slash; match case-insensitively): - `/privacy`, `/privacy-policy`, `/privacy_policy`, `/privacypolicy`, `/privacy-notice` - `/cookies`, `/cookie`, `/cookie-policy`, `/cookies-policy` - `/opt-out`, `/optout`, `/opt_out` - `/do-not-sell`, `/donotsell`, `/ccpa` Match **pathname only** (strip query/hash). Do not treat `/privacy-xyz` or `/blog/privacy` as reserved unless the path is exactly a list entry. 2. **CL8Y-web host:** In `_redirects` and `render.yaml`, put reserved rules **before** `/* → /index.html`. Prefer `404` (or `301` to the canonical notice once that URL is a real first-party document). Keep `/blog` and `/blog/*` prerender rewrites first. 3. **CL8Y-web client:** If a reserved path still reaches React (Vite dev), render a minimal non-product 404 (no homepage hero, no DefaultHead product title) **or** `window.location.replace` only to the canonical notice URL constant. Do not `Navigate` to `/`. 4. **DEX nginx:** `location = /privacy { return 404; }` (and the rest of the table, including `/privacy/`) **before** `location /`. Keep `try_files` for real dApp routes. In `App.tsx`, check reserved paths **before** the `*` → `/` route (render nothing / a 404 view, or rely on the host 404 so the JS never runs). 5. **Bridge:** Same client exception to `path="*"`. If the frontend image has a static server config in-tree, add the same exact locations; if not, client-side is still mandatory. 6. **Redirect mode (optional, only when the notice exists):** Single hardcoded `https://…` Privacy Notice URL. 301/302. No query forwarding except maybe `utm_` strip. Cookies and opt-out guesses may 301 to the same notice (or 404) — pick one and test it; do not invent extra pages. ## Acceptance criteria - [ ] **AC1.** `GET /privacy`, `/cookies`, and `/opt-out` (and trailing-slash variants) on the marketing static host do **not** return `index.html` as 200 with product chrome. They return **404** or **301/302** to the canonical Privacy Notice. - [ ] **AC2.** Same reserved list is applied in DEX nginx **and** DEX `App.tsx` so those paths never `Navigate` to Swap. - [ ] **AC3.** Same reserved list is applied in bridge `main.tsx` so those paths never `Navigate` to Transfer (including under-construction `path="*"`). - [ ] **AC4.** Real product routes stay SPA: `/`, `/blog`, `/blog/:slug`, DEX `/`, `/swap`, `/pool`, `/protocol`, `/trade`, bridge `/`, `/history`, `/settings`, `/verify`. - [ ] **AC5.** `/privacy?next=https://evil.example` does not redirect off-origin. Query is ignored for routing. - [ ] **AC6.** DefaultHead / product OG is not emitted for reserved paths if the client renders at all. - [ ] **AC7.** Path table is unit-tested (allow + deny). Docs (`PROJECT_GUIDE.md` and/or a short skill note) say SPA fallback must not cover reserved legal-guess paths. - [ ] **AC8.** No Privacy Notice prose added to this repo. No T&Cs substitution. ## Test plan (functional paths) | # | Path | Expect | |---|------|--------| | T1 | `/privacy` | 404 or 301/302 to canonical notice; body is not homepage/Swap/Transfer | | T2 | `/privacy/` | Same as T1 | | T3 | `/PRIVACY` | Same as T1 (case-insensitive) | | T4 | `/cookies`, `/cookie-policy` | Same as T1 | | T5 | `/opt-out`, `/optout`, `/do-not-sell` | Same as T1 | | T6 | `/` | Unchanged marketing homepage 200 | | T7 | `/blog` and a prerendered `/blog/:slug` | Still 200 prerendered HTML, not reserved-path 404 | | T8 | `/security` | Existing legacy redirect to `/#trust` unchanged | | T9 | `/cl8y_whitepaper` | Existing PDF redirect unchanged | | T10 | Vite `yarn preview` `/privacy` | Client does not paint hero/CTAs; no product OG title | | T11 | DEX `/privacy` (preview or nginx fixture) | Not Swap; URL must not be replaced to `/` | | T12 | DEX `/` and `/swap?from=` | Unchanged | | T13 | Bridge `/privacy` | Not Transfer; URL must not be replaced to `/` | | T14 | Bridge `/history`, `/settings`, `/verify` | Unchanged | | T15 | `/privacy-policy` vs `/blog/privacy` | First reserved; blog slug not reserved | | T16 | `/privacy?foo=bar` | Reserved; query ignored | Playwright: `yarn test:e2e` already uses `--workers=5`. Add a dedicated spec that `request.get`s reserved paths (status + `content-type` / body must not be the homepage) and that product routes still 200. Unit tests for the matcher in `src/content/invariants.test.ts` or a new `src/lib/reservedLegalGuessPaths.test.ts`. ## Test plan (attack / hack / abuse) | # | Vector | Expect | |---|--------|--------| | A1 | Open redirect `/privacy?redirect=https://phish` | No off-origin `Location` | | A2 | `javascript:` / `data:` / `//evil` as redirect target | Impossible; target is a constant | | A3 | Path traversal `/privacy/../` or `/privacy/%2e%2e/` | Must not skip the deny and serve SPA as “privacy” | | A4 | Prefix `/privacy-not-a-policy` | Not reserved; existing unknown-path behavior (marketing chrome or DEX `*` — **do not expand this issue to all 404s**) | | A5 | DEX reserved path vs `/protocol` | `/protocol` still the protocol page | | A6 | Encoded `/ %70rivacy` vs `/privacy` | Document: decode then match, or only exact decoded pathname; no double-decode surprises | | A7 | Reserved path that still gets `index.html` (host mis-order) | Client must not `Navigate` to `/` and must not set product OG | | A8 | Crawler `Accept` HTML on `/privacy` | Must not receive homepage `og:title` | | A9 | `POST` / `PUT` to `/privacy` | 404/405; do not create an API | | A10 | Host rule after `/*` fallback | Test file order so reserved wins | | A11 | Bridge under-construction `path="*"` | Reserved paths still not presented as the product | | A12 | Header injection in `Location` | Use `URL` / nginx `return 301 https://…`; no string concat of user input | ## Verification criteria 1. **CL8Y-web:** `yarn test`, `yarn typecheck`, `yarn test:e2e` (5 workers) green. New matcher tests cover every reserved alias. `yarn preview` + `curl -sI http://127.0.0.1:5173/privacy` is 404 or 301, not 200 homepage. 2. **After static build:** `_redirects` / `render.yaml` order verified by reading the built `dist/_redirects` (or equivalent) so reserved rules precede `/*`. 3. **DEX sibling PR:** nginx configtest + a fixture request for `/privacy` is 404/301; `/` is still Swap; hashed `.js` miss still 404 (not HTML). RTL or router test: reserved path does not render SwapPage. 4. **Bridge sibling PR:** router test: `/privacy` does not render TransferPage; `/` still does. 5. **Manual:** open `/privacy`, `/cookies`, `/opt-out` on marketing, DEX, and bridge preview builds; confirm no product shell. Open `/`, `/blog`, DEX `/pool`, bridge `/history` and confirm no regression. ## Out of scope - Writing or publishing a Privacy Notice (separate legal-doc work in `code/cl8y-ecosystem-legal`). - Footer links to that notice (do that when the document exists). - Cookie banners, CMP SDKs, or an interactive opt-out form. - Changing T&Cs / TermsGate / clickwrap. - Turning the whole SPA into real per-route HTTP 404s for arbitrary unknown URLs (only the reserved legal-guess list). - Vote / other product hosts unless they copy this table later. - Wallet, indexer, or contract changes.
Author
Owner

S1 opt-out is required (cl8y-pm inbox). Reserved-path routing on this ticket still must not paint Swap; opt-out UI is not authored here.

S1 opt-out is **required** (cl8y-pm inbox). Reserved-path routing on this ticket still must not paint Swap; opt-out UI is not authored here.
Author
Owner

cl8y-agent-control: queued implement job cacd6e4a-83c1-4602-b7c5-6bd72041301e (not executed; no Hetzner VM).

cl8y-agent-control: queued `implement` job `cacd6e4a-83c1-4602-b7c5-6bd72041301e` (not executed; no Hetzner VM).
Author
Owner

cl8y-agent-control: needs_human inbox card POST failed. Job stays parked.

cl8y-agent-control: needs_human inbox card POST failed. Job stays parked.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/CL8Y-web#12
No description provided.