Coded landing /c/<slug> and rotating the code string

Updated · View the entry on sijie.xyz ↗

Verdict (2026-09-07): an access code has three separable parts. The row id is what every link is keyed on (embeds, applications, members, denials). The slug is a snowflake short id that names the code's landing path /c/<slug> — a locator, never a credential. The code string (LABEL-<16 hex>) is the only credential, and it is the only part codes.rotate replaces. The landing page absorbs ?code= out of the address bar on first paint, so the plaintext string lives on paper (QR — quick-response — codes, PDFs) and in the session store, not in URLs, history, or Referer headers.

Motivating problem. The résumé PDF (resume-composer-one-renderer) prints <public_url>/?code=<code> in its QR. That is a credential in a URL: it lands in browser history, in the Referer of every cross-origin subresource, in screenshots. And a code leaks as a whole — recruiters forward PDFs. Before 2026-09-06 the only recovery was codes.revoke, which also killed the code's id-keyed links (embeds, past applications, member list). Two gaps: the plaintext string was addressable, and leak recovery cost the whole row.

The slug is a locator, not a credential

  • Generator. backend/internal/infra/snowflake/snowflake.go:7-8: 41 bits of ms since epoch, 10 bits node, 12 bits sequence; Slug() (:71) base62-encodes it (0-9a-zA-Z, at most 11 chars). Process-wide node in access/repo/codes_slug.go:7-21; stamped at the single creation point access/repo/codes.go:84.
  • Owner override. DeriveSlug(provided, generated) (access/entity/code_derive.go:69-75): lower-cased [a-z0-9-_], max 32; reserved names (home, gate, admin, api, setup, login, p, wiki, output, assets) fall back to the snowflake (:61-64). The owner's HIRING-2026 reads as /c/hiring-2026.
  • Storage. access_codes.slug citext NOT NULL (schema.sql:354), unique per owner (:357). Migration 2026-09-06-access-code-slug.sql:10-13: add nullable → backfill from the row id → NOT NULL → unique index, so an upgraded instance keeps every existing code.
  • No server-side resolution. app/src/app/c/[slug]/page.tsx:28-33 never reads the slug param: it renders VisitorRoot with hasCode={false}, and authorization comes only from the stored session. Nothing in backend/internal/access looks a code up by slug. Guard: coded-landing-slug.spec.ts:41-51 (D3) visits /c/some-locator with no session and asserts the default home with zero chat inputs.

Landing: absorb, rewrite, route

flowchart TB
  Q["visitor opens /?code=LABEL-XXXX (QR, link, paste)"] --> ABS["use-absorb-code: searchParams.delete(code) + history.replaceState"]
  ABS --> ISSUE["issue: /api/v1/sessions (plaintext code)"]
  ISSUE -->|"401"| gate["window.location.assign(/gate)"]
  ISSUE -->|"200 + slug + microsite_slug"| LAND{"landAfterIssue"}
  LAND -->|"microsite bound"| NAV["location.assign(/p/microsite-slug)"]
  LAND -->|"no microsite"| RW["history.replaceState(/c/slug) - same chat, new URL"]
  RW --> chat["visitor chat under the frozen RoleSnapshot"]
  NAV --> chat
  • Absorb. app/src/lib/gate/use-absorb-code.ts:55-58 deletes code from the query and calls history.replaceState before the session request — the string is gone from the address bar before anything else runs.
  • Rewrite. The issue response carries slug and microsite_slug (routes/public/sessions.go:62-65); use-issue-pending-code.ts:124 calls applyLanding(landAfterIssue(...)). app/src/lib/visitor/code-landing.ts:25-40: a bound microsite (access_codes.microsite_id, schema.sql:344, ON DELETE SET NULL at :756-759, set by codes.set_microsite, access/ops/codes.go:70-78) means a full navigation to /p/<microsite-slug> — a microsite is a separate build artifact; otherwise the visitor stays in the already-rendered chat and only the URL becomes /c/<slug>. Guard: coded-landing-slug.spec.ts:28 (D1) asserts the URL matches /c/[^/?#]+$ with no code=.
  • Referer. Absorbing runs in JS (JavaScript); the first paint precedes it. app/next.config.ts:122-125 sets Referrer-Policy: strict-origin-when-cross-origin on every path (fc841f41f, 2026-09-01), so a cross-origin subresource loaded during that first paint sees the origin only. Guard: security-referrer-policy.spec.ts:18-25.
  • Reload. /c/<slug> on reload is served by the visitor session in localStorage; the slug is a bookmark for a session the browser already holds.

A new code switches; an invalid code lands on /gate

app/src/app/visitor-root.tsx:71 — pending ? 'picker' : viewFor(isChatSession(session), hasCode): a ?code= arriving while an old session exists opens the identity picker for the new code instead of silently keeping the old chat (the owner's case: a recruiter who already visited under one code scans a second résumé). A 401 on issue becomes 'invalid' (use-issue-pending-code.ts:141-144) and VisitorNamePicker.tsx:81-89 sends the visitor to /gate. 230a17025 (2026-09-06), unit-tested in visitor-root.test.ts.

codes.rotate — what survives, what dies

The SQL is one column: UPDATE access_codes SET code = $3 WHERE id = $1 AND owner_id = $2 RETURNING * (db/queries/access/access_codes.sql:33-36). Then the usecase purges sessions (access/usecase/codes.go:124-126, d.Sessions.DeleteByCode — the same Redis purge RevokeCode uses at :103; visitor_session.go:124-139).

survives (nothing else is written) dies
row id and slug → /c/<slug> keeps resolving the old plaintext string — every printed QR, emailed PDF, shared ?code= link
assumed_role_id and the frozen RoleSnapshot (role-snapshot-frozen) every live visitor session under this code (Redis code:<id> set, each token deleted)
code_*_denials, code_members, quotas, expiry (acl-and-quota-granularity)
microsite_id, embeds (embeds.code_id), application rows — all keyed by id
  • New string must be non-empty and free of whitespace and /?#& (codes.go:134-136, else ErrCodeInvalid); a citext collision → ErrCodeTaken → 409 (access/repo/codes_rotate.go:41-48).
  • Admin: PATCH /codes/{id}/code (routes/admin/codes.go:51, use-codes.ts:117-123) behind a warning modal (admin-access.json:33-37, 8 locales): "Rotating the code makes every copy already handed out stop working — résumé PDFs and QRs you've already sent, shared ?code= links — and signs out anyone using it now. Embeds and past applications keep working (they're bound by id). This can't be undone." MCP: codes.rotate {code_id, code} (access/ops/codes_write.go:105-127).
  • Guards: code-rotation.spec.ts:46-72 (id unchanged, old string refused, 409 on collision) and code-change-ui.spec.ts:31-48 (modal + confirm).

Entropy and the issuance log

DeriveCode (access/entity/code_derive.go:47-56) = upper-cased label ([A-Z0-9], max 12, default CODE) + - + 8 random bytes as 16 upper-case hex chars: 64 bits of suffix (1ca9d9564, 2026-09-01, after the pentest that found the old 16-bit suffix). If crypto/rand fails the suffix is sixteen 0s — a visible sentinel, not a weak code that looks normal (:146). IssueCode logs access code issued with owner_id, code_id, slug, role_id, label — never the string (access/usecase/codes.go:60-63, 0e6104a6f, 2026-09-07), so a slug seen in prod traces back to its issuance.

The honest ceiling

  • The QR still carries the plaintext. A recruiter with no session needs a credential; the slug cannot stand in for it (D3 proves it grants nothing). Absorb-and-rewrite shortens the string's exposure to the first paint; it does not remove the string from paper. Rotation is the recovery, and it is deliberately total.
  • Rotation kills sessions, and no e2e asserts that. code-rotation.spec.ts:8-9 defers the purge to visitor_session_revoke_test; the modal-copy assertion is a loose regex. A stale comment in codes_rotate.go:17-18 claims live sessions are untouched — true of the SQL, false of the operation; trust usecase/codes.go:109-112.
  • The slug is per owner, not global. Uniqueness is (owner_id, slug); /c/<slug> is unambiguous only because a v1 instance is single-owner. Multi-tenant routing needs the handle in the path.
  • Switching is unit-tested only. 230a17025 added view-selection cases to visitor-root.test.ts; no browser spec drives "second QR after a first session" end to end.

Built 2026-09-01 → 2026-09-07. Referrer-Policy fc841f41f; entropy 1ca9d9564 (backend/internal/access/entity/code_derive.go); snowflake dbb803288 (backend/internal/infra/snowflake/snowflake.go); slug column 4e383c2a7 + migration 2026-09-06-access-code-slug.sql; landing path c6c54ce88 (app/src/lib/visitor/code-landing.ts, app/src/app/c/[slug]/page.tsx); spec 1b 59f83db86 (e2e/test/coded-landing-slug.spec.ts); session switch 230a17025; RotateCode 505b3fc4f (backend/internal/access/usecase/codes.go, access/repo/codes_rotate.go, e2e/test/code-rotation.spec.ts); admin modal 6ed1759bb (e2e/test/code-change-ui.spec.ts); issuance log 0e6104a6f. The chain this serves: chain-selective-access.

Related notes