Coded landing /c/<slug> and rotating the code string
Parent: access-control · Sibling: embed-credential-never-carries-the-code
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 partcodes.rotatereplaces. 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 inaccess/repo/codes_slug.go:7-21; stamped at the single creation pointaccess/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'sHIRING-2026reads as/c/hiring-2026. - Storage.
access_codes.slug citext NOT NULL(schema.sql:354), unique per owner (:357). Migration2026-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-33never reads theslugparam: it rendersVisitorRootwithhasCode={false}, and authorization comes only from the stored session. Nothing inbackend/internal/accesslooks a code up by slug. Guard:coded-landing-slug.spec.ts:41-51(D3) visits/c/some-locatorwith no session and asserts the default home with zero chat inputs.
Landing: absorb, rewrite, route
- Absorb.
app/src/lib/gate/use-absorb-code.ts:55-58deletescodefrom the query and callshistory.replaceStatebefore the session request — the string is gone from the address bar before anything else runs. - Rewrite. The issue response carries
slugandmicrosite_slug(routes/public/sessions.go:62-65);use-issue-pending-code.ts:124callsapplyLanding(landAfterIssue(...)).app/src/lib/visitor/code-landing.ts:25-40: a bound microsite (access_codes.microsite_id,schema.sql:344,ON DELETE SET NULLat:756-759, set bycodes.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 nocode=. - Referer. Absorbing runs in JS (JavaScript); the first paint precedes it.
app/next.config.ts:122-125setsReferrer-Policy: strict-origin-when-cross-originon 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 inlocalStorage; 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, elseErrCodeInvalid); 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) andcode-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-9defers the purge tovisitor_session_revoke_test; the modal-copy assertion is a loose regex. A stale comment incodes_rotate.go:17-18claims live sessions are untouched — true of the SQL, false of the operation; trustusecase/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.
230a17025added view-selection cases tovisitor-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.