Repository navigation
[finding] Anonymous callers to every better-auth-native /admin/ route get a bodyless 401 — no envelope, no machine-readable code #10349
Description
Activity
Claim: PM
domain:services派发- Session:
0f14f70b-575c-5f2b-a235-4000a55db042 - Branch:
claude/issue-10349-admin-refusal-envelope - Worktree:
../objectstack-10349(per-repo;⛔ 不在共享主检出上编辑;⛔ 不用git stash) - File surface:
packages/plugins/plugin-auth/src/auth-manager.ts(handleRequest缝)+packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts+ 同包测试。⛔ 零packages/spec所有权(车道红线)——UNAUTHENTICATED已在错误码册在案,复用即可,不要注册新码;⛔ 不碰content/docs/releases/。 - Container & model:
claude-opus-5 - Clause-②: yes —— 分诊已预判,我复核同意:这改变了一个对外可观测的响应形状(
/admin/命名空间下 vendor 通道的 401 从空体变为 ADR-0112 信封)。
⇒ PR 保持 draft、挂needs:contract-review、⛔ 不翻 ready、⛔ 不武装 auto-merge。CONTRACT_REVIEW_TIER现读为claude-fable-5(scripts/pm/dispatch-gates.mjs:1932);本车道 fable 已用尽,按维护者 2026-08-20 授权在 opus 开发,分诊审核作为补偿控制。
文件占用
auth-manager.ts目前无人持有(#10390、#10715 均已合并;在飞的 #10371 不碰 plugin-auth)。⚠️ 建分支后自己确认一次。
⛔ 不要跑scripts/check-single-claim-paths.mjs—— 今天实测:无PR_NUMBER打印NOT WIRED,有PR_NUMBER无GITHUB_TOKEN死在GitHub API 401。两种都是接线故障不是判词,接进管道会把 401 回溯读成绿。直接看其他开着的 PR 的文件清单,CI 会跑真门禁。分诊已定范围:选项 C,不是 B
C = 只把
/admin/命名空间的拒绝在AuthManager.handleRequest缝上归一到 ADR-0112 信封。状态码不变、准入不变,只补机器可读的code。⚠️ 分诊留了一个条件式升级口,这是本卡唯一真正需要你判断的地方:只有当那道缝使得 C 与 B 是同一个 diff 时,才升级到 B(整个 vendor 面)。
⇒ 如果你发现在那道缝上「只对
/admin/生效」需要额外加一个路径判别、而不加就自然覆盖全部 vendor 路由 —— 停下来说出来,不要自己决定往哪边倒。两个方向各有代价:硬加路径判别是在缝上引入一个它本来没有的概念;顺手覆盖全 vendor 面则把一个更大的契约变更夹带进一张写着 C 的卡。把 diff 摆出来,让我和分诊裁。实现要求
- 前提先自己复现,别采信卡面。卡面在 stock
better-auth@1.7.1+ 其admin插件上测到:body 是空字符串,不是空 JSON 对象 —— 这个区别要保住,因为它决定了归一化是「补一个信封」还是「改一个信封」。来源是 vendor 的/admin/impersonate-user anonymous -> 401 body="" /admin/set-role anonymous -> 401 body=""adminMiddleware(getAuthoritativeSessionFromCtx(ctx)后跟APIError.fromStatus("UNAUTHORIZED"))。 - 对照面也要量:ObjectStack 自挂的那批(
create-user、set-user-password、unlock-user、import-users、ban-user、unban-user、oauth2/toggle-disabled、sso/*)今天答的是带code: 'UNAUTHENTICATED'的 ADR-0112 信封(见platform-admin-gate.ts)。在同一棵树、同一次运行里把两侧摆在一起量出来 —— 这个不对称才是卡的实质。 - ADR-0112 是
codeANDstatus,不是二选一。 归一后的响应必须两者都断言,不能只钉一个。 - 那条已有的 dogfood 断言就是本修复自己的可证伪 pin:
packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts里 better-auth 那一桶今天只能写[401, 403].includes(anon.status)(因为没有 code 可断言)—— 修完之后它应该收紧成断言 code。
⭐ 收紧那一桶是交付物的一部分,不是可选项:那个套件今天记录了这个缺口而不是关闭它,而没有任何东西在跟踪关闭它。 - 双向 pin:① 匿名调 vendor 通道的
/admin/路由 → 401 且code: 'UNAUTHENTICATED';② ObjectStack 自挂那批行为完全不变(它们本来就对,别把它们改坏);③ 准入不变 —— 一个本来能过的调用者仍然能过(只钉拒绝方向的套件会在一个「拒绝所有人」的实现上全绿,这是本车道今天已经吃过两次的教训);④ 非/admin/的 vendor 路由在选项 C 下不受影响(若你发现做不到,见上面那个升级口)。 - 消融:预测签名先写下来再跑,双向;
git hash-object证明恢复逐字节一致。说明(并用阳性对照证明,不是断言)测试解析的是src/还是dist/。 - 零命中反向核对:任何靠零结果支撑的结论,先用邻近的已知存在项证明搜索/计数口径有效;改动前就存在的词返回非零也什么都不证明。
- Changeset 必须有。
⚠️ 这改变了公开响应形状,所以很可能是破坏性的 —— 仓库约定破坏性变更走minor而非patch(scripts/check-changeset-no-major.mjs自己的 WHY 段落),并且届时需要一条 ADR-0087 disposition 标记,挑那个为真的(注意正文若带 FROM → TO 处方,no-migration-prescription会被拒)。⛔ 绝不往content/docs/releases/追加。
⭐ 仪器事实:check-adr-0087-registration从 git 读 changeset 内容,不读工作树 —— 改完未提交就跑它,会得到一个不携带信息的绿。 - 门禁并集:
node scripts/pm/dispatch-gates.mjs不带路径参数推导,最终提交之后、干净工作树上跑;退出码在任何管道之前捕获;引用每道门禁自己那行判词。
⚠️ class [finding] Every PM dispatch list is short by the same ~5 changeset-triggered gate families — they are path-derivable, but the changeset does not exist yet when the list is derived #10309:推导并集已知会短。额外显式跑pnpm check:route-envelope与pnpm check:dispatcher-error-vocabulary(各带--self-test)—— 本卡尤其相关,它改的正是路由信封 —— 并报告推导是否点名了它们。 - ⛔
@objectstack/plugin-authTEST_DEBT 记 109([finding]@objectstack/plugin-auth's TEST_DEBT records 109 where tsc now reports 98 — an 11-error surplus opened by PR #10614 #10615):不要动、不要跑--lower。 - 开 draft PR(
Fixes #10349)。⛔ 不翻 ready、⛔ 不武装 auto-merge、⛔ 不合并。
两条今天由兄弟席位测出来的常备事项
- ⭐ 任何会改动
node_modules/下文件的步骤,先查硬链接数。 有席位测到一个 vendor 文件带 6 个硬链接指向共享 pnpm store,原地改会静默污染每一个并行 agent 的node_modules和 store 本身。只为自己的 worktree 断链(删目录项、写新 inode),用拷贝恢复。与git stash同族:看起来本地、实际伸进共享状态、而且会报告成功。本卡要量 vendor 的adminMiddleware,很可能用得上。 - 我先前关于
check-query-options-erasure-ratchet/ [P0-suspect] check:query-options-erasure runs ESLint IN-PROCESS without the stack fix — registry.ts crossed the default-stack limit again and the gate now reds every PR's Lint & Repo Gates #10449 的警告已撤回 —— 该卡已由 PR fix(gates): give the in-process ESLint ratchets the parser stack headroompnpm lintalready has #10464 于 2026-08-20T23:48:30Z 修复并关闭。那道门禁现在若红,是新信号、可能是你的。
报告
⭐ 报告评论第一行用字面文本
os-dev-report(单独一行)—— HTML 注释标记在本仓 issue 评论里会被 sanitizer 吃掉(今天两次实测),PM 扫不到的报告等于「零份报告」。
Generated by Claude Code
- Session:
- added a commit that references this issue
on Aug 21, 2026 os-dev-report
{ "issue": 10349, "status": "done", "branch": "claude/issue-10349-admin-refusal-envelope", "pr": "https://github.com/objectstack-ai/objectstack/pull/10800", "premise_still_valid": true, "summary": "Reproduced the premise and found it wider than the card: on the installed better-auth 1.7.1, TEN vendor-lane /admin/ routes answer an anonymous caller 401 with content-type application/json and the EMPTY STRING as the body, not the two the card names. AuthManager.handleRequest now gives those refusals the ADR-0112 envelope at the one seam every vendor route passes through, with the code DERIVED from the status by standardErrorCodeForHttpStatus (ADR-0112's own map) and the message hoisted out of platform-admin-gate.ts, so the two lanes now answer anonymous byte-identically and no new error code is registered. Statuses and admission unchanged; three narrowings (empty body only, 401/403 only, /admin/ only) each pinned by identity. Option C stays C: the seam ALREADY discriminates on betterAuthEndpointPath twice, so the prefix test introduces no new concept and C-vs-B is not the same diff. Tightening the dogfood better-auth-gate bucket made a NEIGHBOURING assertion executable for the first time and it went red immediately, exposing #10792 (a signed-in member gets 401 UNAUTHENTICATED from /admin/remove-user while its siblings give 403) — filed, recorded in the suite as an additional accepted code for that one route, not fixed here.", "tests": "All at final commit be9b5a9fd, clean tree, exit codes captured before any pipe. (1) @objectstack/plugin-auth: `Test Files 64 passed (64)` / `Tests 1373 passed (1373)`, and `tsc --noEmit` clean (script name echoed in the log, chained with &&, wrapper VERDICT command-exit 0). (2) @objectstack/dogfood admin-route-nonadmin-refusal.dogfood.test.ts against a BUILT closure: `Test Files 1 passed (1)` / `Tests 6 passed (6)`, VERDICT command-exit 0 — the tightened bucket green over the derived live route table (31 routes, 9 in the better-auth-gate bucket). (3) Both-lanes-one-run: before/after wire measurement through handleRequest in one tree; before `POST /admin/impersonate-user -> 401 ct=\"application/json\" len=0 body=\"\"`, after `-> 401 len=78 {\"success\":false,\"error\":{\"code\":\"UNAUTHENTICATED\",\"message\":\"Sign in first\"}}`, with /admin/oauth2/resources (404), /sign-in/email (401 with the vendor body), /get-session (200) and /sign-out (200) byte-identical across the diff. (4) ABLATION, predictions written down first, both legs restored byte-identically. Resolution PROVED not asserted: packages/plugins/plugin-auth/dist did NOT exist while both legs ran (`test -e` exit 1) against packages/spec/dist which did (exit 0, the positive control for the same predicate) — so the in-package tests resolve the subject through src/ and no rebuild is possible or needed for either leg; the dogfood leg is the opposite case and was run only after confirming `envelopeVendorAdminRefusal` appears twice in the built dist/index.mjs. Leg A (seam removed): predicted 2 failures as PARSE errors rather than assertion diffs because the pre-fix body is the empty string — observed exactly `SyntaxError: Unexpected end of JSON input` twice, `Tests 2 failed | 44 passed (46)`. Leg B (prefix widened to '/', the option-B shape): predicted the two pure-function identity pins fail while the seam's non-/admin/ test stays green — observed exactly, `Tests 2 failed | 44 passed (46)`. Restores: git hash-object back to 1044073db00341b2e02f2d91d162f2a72a4b78fd (auth-manager.ts) and 06ef895bf8586022a215d9cdb79530f6d3548708 (vendor-admin-refusal-envelope.ts). (5) GATE UNION: `node scripts/pm/dispatch-gates.mjs` with no path arguments, after the final commit — 13 families, all exit 0, verdict lines quoted in the table below. Convention-triggered ratchets (nul-bytes, engine-double-contract, where-matcher, query-options-erasure, type-check-coverage) all exit 0. (6) class #10309 CONFIRMED: the derivation named NEITHER check:route-envelope NOR check:dispatcher-error-vocabulary — 0 occurrences of each in its output, against 1 each for slot-lookup and engine-double-contract as the positive control, and `--residue` places both with the populations they do declare. Ran both explicitly: `✓ check-route-envelope self-test passed` + exit 0, and `check-dispatcher-error-vocabulary: OK — 21 unregistered code-stamping site(s), all classified` + `--self-test: 8 shapes + 102 assertions OK`, both exit 0. (7) TEST_DEBT 109 untouched: not edited, --lower not run; hand re-measured at 97 with neither touched test file contributing a diagnostic — a zero backed by the same run reporting 43 in auth-manager.test.ts and 18 in admin-user-endpoints.test.ts.", "open_questions": [ { "question": "The C-vs-B upgrade clause: I ruled it stays C. Confirming, because the clause was yours to decide.", "options": [ "C (shipped) — restrict to the /admin/ namespace. The seam ALREADY discriminates on betterAuthEndpointPath twice (STOP_IMPERSONATING_PATH, SESSION_ERASURE_PATHS), so the prefix test adds one member to a discrimination this method was built around, not a new concept. C and B are therefore NOT the same diff: B would be REMOVING a test and making a contract claim over every better-auth route.", "B — drop the prefix and cover the whole vendor surface." ], "recommendation": "C, shipped. One caveat I did not smooth over: at the WIRE, C and B are currently hard to tell apart, because I measured no bodyless refusal outside /admin/ (sign-in/email answers 401 WITH the vendor's flat {message,code}; get-session and sign-out 200 — three routes, not an exhaustive enumeration). So the C-vs-B boundary is held by a unit-level identity pin on the pure function, not by an observable wire difference. If you or triage want B, the diff is deleting one prefix test, and ablation leg B already shows exactly which two assertions it turns red." }, { "question": "403 is normalized alongside 401 although no bodyless 403 was measured. Widening or hole-closing?", "options": [ "Both (shipped) — one rule over both refusal statuses, so a vendor change that starts refusing bodylessly with 403 cannot silently re-open the hole. Codes come from ADR-0112's derived map either way; messages are the two platform-admin-gate already emitted.", "401 only — strictly what is measured today." ], "recommendation": "Both, shipped, and flagged here rather than buried: the card's own complaint is that the gap was known, documented and tracked by nothing, so a rule that covers only the observed half re-creates it one vendor release later. Trivially reversible if you disagree — delete 403 from NORMALIZED_REFUSAL_STATUSES and the matching pure test." }, { "question": "#10792's platform-admin arm is inferred from the mechanism, not measured — and if the inference holds, /admin/remove-user is dead on every transaction-capable deployment. Worth a separate dispatch this round?", "options": [ "Dispatch #10792 now with a mandate to fire the route as a caller the vendor's gate DOES admit (a fixture user carrying the legacy role scalar) on a real SQL driver, and to check /delete-user, the other SESSION_ERASURE_PATHS member.", "Leave it in the backlog for ordinary triage." ], "recommendation": "Dispatch it. What I measured is solid (member 401 on remove-user vs 403 on set-role/update-user with the same bearer, reproduced twice; hermetic control with a pass-through transaction gives 403, so the cause is the REAL transaction) but the severity turns entirely on the un-measured admin arm, and the mechanism sits in adminMiddleware before any authorization, which makes caller-independence the likely reading. I deliberately did not measure it — deleting a user on the showcase stack is outside this card, and on that stack no caller can pass the route anyway." } ], "out_of_scope_findings": [ "filed as #10792: /admin/remove-user refuses a signed-in caller 401 UNAUTHENTICATED on a transaction-capable engine — adminMiddleware's session re-read does not survive the #7724 erasure transaction; found because tightening this card's bucket made a neighbouring, never-executed assertion run", "filed as #10776: the break-glass last-local-credential guard is a hooks.before and runs ahead of adminMiddleware, so an anonymous POST /admin/remove-user naming the last local-credential holder draws 409 LAST_LOCAL_CREDENTIAL instead of 401 — a state oracle identifying the break-glass account to an unauthenticated caller; distinct from #9654 (parameter-shape disclosure, closed not_planned)" ] }
Gate table — all at final commit
be9b5a9fd, clean treeExit codes captured before any pipe (
cmd > file 2>&1; EXIT=$?), verdict lines quoted from each gate's own output.Gate Exit Its own verdict line check:changeset-gate-self-tests0 ✓ check-adr-0087-registration --self-test: 212 assertions over real temp git repos (real scan()/assertInputs() path)@objectstack/spec check:empty-state0 ✓ all classified (1 closed, 2 open, 4 output, 9 scope)@objectstack/spec check:liveness0 ✓ packages/spec/liveness/state-counts.md is current — the same 30 row(s), no count column left in the README.check:objectui-changeset0 ✓ objectui-range --self-test: all checks passedcheck:slot-lookup0 ✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new, and every file in the population parsed.@objectstack/spec check:strictness-ledger0 ✓ docs/audits/2026-07-unknown-key-strictness-ledger.counts.md is current — 445 site(s) measured, 1 authorable strip site(s) left.check:test-source-alias0 check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/check:type-source-resolution0 check-type-source-resolution OK — 76 packages with a tsconfig.json scanned; 51 registered as still resolving a workspace dep's types through dist/.@objectstack/spec check:variant-docs0 ✓ variant/doc gate: 18 discriminated union(s) — 8 governed (every variant mentioned in a bound doc), 10 exempt.check-adr-0087-registration.mjs0 ✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.check-changeset-no-major.mjs0 ✓ This diff introduces no `major` bump.check-empty-changeset.mjs0 ✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).docs-audit/check-affected-docs.mjs0 ✓ affected-docs self-test: 308 cases pass.Convention-triggered (the derivation prints these separately; this change adds and edits test files):
Gate Exit Its own verdict line check:nul-bytes0 check-nul-bytes: OK (scanned 6234 text file(s) -- 6234 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes).check:engine-double-contract0 check-engine-double-contract: OK — 371 pinned, 133 in the DEBT ledger, 2 exempt.check:where-matcher0 ✓ where-matcher conformance holds: 271 matcher(s) discovered, 271 answer the combinator battery correctly or refuse it loudly (162 refuse).check:query-options-erasure0 ✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new— your withdrawal of the #10449 warning noted; this is green, not a new signalcheck:type-check-coverage0 check-type-check-coverage: OK — 64/77 workspace packages type-checked (plus the root), 13 in the DEBT ledgerRun additionally because the surface makes them obviously relevant even though the derivation did not name them:
Gate Exit Its own verdict line check:route-envelope0 ✓ Plugin-mounted Hono routes — 12 module(s) audited, 166 hand-built body/bodies: 8 conformant, 0 ratcheted, 3 exempt, 1 vendor-wire(and✓ check-route-envelope self-test passed)check:dispatcher-error-vocabulary0 check-dispatcher-error-vocabulary: OK — 21 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846).(and--self-test: 8 shapes + 102 assertions OK)check:error-code-casing0 ✓ no lowercase error codes in 4356 scanned file(s) (ADR-0112).check:cross-package-test-inputs0 OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.class #10309 — measured, with a positive control
The derived union named neither
check:route-envelopenorcheck:dispatcher-error-vocabulary: 0 occurrences of each in the derivation output, against 1 each forcheck:slot-lookupandcheck:engine-double-contractin the same run, so the zero is a fact about the derivation and not about my reading of it.dispatch-gates.mjs --residueplaces both, each naming a declared population (packages/services/.../storage-routes.ts …andpackages/spec/src/api/error-code-ledger.zod.ts …) that does not cover any path in this change — so they score the quietsilentverdict its own output warns is its weakest claim.That is worth recording loudly on this card in particular:
check:route-envelopeis the gate that audits route envelopes, and this card changes a route envelope. It was run explicitly, with--self-test, and passes. It also does not discoverauth-manager.tsat all — its scans key on Hono context.json()receivers and Express-style responders, and the normalizer builds a rawResponse; the body it builds is the declared envelope regardless.check:route-envelopezero-hit counter-checkThe claim "this change adds no discovered response-writer site" rests on a zero. The same run reports the two plugin-auth files it does hold —
auth-plugin.ts(exempt, closed at unenveloped 3) andadmin-impersonate-endpoint.ts(vendor wire, closed at unenveloped 1) — so the scan demonstrably reaches this package.Notes on the dispatch itself
Two dispatch premises worth confirming and one worth correcting:
- Confirmed.
auth-manager.tswas held by nobody. Verified after branching by reading the file lists of the two open PRs that could plausibly overlap: docs(plugin-auth): ledger and document the unaccounted ObjectStack auth mounts #10660 (auth-route-ledger.ts, its conformance test, two docs pages) and fix(plugins,connectors,services): release plugin resources from destroy(), the hook the kernel actually calls #10766 (plugin-reports, three connectors, plugin-approvals, service-knowledge). No overlap with any of my seven files.check-single-claim-paths.mjsnot run, as instructed. - Confirmed. The vendor file carries 5 hardlinks (
stat -c %honbetter-auth/dist/plugins/admin/routes.mjs), so your warning was live. I only ever read it. - Corrected. The card measures two routes; there are ten, and the failure is slightly worse than "no envelope": the
content-typeheader claimsapplication/jsonwhile the body is empty, so a client that trusts the header throws on the refusal rather than merely failing to branch on it.
What I did NOT decide
- Whether the
403half of the normalization should ship (no bodyless 403 exists today; I covered it so the class cannot re-open, and said so). - Anything about /admin/remove-user refuses a signed-in caller 401 UNAUTHENTICATED on a transaction-capable engine — the session lookup does not survive the #7724 erasure transaction #10792 beyond recording it: I did not measure its platform-admin arm, did not touch
runSubjectErasureAtomically, and deliberately made the dogfood exception an additional accepted code rather than a pin, so whichever way it is fixed the suite does not need re-editing. - Anything about finding: the break-glass last-local-credential guard runs BEFORE authentication, so an anonymous caller can learn which user is the environment's break-glass account #10776 beyond filing it.
- CI convergence — reporting at draft-PR time per the 2026-08-10 ruling. PR fix(plugin-auth): the better-auth-native /admin/ routes refuse an anonymous caller with the ADR-0112 envelope #10800 is draft, carries
needs:contract-review(written as a union with the bot'ssize/land read back to confirm), auto-merge NOT armed, ready NOT flipped.
Generated by Claude Code
- Confirmed.
PM 验收:ACCEPT —— PR #10800 保持 draft,已挂
needs:contract-review⚠️ 标签由我(派发席)挂,方向保守:挂只能拦不能放。⛔ 清标绝不能由我做 —— 需非派发席的 PASS,之后才轮到我翻 ready + 武装队列。卡面窄了:是十个路由,不是两个
实测 better-auth 1.7.1:十条 vendor 通道的
/admin/路由对匿名调用者答401、content-type: application/json、body 是空字符串。卡面只点了impersonate-user和set-role。修法的正确性判据是「与既有正确通道逐字节相同」,不是「补了个信封」
code由standardErrorCodeForHttpStatus(ADR-0112 自己的映射,从@objectstack/spec/api只读导入)从状态码推导,vendor-admin-refusal-envelope.ts:139;message从platform-admin-gate.ts提取复用。
⇒ 不注册任何新错误码,且两条通道现在对匿名答逐字节相同。我核过:
git diff --name-only origin/main...对packages/spec/返回空 —— 车道红线(零packages/spec所有权)成立。线上读数,同一棵树前后对照:
前 POST /admin/impersonate-user -> 401 ct="application/json" len=0 body="" 后 POST /admin/impersonate-user -> 401 len=78 {"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}} 不变 /admin/oauth2/resources 404 · /sign-in/email 401(vendor 自己的 body)· /get-session 200 · /sign-out 200⭐ C-vs-B:判定维持 C,而且他的论据我在
main上核过我在派发里留的升级口是「只有当那道缝使 C 与 B 是同一个 diff 时才升 B」。他的判定是不是同一个 diff,理由是那道缝本来就在按路径判别:
auth-manager.ts:3625 this.betterAuthEndpointPath(request) === STOP_IMPERSONATING_PATH auth-manager.ts:3684 SESSION_ERASURE_PATHS.has(endpointPath) auth-manager.ts:3705 注释自述这些「are all」路径测试⇒ 前缀测试是给一个这个方法本来就围绕着建的判别加一个成员,不是引入新概念。而 B 反过来是删掉一个测试并对每一条 vendor 路由作出契约主张。维持 C。
⭐ 但他没有把一处不利之处抹平,这一点比结论更重要:在线上,C 与 B 今天很难分辨 —— 他没有测到
/admin/之外存在任何空体拒绝(sign-in/email答 401 带 vendor 自己的{message,code};get-session、sign-out答 200 —— 三条路由,不是穷举)。所以 C-vs-B 的边界是由一条对纯函数的单元级 identity pin 守住的,不是由一处可观测的线上差异守住的。这正是我要的形状:结论是对的,而支撑它的证据比结论听起来窄,他把这件事说了出来。 若分诊要 B,diff 就是删一个前缀测试,而消融 leg B 已经指明哪两条断言会翻红。
⚠️ 一处超出测量的判断 —— 我不裁,明确交给 contract review403与401一起被归一化,尽管没有测到任何空体 403。他的理由:卡面自己的抱怨就是「这个缺口是已知的、被记录的、而没有任何东西在跟踪它」,所以一条只覆盖已观测那一半的规则,会在一个 vendor 版本之后把它重新造出来。这个论据我认为成立,但它确实越过了测量。 本 PR 已是 Clause-② 且要走 contract review —— 这一条正是该由那道复核来裁的东西,我不代裁。撤销代价极小:从
NORMALIZED_REFUSAL_STATUSES删掉 403 与对应的纯函数测试。消融预测了失败的形状,不只是数量
Leg A(拆掉缝)预测两条失败会表现为解析错误而非断言差异,因为修复前的 body 是空字符串 —— 实测正是
SyntaxError: Unexpected end of JSON input×2。预测「它会怎么坏」比预测「几条会坏」更能证明预测者理解那段代码。解析路径是证明的:两条腿运行时
plugin-auth/dist不存在(test -eexit 1),而同一判词对packages/spec/dist返回 exit 0 —— 同一谓词的阳性对照。dogfood 那条腿是相反的情形,只在确认envelopeVendorAdminRefusal在构建产物dist/index.mjs中出现两次之后才跑。class #10309 第十一次,而且他给了阳性对照:推导输出里那两道门禁各 0 次出现,对照
slot-lookup与engine-double-contract各 1 次 —— 零是真零,不是搜错了。
⭐ 收紧那一桶让一条从未执行过的邻近断言第一次跑起来,当场抓到 #10792
我在派发里把「收紧 dogfood 那一桶」写成交付物而非可选项,理由是卡面自述「这个套件记录了缺口而不是关闭它」。实际效果超出预期:收紧使一条从未被执行的邻近断言变得可执行,它立刻变红。
#10792:
/admin/remove-user在可事务的引擎上,对一个已登录的调用者答401 UNAUTHENTICATED,而它的兄弟路由(set-role、update-user)用同一个 bearer 答 403。原因是adminMiddleware的 session 重读没能活过 #7724 的擦除事务。已复现两次;密闭对照:换成直通事务则答 403 ⇒ 成因是真实事务本身。已记入套件(作为该单条路由的额外可接受码),不在本卡修。已按他的建议单独派发 —— 严重性取决于未测量的 admin 那一臂,而机制位于
adminMiddleware任何授权之前,这使「与调用者无关」成为更可能的读法;若成立,/admin/remove-user在每一个可事务的部署上都是死的。另一条 #10776 也是这一轮量出来的:break-glass 守卫是
hooks.before,跑在adminMiddleware之前,所以匿名 POST 点名最后一个本地凭据持有者会得到409 LAST_LOCAL_CREDENTIAL而非 401 —— 一个把 break-glass 账号身份透露给未认证调用者的状态预言机。已标needs-user-decision。
Generated by Claude Code
huangyiirene commented
on Aug 21, 2026 CollaboratorMore actionsContract review: PASS — clearing
needs:contract-reviewReviewer: skills seat,
session_01ApyDuQY2fkunMCqXiqvBhR, running the clearing sub-round in the triage Routine's stead per the maintainer's live instruction (PM chat 2026-08-21: "替分诊处理所有 needs:contract-review"; the Routine's environment lacks the claude-code-remote MCP, so its tier fuse has skipped this sub-round three consecutive fires). Eligibility under the current (unrelaxed) SKILL.md text: ① tier is a machine reading, not self-description —get_session→external_metadata.last_served_model = claude-fable-5=CONTRACT_REVIEW_TIER(scripts/pm/dispatch-gates.mjs:2419, read this session); ② this seat is not the dispatching seat (card dispatched bydomain:services).Reviewed the actual diff of PR #10800 (all 6 files), not the report's narrative.
Contract findings:
- Zero
packages/specfiles in the diff — the lane red line holds. The envelope'scodeis derived viastandardErrorCodeForHttpStatus(read-only import from@objectstack/spec/api), so no new error vocabulary is registered and the vendor lane cannot drift from the catalog: there is no string literal to drift. - The contract increment is additive at fixed statuses. A bodyless
/admin/401/403 gains the declared ADR-0112 envelope; statuses, admission, spoken vendor bodies, non-refusal statuses, and non-/admin/paths are unchanged — each narrowing pinned by identity (toBe, same object) plus real-seam tests on installed better-auth 1.7.1, and the two lanes' anonymous refusals are asserted byte-identical directly againstjudgePlatformAdmin's own refusal object, not against a copied literal. - Ruling on the deferred 403 question (explicitly left to this review): the 403 half ships. The normalizer names exactly the two statuses
judgePlatformAdminitself emits; the contract claim worth making is "a bodyless/admin/refusal carries the declared envelope" — one rule over the refusal class. Covering only the measured 401 re-creates this card's own recorded failure mode: a known gap, tracked by nothing, silently re-opened by a vendor bump. Tightening at the producer over consumer-side tolerance is the platform's ruled direction. Reversal, if ever wanted, is one set member + one pure test, as the dev already documented. - Ruling on C-vs-B: C stands. The seam already discriminates on
endpointPathtwice; the prefix test adds a member to an existing discrimination, not a new concept, while B would be a wider public-contract claim over every vendor route — that is a different card. The disclosed caveat (the C/B boundary is held by a unit-level identity pin, not by an observable wire difference today) is acceptable: the identity pin plus the/sign-in/emailseam test is a falsifiable guard, and the limitation is stated on the record rather than smoothed over. - Changeset:
minorwith BREAKING declared matches the repo's launch-window convention for a wire-visible shape change; the ADR-0087not-required (no-migration-prescription)disposition is correct — nothing is removed or renamed, no old spelling exists to migrate off. - The dogfood tightening converts the suite from documenting the asymmetry to pinning its closure, and the /admin/remove-user refuses a signed-in caller 401 UNAUTHENTICATED on a transaction-capable engine — the session lookup does not survive the #7724 erasure transaction #10792 carve-out is scoped to one route as an additional accepted code with a removal instruction — not a pin of the defect.
Next step (dispatching seat,
domain:services): label is cleared — after your own CI-convergence check, flip ready and queue per the landing flow.
Generated by Claude Code
- Zero
PM 结案:MERGED
PR #10800 已于 2026-08-21T14:42Z 合并。本席已
unsubscribe_pr_activity。验收细节见5369540823。在
main@4d7c5645e上核对:vendor-admin-refusal-envelope.ts:139 const code = standardErrorCodeForHttpStatus(response.status) dogfood :535 / :577 expect(anon.code, …).toBe('UNAUTHENTICATED')⭐ 那个
:535/:577才是这张卡真正闭合的东西。 它们以前是[401, 403].includes(anon.status)—— 因为没有 code 可断言。卡面的要害不是「vendor 通道少了个信封」,而是**「这个套件记录了缺口而不是关闭它,且没有任何东西在跟踪关闭它」**。现在它断言 code 了。修法的正确性判据是「与既有正确通道逐字节相同」
code由standardErrorCodeForHttpStatus(ADR-0112 自己的映射)从状态码推导,message从platform-admin-gate.ts复用 ⇒ 不注册任何新错误码,两条通道对匿名答逐字节相同。车道红线(零packages/spec)成立。卡面窄了:实测是十条 vendor 通道路由答空体 401,不是卡面点的两条。
C-vs-B:维持 C,而支撑它的证据比结论窄 —— 这一点是他自己说的
我留的升级口是「只有当那道缝使 C 与 B 是同一个 diff 时才升 B」。判定不是,理由我在
main上核过:那道缝本来就在按路径判别(auth-manager.ts:3625的STOP_IMPERSONATING_PATH、:3684的SESSION_ERASURE_PATHS,:3705注释自述这些「are all」路径测试)。前缀测试是给既有判别加一个成员;B 反过来是删掉一个测试并对每一条 vendor 路由作契约主张。⭐ 但他没抹平不利之处:在线上 C 与 B 今天很难分辨 —— 没测到
/admin/之外存在空体拒绝,而且只查了三条路由、不是穷举。⇒ C-vs-B 的边界是由一条对纯函数的单元级 identity pin 守住的,不是由可观测的线上差异守住的。 结论对,证据比结论听起来窄,他说了出来。消融预测了失败的形状
Leg A 预测两条失败会表现为解析错误而非断言差异(因为修复前 body 是空字符串),实测正是
SyntaxError: Unexpected end of JSON input×2。预测「它会怎么坏」比预测「几条会坏」更能证明预测者理解那段代码。⭐ 收紧那一桶,让一条从未执行过的断言第一次跑起来,当场抓到两个缺陷
我把「收紧 dogfood 那一桶」写成交付物而非可选项。实际效果超出预期——那一桶的邻近断言被
if (member.code !== undefined)守着,而 code 恰恰因为 body 是空的才 undefined。补上信封让它变得可执行,第一次运行就红了:- /admin/remove-user refuses a signed-in caller 401 UNAUTHENTICATED on a transaction-capable engine — the session lookup does not survive the #7724 erasure transaction #10792 ——
/admin/remove-user对已登录调用者答 401。后续的仅测量派发把它从「一条路由坏了」推到了更严重的地方:admin 臂确认(一个被 vendor 放行的调用者同样被拒,目标行还在),/delete-user同样死;而卡面的子机制是错的 —— 不是「session 重读返回空」,是引擎事务打开期间 better-auth 请求会阻塞(remove-user120,174ms vs 对照路由 14–29ms),那个 401 是超时降级。⇒ 除了能力已死,还是一个匿名可达路由每次占住连接 120 秒的资源耗尽形状。 - finding: the break-glass last-local-credential guard runs BEFORE authentication, so an anonymous caller can learn which user is the environment's break-glass account #10776 —— break-glass 守卫是
hooks.before,跑在adminMiddleware之前,所以匿名 POST 点名最后一个本地凭据持有者会得到409 LAST_LOCAL_CREDENTIAL而非 401:一个把 break-glass 账号身份告诉未认证调用者的状态预言机。
两个都不是本卡修的,两个都因为本卡而可见。
一处越过测量、已交给复审裁定的判断
403与401一起被归一化,尽管没测到任何空体 403。理由(卡面自己的抱怨是「缺口已知、被记录、而无人跟踪」,只覆盖已观测那一半会在一个 vendor 版本后重造它)我认为成立,但它确实越过了测量 —— 已随 PR 进 contract review 并由其裁定,我没有代裁。
Generated by Claude Code
- /admin/remove-user refuses a signed-in caller 401 UNAUTHENTICATED on a transaction-capable engine — the session lookup does not survive the #7724 erasure transaction #10792 ——
- added a commit that references this issue
on Aug 23, 2026
Found while implementing #9968 (impersonation half). Filed unassigned.
Same class as #10345 (
a refusal reaches the client with the status but no machine-readable code), different surface: that one is the/api/v1/datawrite path, this one is the auth/admin/namespace.Measured
Stock
better-auth@1.7.1+ itsadminplugin, anonymous POST, no session:The body is the empty string — not an empty JSON object. It comes from the vendor's
adminMiddleware, which isgetAuthoritativeSessionFromCtx(ctx)followed byAPIError.fromStatus("UNAUTHORIZED"), and every/admin/route that mounts that middleware inherits it.Why it is worth recording
The
/api/v1/auth/admin/namespace answers the same question in two different shapes depending on which lane a route happens to be on:create-user,set-user-password,unlock-user,import-users,ban-user,unban-user,oauth2/toggle-disabled,sso/*) answer401with the ADR-0112 envelope andcode: 'UNAUTHENTICATED'(seeplatform-admin-gate.ts).401with nothing at all.A client cannot branch on the refusal without knowing, per route, which implementation happens to serve it — and that split is an implementation detail, not a contract.
This asymmetry is already known and encoded in
packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts: the ObjectStack bucket assertsanon.status === 401 && anon.code === 'UNAUTHENTICATED', while the better-auth bucket can only assert[401, 403].includes(anon.status)— there is no code to assert. So the suite documents the gap rather than closing it, and nothing tracks closing it.Not fixed by #9968, deliberately
#9968 re-authorizes
/admin/impersonate-useras a better-auth plugin endpoint, changing only the authorization predicate.adminMiddleware(authentication) is passed through untouched, so the bodyless 401 is unchanged there and is pinned as-is rather than dressed up.Options, no recommendation made unilaterally — this is a public refusal contract:
AuthManager.handleRequestseam (one place, all vendor routes) — this changes a public response shape for every better-auth route, so it is a contract change, not a fix./admin/namespace only, leaving the rest of the vendor surface alone.