带码落地 /c/<slug> 与轮换 code 字符串
父级:access-control · 兄弟:embed-credential-never-carries-the-code
结论(2026-09-07): 一张 access code 有三个可分离的部分。行 id 是所有关联的键(embed、application、成员、拒绝表)。slug 是一个 snowflake 短 id,命名这张 code 的落地路径
/c/<slug>——是定位符,永远不是凭据。code 字符串(LABEL-<16 hex>)是唯一的凭据,也是codes.rotate唯一替换的部分。落地页在首次绘制时就把?code=从地址栏吸收掉,所以明文只活在纸上(QR——quick-response——码、PDF)和会话存储里,不在 URL、历史记录或 Referer 头里。
触发问题。 简历 PDF(resume-composer-one-renderer)在二维码里印的是 <public_url>/?code=<code>。这是 URL 里的凭据:它会进浏览器历史、进每个跨源子资源的 Referer、进截图。而且 code 是整体泄露的——招聘者会转发 PDF。2026-09-06 之前唯一的恢复手段是 codes.revoke,它连带杀掉这张 code 所有按 id 关联的东西(embed、过去的 application、成员列表)。两个缺口:明文字符串可寻址,泄露恢复要付出整行的代价。
slug 是定位符,不是凭据
- 生成器。
backend/internal/infra/snowflake/snowflake.go:7-8:41 位自 epoch 起的毫秒、10 位节点、12 位序列;Slug()(:71)做 base62 编码(0-9a-zA-Z,最多 11 字符)。进程级节点在access/repo/codes_slug.go:7-21;在唯一的创建点access/repo/codes.go:84盖上。 - owner 覆盖。
DeriveSlug(provided, generated)(access/entity/code_derive.go:69-75):小写[a-z0-9-_],最长 32;保留名(home、gate、admin、api、setup、login、p、wiki、output、assets)回退到 snowflake(:61-64)。owner 的HIRING-2026读作/c/hiring-2026。 - 存储。
access_codes.slug citext NOT NULL(schema.sql:354),每个 owner 内唯一(:357)。migration2026-09-06-access-code-slug.sql:10-13:先加可空列 → 从行 id 回填 →NOT NULL→ 唯一索引,所以升级上来的实例保住每一张已有 code。 - 没有服务端解析。
app/src/app/c/[slug]/page.tsx:28-33从不读slug参数:它以hasCode={false}渲染VisitorRoot,授权只来自已存的会话。backend/internal/access里没有任何按 slug 查 code 的地方。守卫:coded-landing-slug.spec.ts:41-51(D3)无会话访问/c/some-locator,断言是默认首页且聊天输入框为零。
落地:吸收、改写、分流
- 吸收。
app/src/lib/gate/use-absorb-code.ts:55-58从 query 里删掉code,在发会话请求之前就调history.replaceState——别的任何东西跑起来之前,字符串已经不在地址栏里了。 - 改写。 签发响应带
slug和microsite_slug(routes/public/sessions.go:62-65);use-issue-pending-code.ts:124调applyLanding(landAfterIssue(...))。app/src/lib/visitor/code-landing.ts:25-40:绑定了 microsite(access_codes.microsite_id,schema.sql:344,:756-759处ON DELETE SET NULL,由codes.set_microsite设置,access/ops/codes.go:70-78)就整页跳到/p/<microsite-slug>——microsite 是独立的构建产物;否则访客留在已经渲染好的聊天里,只有 URL 变成/c/<slug>。守卫:coded-landing-slug.spec.ts:28(D1)断言 URL 匹配/c/[^/?#]+$且没有code=。 - Referer。 吸收在 JS(JavaScript)里跑;首次绘制在它之前。
app/next.config.ts:122-125给每条路径设Referrer-Policy: strict-origin-when-cross-origin(fc841f41f,2026-09-01),所以首次绘制期间加载的跨源子资源只看得到 origin。守卫:security-referrer-policy.spec.ts:18-25。 - 刷新。 刷新时的
/c/<slug>由localStorage里的访客会话来服务;slug 是浏览器已持有的那个会话的书签。
新 code 切换会话;无效 code 落到 /gate
app/src/app/visitor-root.tsx:71——pending ? 'picker' : viewFor(isChatSession(session), hasCode):旧会话还在时来了一个 ?code=,就为新 code 打开身份选择器,而不是悄悄留在旧聊天里(owner 的场景:已经凭一张 code 来过的招聘者扫了第二份简历)。签发时的 401 变成 'invalid'(use-issue-pending-code.ts:141-144),VisitorNamePicker.tsx:81-89 把访客送到 /gate。230a17025(2026-09-06),单元测试在 visitor-root.test.ts。
codes.rotate——什么活下来,什么死
SQL 只动一列:UPDATE access_codes SET code = \$3 WHERE id = \$1 AND owner_id = \$2 RETURNING *(db/queries/access/access_codes.sql:33-36)。然后 usecase 清掉会话(access/usecase/codes.go:124-126,d.Sessions.DeleteByCode——和 RevokeCode 在 :103 用的是同一个 Redis 清场;visitor_session.go:124-139)。
| 活下来(别的什么都不写) | 死 |
|---|---|
行 id 和 slug → /c/<slug> 继续可达 | 旧的明文字符串——每一张已打印的二维码、已发出的 PDF、分享过的 ?code= 链接 |
assumed_role_id 和冻结的 RoleSnapshot(role-snapshot-frozen) | 这张 code 下每一个活着的访客会话(Redis code:<id> 集合,逐个 token 删除) |
code_*_denials、code_members、配额、有效期(acl-and-quota-granularity) | |
microsite_id、embed(embeds.code_id)、application 行——全按 id 关联 |
- 新字符串必须非空且不含空白和
/?#&(codes.go:134-136,否则ErrCodeInvalid);citext 撞车 →ErrCodeTaken→ 409(access/repo/codes_rotate.go:41-48)。 - Admin:
PATCH /codes/{id}/code(routes/admin/codes.go:51,use-codes.ts:117-123),前面挡一个警告弹窗(admin-access.json:33-37,8 个 locale):"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)。 - 守卫:
code-rotation.spec.ts:46-72(id 不变、旧字符串被拒、撞车 409)和code-change-ui.spec.ts:31-48(弹窗 + 确认)。
熵与签发日志
DeriveCode(access/entity/code_derive.go:47-56)= 大写 label([A-Z0-9],最长 12,默认 CODE)+ - + 8 个随机字节写成 16 个大写十六进制字符:64 位后缀(1ca9d9564,2026-09-01,起因是渗透测试发现旧的 16 位后缀)。crypto/rand 失败时后缀是十六个 0——看得见的哨兵,不是一个看起来正常的弱 code(:146)。IssueCode 打一条 access code issued 日志,带 owner_id、code_id、slug、role_id、label——从不带字符串本身(access/usecase/codes.go:60-63,0e6104a6f,2026-09-07),所以线上看到的 slug 能追回它的签发。
诚实的天花板
- 二维码仍然带明文。 没有会话的招聘者需要一个凭据;slug 替不了它(D3 证明它什么都不授予)。吸收-改写把字符串的暴露缩短到首次绘制;它去不掉纸上的字符串。轮换是恢复手段,而且是有意做成全杀的。
- 轮换会杀会话,但没有 e2e 断言这一点。
code-rotation.spec.ts:8-9把清场推给visitor_session_revoke_test;弹窗文案的断言是一个宽松的正则。codes_rotate.go:17-18有一条过期注释说活着的会话不受影响——对 SQL 成立,对整个操作不成立;以usecase/codes.go:109-112为准。 - slug 是每个 owner 内唯一,不是全局唯一。 唯一性是
(owner_id, slug);/c/<slug>无歧义只因为 v1 实例是单 owner。多租户路由需要把 handle 放进路径。 - 切换只有单元测试。
230a17025给visitor-root.test.ts加了视图选择用例;没有浏览器 spec 端到端驱动"有了第一个会话之后扫第二张二维码"。
已实现 2026-09-01 → 2026-09-07。 Referrer-Policy fc841f41f;熵 1ca9d9564(backend/internal/access/entity/code_derive.go);snowflake dbb803288(backend/internal/infra/snowflake/snowflake.go);slug 列 4e383c2a7 + migration 2026-09-06-access-code-slug.sql;落地路径 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);会话切换 230a17025;RotateCode 505b3fc4f(backend/internal/access/usecase/codes.go、access/repo/codes_rotate.go、e2e/test/code-rotation.spec.ts);admin 弹窗 6ed1759bb(e2e/test/code-change-ui.spec.ts);签发日志 0e6104a6f。它服务的链:chain-selective-access。