fix(frontend): Legal accept and deep links 404 on /vote at vote.cl8y.com #8

Closed
opened 2026-08-29 14:15:28 +00:00 by PlasticDigits · 7 comments
PlasticDigits commented 2026-08-29 14:15:28 +00:00 (Migrated from gitlab.com)

Summary

Live QA on https://vote.cl8y.com (Terra Classic, mobile in-app browser): after Accept Terms, the Legal portal returns the user to https://vote.cl8y.com/vote and the host responds 404. Stripping /vote (landing on /) loads the dApp, restores the SPA, and the wallet stays connected.

This is a production-blocking loop: every connected user must leave the origin for Legal, then come back via a full document load of a client-only route.

Related: #5 (Legal gate), #7 (Coolify / live QA). Do not treat this as “ops only” — in-tree routing and the Coolify nginx snippet both need to make /vote a 200 HTML document.

Current codebase

Standalone voting dApp (frontend/) is hosted on its own hostname (vote.cl8y.com) but still uses the DEX-era path prefix:

Piece Behavior
frontend/src/App.tsx / → <Navigate to="/vote" replace />. Real pages: /vote, /vote/new, /vote/:id.
frontend/src/components/layout/AppLayout.tsx ConnectedTermsGate wraps <Outlet />.
frontend/src/components/legal/ConnectedTermsGate.tsx Passes redirectUri={resolveLegalRedirectUri()} (current window.location.href).
frontend/src/utils/legalClickwrap.ts Allowlist is origin https://vote.cl8y.com. Paths such as https://vote.cl8y.com/vote are allowed.
@plasticdigits/cl8y-clickwrap TermsGate Accept does window.location.href = buildSignUrl(..., { redirectUri }). Portal later full-navigates back to that URI.
deploy/docker/frontend.nginx.conf location / { try_files $uri $uri/ /index.html; } — correct if this file is what actually serves production.
deploy/docker/frontend.Dockerfile HEALTHCHECK only GET /. Never probes /vote.
Vite base is / (not /vote/).

Client-side <Link to="/vote"> works. Reload, Legal return, and shared URLs hit the HTTP server. If Coolify, Traefik, or a parent nginx does not SPA-fallback, /vote is a missing file → 404. / serves index.html, React then client-redirects to /vote, which matches the reported workaround.

Why this is needed

Legal is required (fail closed after connect). The happy path is:

  1. Connect Terra or EVM on vote.cl8y.com.
  2. Gate sends the user to terms.cl8y.com with redirect_uri.
  3. After accept, the portal navigates back to that URI.

If step 3 404s, users cannot reach register / propose / vote without manually editing the URL. Mobile in-app browsers make that worse. /vote/new and /vote/:id will 404 on refresh the same way.

The /vote prefix was designed when voting was going to live inside the DEX origin. The product is now a dedicated host; the prefix is leftover and collides with Legal’s full-page return.

Constraints / guardrails

  1. No new smart contracts.
  2. Keep Legal SDK-only for terms. Portal full navigation — no iframe Accept. Do not treat Legal sign URLs as voting auth.
  3. Do not weaken portal redirect_uri allowlisting. Origins only (plus localhost in non-prod). Reject javascript:, data:, protocol-relative, and attacker hosts. Portal remain authoritative.
  4. Do not put Legal ADMIN_TOKEN in this frontend.
  5. Production CSP stays explicit (connect-src Legal API + terms + operator-voting). No blanket https:.
  6. Do not set VITE_PLAYWRIGHT_E2E or VITE_DEV_MNEMONIC on Coolify.
  7. Prefer fixing HTTP 200 + index.html for all app routes over hiding the bug by only redirecting Legal to /. Refresh of /vote/new must still work.
  8. Identity v1 unchanged. Wallet stacks stay DEX Terra + Bridge EVM.

Relevant files

  • frontend/src/App.tsx
  • frontend/src/components/legal/ConnectedTermsGate.tsx
  • frontend/src/utils/legalClickwrap.ts
  • frontend/src/utils/__tests__/legalClickwrap.test.ts
  • frontend/src/vendor/cl8y-clickwrap.ts (fallback buildSignUrl / sanitizeRedirectUri)
  • deploy/docker/frontend.nginx.conf
  • deploy/docker/frontend.Dockerfile
  • deploy/docker/frontend.security-headers.conf
  • docs/FRONTEND.md, docs/OPS.md §2, skills/AGENTS_LEGAL_CLICKWRAP.md

Sibling (do not implement voting there): cl8y-ecosystem-legal portal VITE_REDIRECT_URI_ALLOWLIST and web/src/redirect.ts.

  1. Prove what serves vote.cl8y.com. If Coolify is not using frontend.Dockerfile + frontend.nginx.conf, either switch to that image or stamp the same try_files on the actual edge. Confirm no parent proxy 404s /vote before nginx.
  2. Health / smoke: GET /, GET /vote, GET /vote/new must return 200 text/html (SPA index.html), not 404. Extend the image HEALTHCHECK or add a Coolify HTTP check for /vote.
  3. Legal return: keep a path-preserving redirect_uri once deep links work. Optional: also allow https://vote.cl8y.com/ so a broken edge still recovers. Do not send users to a different origin.
  4. Product option (same issue, do not split): flatten routes to /, /new, /:id on this dedicated host (keep /vote* as aliases). Hostname already says vote; the extra prefix is what Legal round-trips. Only do this if SPA fallback is also fixed — aliases without fallback still 404.
  5. Add a Playwright (or curl in CI/image) assertion that unknown app paths fall through to index.html and React renders the list/new/detail routes.

Acceptance criteria

  • After Legal Accept on Terra Classic and EVM, the browser lands on a working voting page (not a 404), wallet still connected, gate sees signed_latest.
  • Hard refresh of /vote, /vote/new, and /vote/:id returns the app, not 404.
  • https://vote.cl8y.com/ still works (today it client-redirects to /vote).
  • Disconnected browse of / and /vote still open; connected unsigned still fail-closed behind TermsGate.
  • Evil redirect_uri still does not navigate (portal + SDK tests remain green).
  • Production build still refuses Legal hatch / mnemonic / VITE_* BSC RPC.

Test plan (all paths)

HTTP / deploy

  1. curl -sI https://vote.cl8y.com/ → 200 HTML.
  2. curl -sI https://vote.cl8y.com/vote → 200 HTML (not 404).
  3. curl -sI https://vote.cl8y.com/vote/new → 200 HTML.
  4. curl -sI https://vote.cl8y.com/vote/does-not-exist-uuid → 200 HTML (SPA), then React empty/error — not nginx 404 for the document.
  5. curl -sI https://vote.cl8y.com/assets/<missing>.js → 404 (static try_files $uri =404 must stay).

Legal

  1. Disconnected: list page visible, no gate.
  2. Connect Terra (Keplr / Station / in-app browser): unsigned → Accept → portal → return → list page, not 404.
  3. Repeat on EVM (MetaMask / in-app) with network=EVM.
  4. Accept starting from /vote/new (if gate wraps that route): return to new-proposal, not 404.
  5. Localhost allowlist only in non-prod; production origin https://vote.cl8y.com only.

App routes after return

  1. Register CTA visible when unsigned-for-voting but Legal-signed.
  2. Client navigation /vote → /vote/new → back still works.
  3. Direct paste of a proposal URL after deploy.

Regression

  1. cd frontend && npm test (legalClickwrap allowlist / sanitize).
  2. cd frontend && npm run test:e2e (5 Playwright workers). Hatch remains webServer-only.

Test plan (attack, hack, abuse)

  1. Open redirect: redirect_uri=https://evil.example/ must not auto-navigate after Legal accept. Same for javascript:, data:, //evil, userinfo, http non-loopback.
  2. Allowlist bypass: extra path/query/fragment on https://vote.cl8y.com is OK; a lookalike host (vote.cl8y.com.evil, https://vote.cl8y.com@evil) is not.
  3. Host header / forwarded proto: app must not mint a redirect_uri for an attacker origin.
  4. Cache: a 404 for /vote must not be cached as the lasting representation once fallback is fixed (index.html no-cache already in nginx).
  5. Static location regex: SPA paths must not be swallowed by the .(js|css|…) location (404 without fallback).
  6. CSP: do not add connect-src https: to “fix” Legal. No ADMIN_TOKEN in the image.
  7. Phishing copy: returning from Legal must not ask for a seed. Existing wallet copy stays.
  8. Clickjacking: existing frame-ancestors / security headers remain on /vote HTML.

Verification criteria

  • Live vote.cl8y.com: Accept Terms in a real Terra mobile in-app browser and a desktop Keplr session; both return to a rendered proposals page without URL editing.
  • Same for MetaMask BSC 56 (#7 O7).
  • Ops can show the Coolify service uses deploy/docker/frontend.nginx.conf (or an equivalent try_files) and that curl to /vote is 200.
  • Do not close while /vote 404s on a hard navigation.
## Summary Live QA on `https://vote.cl8y.com` (Terra Classic, mobile in-app browser): after **Accept Terms**, the Legal portal returns the user to `https://vote.cl8y.com/vote` and the host responds **404**. Stripping `/vote` (landing on `/`) loads the dApp, restores the SPA, and the wallet stays connected. This is a production-blocking loop: every connected user must leave the origin for Legal, then come back via a **full document load** of a client-only route. Related: [#5](https://gitlab.com/PlasticDigits/voting/-/issues/5) (Legal gate), [#7](https://gitlab.com/PlasticDigits/voting/-/issues/7) (Coolify / live QA). Do not treat this as “ops only” — in-tree routing and the Coolify nginx snippet both need to make `/vote` a 200 HTML document. ## Current codebase Standalone voting dApp (`frontend/`) is hosted on its **own hostname** (`vote.cl8y.com`) but still uses the DEX-era path prefix: | Piece | Behavior | |-------|----------| | `frontend/src/App.tsx` | `/` → `<Navigate to="/vote" replace />`. Real pages: `/vote`, `/vote/new`, `/vote/:id`. | | `frontend/src/components/layout/AppLayout.tsx` | `ConnectedTermsGate` wraps `<Outlet />`. | | `frontend/src/components/legal/ConnectedTermsGate.tsx` | Passes `redirectUri={resolveLegalRedirectUri()}` (current `window.location.href`). | | `frontend/src/utils/legalClickwrap.ts` | Allowlist is **origin** `https://vote.cl8y.com`. Paths such as `https://vote.cl8y.com/vote` are allowed. | | `@plasticdigits/cl8y-clickwrap` `TermsGate` | Accept does `window.location.href = buildSignUrl(..., { redirectUri })`. Portal later **full-navigates** back to that URI. | | `deploy/docker/frontend.nginx.conf` | `location / { try_files $uri $uri/ /index.html; }` — correct **if** this file is what actually serves production. | | `deploy/docker/frontend.Dockerfile` | HEALTHCHECK only `GET /`. Never probes `/vote`. | | Vite | `base` is `/` (not `/vote/`). | Client-side `<Link to="/vote">` works. **Reload, Legal return, and shared URLs** hit the HTTP server. If Coolify, Traefik, or a parent nginx does not SPA-fallback, `/vote` is a missing file → 404. `/` serves `index.html`, React then client-redirects to `/vote`, which matches the reported workaround. ## Why this is needed Legal is **required** (fail closed after connect). The happy path is: 1. Connect Terra or EVM on `vote.cl8y.com`. 2. Gate sends the user to `terms.cl8y.com` with `redirect_uri`. 3. After accept, the portal navigates back to that URI. If step 3 404s, users cannot reach register / propose / vote without manually editing the URL. Mobile in-app browsers make that worse. `/vote/new` and `/vote/:id` will 404 on refresh the same way. The `/vote` prefix was designed when voting was going to live **inside** the DEX origin. The product is now a dedicated host; the prefix is leftover and collides with Legal’s full-page return. ## Constraints / guardrails 1. **No new smart contracts.** 2. Keep Legal **SDK-only** for terms. Portal full navigation — no iframe Accept. Do not treat Legal sign URLs as voting auth. 3. **Do not weaken** portal `redirect_uri` allowlisting. Origins only (plus localhost in non-prod). Reject `javascript:`, `data:`, protocol-relative, and attacker hosts. Portal remain authoritative. 4. Do not put Legal `ADMIN_TOKEN` in this frontend. 5. Production CSP stays explicit (`connect-src` Legal API + terms + operator-voting). No blanket `https:`. 6. Do not set `VITE_PLAYWRIGHT_E2E` or `VITE_DEV_MNEMONIC` on Coolify. 7. Prefer fixing **HTTP 200 + `index.html` for all app routes** over hiding the bug by only redirecting Legal to `/`. Refresh of `/vote/new` must still work. 8. Identity v1 unchanged. Wallet stacks stay DEX Terra + Bridge EVM. ## Relevant files - `frontend/src/App.tsx` - `frontend/src/components/legal/ConnectedTermsGate.tsx` - `frontend/src/utils/legalClickwrap.ts` - `frontend/src/utils/__tests__/legalClickwrap.test.ts` - `frontend/src/vendor/cl8y-clickwrap.ts` (fallback `buildSignUrl` / `sanitizeRedirectUri`) - `deploy/docker/frontend.nginx.conf` - `deploy/docker/frontend.Dockerfile` - `deploy/docker/frontend.security-headers.conf` - `docs/FRONTEND.md`, `docs/OPS.md` §2, `skills/AGENTS_LEGAL_CLICKWRAP.md` Sibling (do not implement voting there): `cl8y-ecosystem-legal` portal `VITE_REDIRECT_URI_ALLOWLIST` and `web/src/redirect.ts`. ## Recommended direction 1. **Prove what serves `vote.cl8y.com`.** If Coolify is not using `frontend.Dockerfile` + `frontend.nginx.conf`, either switch to that image or stamp the same `try_files` on the actual edge. Confirm no parent proxy 404s `/vote` before nginx. 2. **Health / smoke:** `GET /`, `GET /vote`, `GET /vote/new` must return **200** `text/html` (SPA `index.html`), not 404. Extend the image HEALTHCHECK or add a Coolify HTTP check for `/vote`. 3. **Legal return:** keep a path-preserving `redirect_uri` once deep links work. Optional: also allow `https://vote.cl8y.com/` so a broken edge still recovers. Do not send users to a different origin. 4. **Product option (same issue, do not split):** flatten routes to `/`, `/new`, `/:id` on this dedicated host (keep `/vote*` as aliases). Hostname already says vote; the extra prefix is what Legal round-trips. Only do this if SPA fallback is also fixed — aliases without fallback still 404. 5. Add a Playwright (or curl in CI/image) assertion that unknown app paths fall through to `index.html` and React renders the list/new/detail routes. ## Acceptance criteria - [ ] After Legal Accept on Terra Classic **and** EVM, the browser lands on a **working** voting page (not a 404), wallet still connected, gate sees `signed_latest`. - [ ] Hard refresh of `/vote`, `/vote/new`, and `/vote/:id` returns the app, not 404. - [ ] `https://vote.cl8y.com/` still works (today it client-redirects to `/vote`). - [ ] Disconnected browse of `/` and `/vote` still open; connected unsigned still fail-closed behind TermsGate. - [ ] Evil `redirect_uri` still does not navigate (portal + SDK tests remain green). - [ ] Production build still refuses Legal hatch / mnemonic / `VITE_*` BSC RPC. ## Test plan (all paths) **HTTP / deploy** 1. `curl -sI https://vote.cl8y.com/` → 200 HTML. 2. `curl -sI https://vote.cl8y.com/vote` → 200 HTML (not 404). 3. `curl -sI https://vote.cl8y.com/vote/new` → 200 HTML. 4. `curl -sI https://vote.cl8y.com/vote/does-not-exist-uuid` → 200 HTML (SPA), then React empty/error — not nginx 404 for the document. 5. `curl -sI https://vote.cl8y.com/assets/<missing>.js` → 404 (static `try_files $uri =404` must stay). **Legal** 6. Disconnected: list page visible, no gate. 7. Connect Terra (Keplr / Station / in-app browser): unsigned → Accept → portal → return → list page, not 404. 8. Repeat on EVM (MetaMask / in-app) with `network=EVM`. 9. Accept starting from `/vote/new` (if gate wraps that route): return to new-proposal, not 404. 10. Localhost allowlist only in non-prod; production origin `https://vote.cl8y.com` only. **App routes after return** 11. Register CTA visible when unsigned-for-voting but Legal-signed. 12. Client navigation `/vote` → `/vote/new` → back still works. 13. Direct paste of a proposal URL after deploy. **Regression** 14. `cd frontend && npm test` (legalClickwrap allowlist / sanitize). 15. `cd frontend && npm run test:e2e` (5 Playwright workers). Hatch remains webServer-only. ## Test plan (attack, hack, abuse) 1. **Open redirect:** `redirect_uri=https://evil.example/` must not auto-navigate after Legal accept. Same for `javascript:`, `data:`, `//evil`, userinfo, http non-loopback. 2. **Allowlist bypass:** extra path/query/fragment on `https://vote.cl8y.com` is OK; a lookalike host (`vote.cl8y.com.evil`, `https://vote.cl8y.com@evil`) is not. 3. **Host header / forwarded proto:** app must not mint a `redirect_uri` for an attacker origin. 4. **Cache:** a 404 for `/vote` must not be cached as the lasting representation once fallback is fixed (`index.html` no-cache already in nginx). 5. **Static location regex:** SPA paths must not be swallowed by the `.(js|css|…)` location (404 without fallback). 6. **CSP:** do not add `connect-src https:` to “fix” Legal. No `ADMIN_TOKEN` in the image. 7. **Phishing copy:** returning from Legal must not ask for a seed. Existing wallet copy stays. 8. **Clickjacking:** existing frame-ancestors / security headers remain on `/vote` HTML. ## Verification criteria - Live `vote.cl8y.com`: Accept Terms in a **real** Terra mobile in-app browser and a desktop Keplr session; both return to a rendered proposals page without URL editing. - Same for MetaMask BSC 56 (#7 O7). - Ops can show the Coolify service uses `deploy/docker/frontend.nginx.conf` (or an equivalent `try_files`) and that `curl` to `/vote` is 200. - Do not close while `/vote` 404s on a hard navigation.
PlasticDigits commented 2026-08-29 14:16:37 +00:00 (Migrated from gitlab.com)

mentioned in issue #9

mentioned in issue #9
PlasticDigits commented 2026-08-29 14:16:52 +00:00 (Migrated from gitlab.com)

mentioned in issue #7

mentioned in issue #7
PlasticDigits commented 2026-08-29 14:43:47 +00:00 (Migrated from gitlab.com)

mentioned in merge request !19

mentioned in merge request !19
PlasticDigits commented 2026-08-29 15:02:40 +00:00 (Migrated from gitlab.com)

!19 is merged to main (no automerge, did not wait for CI). GitLab pipeline on !19 failed with ci_quota_exceeded — jobs never started; not a product defect. Local sanity after merge: frontend unit 55, Playwright 9/9 (5 workers), deploy/docker/test-frontend-spa-fallback.sh OK.

In-tree (this does not close the issue): canonical routes /, /new, /:id with /vote* aliases; nginx try_files $uri /index.html plus /assets/ =404; HEALTHCHECK probes /vote and /new; Legal redirect_uri stays path-preserving. Guardrails (allowlist, CSP, hatch/RPC refuse) intact.

Still required before close:

  1. Terra Classic Accept (including mobile in-app) → return to a rendered list, wallet still connected, gate signed_latest.
  2. Same on EVM / MetaMask BSC 56.
  3. Hard refresh /vote/new and a proposal URL after the flattened JS is deployed.
  4. Confirm Coolify keeps this try_files (live host was nginx/1.31.4, not the in-tree 1.27 image). HTML responses still lacked X-Frame-Options / CSP / Cache-Control: no-cache that frontend.security-headers.conf would stamp.

Live curl of https://vote.cl8y.com/vote was already 200 SPA HTML at review time; that does not substitute for Legal Accept QA.

!19 is merged to `main` (no automerge, did not wait for CI). GitLab pipeline on !19 failed with `ci_quota_exceeded` — jobs never started; not a product defect. Local sanity after merge: frontend unit 55, Playwright 9/9 (5 workers), `deploy/docker/test-frontend-spa-fallback.sh` OK. **In-tree (this does not close the issue):** canonical routes `/`, `/new`, `/:id` with `/vote*` aliases; nginx `try_files $uri /index.html` plus `/assets/` `=404`; HEALTHCHECK probes `/vote` and `/new`; Legal `redirect_uri` stays path-preserving. Guardrails (allowlist, CSP, hatch/RPC refuse) intact. **Still required before close:** 1. Terra Classic Accept (including mobile in-app) → return to a rendered list, wallet still connected, gate `signed_latest`. 2. Same on EVM / MetaMask BSC 56. 3. Hard refresh `/vote/new` and a proposal URL after the flattened JS is deployed. 4. Confirm Coolify keeps this `try_files` (live host was `nginx/1.31.4`, not the in-tree 1.27 image). HTML responses still lacked `X-Frame-Options` / CSP / `Cache-Control: no-cache` that `frontend.security-headers.conf` would stamp. Live `curl` of `https://vote.cl8y.com/vote` was already 200 SPA HTML at review time; that does not substitute for Legal Accept QA.
PlasticDigits commented 2026-08-30 02:42:03 +00:00 (Migrated from gitlab.com)

mentioned in issue #12

mentioned in issue #12
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-30 05:21:30 +00:00
PlasticDigits commented 2026-08-30 05:21:31 +00:00 (Migrated from gitlab.com)

Users reporting fixed, closing

Users reporting fixed, closing
PlasticDigits commented 2026-08-30 15:47:45 +00:00 (Migrated from gitlab.com)

mentioned in issue #16

mentioned in issue #16
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/voting#8
No description provided.