Skip to content

The better-auth admin family is absent on any composition that does not enable the admin plugin, and nothing on the wire says so — 404, identical to a path that never existed #15920

Description

@os-warren

Split out of objectstack-ai/cloud#2526, whose triage recorded this split criterion in advance so the taker would not have to block on it. objectstack-ai/cloud#2526's other half is addressed by PR #15918; this is the part that is a composition question rather than a framework one.

What was measured

Framework-side boot (@objectstack/verify + the showcase app, packages/qa/dogfood), origin/main at abdceef8c, member and platform-admin sessions, both configurations of the same stack:

better-auth admin plugin OFF — the stock composition:

auth.api /admin/ endpoints: 9  (all /admin/oauth2/*, all SERVER_ONLY: true)

POST /api/v1/auth/admin/update-user   member -> 404  len=0  ct=(none)
GET  /api/v1/auth/admin/list-users    member -> 404  len=0  ct=(none)
POST /api/v1/auth/admin/set-role      member -> 404  len=0  ct=(none)
POST /api/v1/auth/admin/definitely-not-a-route-1989  member -> 404  len=0  ct=(none)   <-- CONTROL

better-auth admin plugin ON (OS_SCIM_ENABLED=true, which forces admin on via ADR-0071 — the one env knob that reaches it):

auth.api /admin/ endpoints: 24

POST /api/v1/auth/admin/update-user   member -> 403  {"message":"You are not allowed to update users","code":"YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS"}
GET  /api/v1/auth/admin/list-users    member -> 403  {"message":"You are not allowed to list users","code":"YOU_ARE_NOT_ALLOWED_TO_LIST_USERS"}
POST /api/v1/auth/admin/set-role      member -> 403  {"message":"You are not allowed to change users role","code":"YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE"}

The finding, and what it is NOT

The three routes are configuration-dependent, exactly as designed. ⛔ The ledger is not wrong, and no row should be deleted. BETTER_AUTH_MOUNTED_SURFACE is pinned at the maximal LEDGERED_PLUGIN_CONFIG and its own header already says so in as many words — it answers "what does the catch-all expose", it is publication and not liveness, and "a deployment running fewer plugins serves a subset; that is correct". Removing a published entry would also make the exact-equality conformance test red and misreport the mounted attack surface, which is the one thing that list exists to keep honest.

What is left over is a real gap, and it is narrower than objectstack-ai/cloud#2526 read it:

On a composition that does not enable the admin plugin, three routes the SDK can build URLs for answer 404 with a zero-length body and no content-type — byte-identical to a path that never existed. A caller cannot tell "this deployment does not mount that family" from "you typed the route wrong" from "upstream renamed it". The ledger knows the difference — AUTH_ROUTE_LEDGER carries requires for gated families precisely so a subset deployment is describable — but nothing on the wire carries it.

That matters most where it is least visible: better-auth is a third-party dependency on its own release cadence, and this repo has already chased 1.7 drift in #3624 / #3647. A renamed upstream admin route and an unmounted admin family are the same 404 to every caller.

Prior art worth reading before acting

What this is not

Not a privilege leak, and objectstack-ai/cloud#2526 disproves that itself: the platform admin receives the same 404, and nothing an ordinary member sent changed any row. Not a request to narrow the catch-all either — objectstack-ai/cloud#2526's triage is explicit about why that is the wrong move, and PR #15918 keeps the mount exactly as wide as it is.

Open question for triage

Does an unmounted-by-configuration route deserve a distinguishable answer, and if so where does it come from — a requires-aware answer from the ledger, an ObjectStack-side envelope on the auth namespace's 404, or nothing at all because a 404 discloses correctly and the SDK is what should stop offering the family? The third reading is live: vendor-admin-refusal-envelope.ts already considered enveloping 404 on this surface and deliberately excluded it ("a 404 discloses nothing that needs a code"). Re-opening that narrowing is a maintainer call, not a lane decision — which is why this is filed rather than fixed.

Filed with no assignee by the seat that took objectstack-ai/cloud#2526.

Activity

  1. os-warren commented on Sep 5, 2026

    @os-warren
    CollaboratorAuthor

    A measurement that bears on this card's premise, recorded before anyone builds on it

    From the Clause-② review of PR #15918, found while checking that PR's blast radius rather than this card:

    ⚠️ BETTER_AUTH_MOUNTED_SURFACE counts the nine SERVER_ONLY /admin/oauth2/* rows as "published", though better-call never routes them.

    That matters here because this card is about the gap between what the ledger declares and what the wire serves. If the ledger's own notion of "published" already includes endpoints that are structurally unroutable, then ⇒ the declared-vs-served gap this card measures is partly a property of the ledger's counting rule, not only of the composition. Anyone taking this card should settle what SERVER_ONLY means to that surface before treating a declared-but-absent row as evidence of a deployment difference.

    The SERVER_ONLY reading itself is measured and firm: exactly nine /admin/oauth2/* endpoints, all nine carrying SERVER_ONLY: true at both stock and maximal config, with the unrouted-404 signature on the wire, and the dogfood sweep already classifying them not-mounted. ⭐ Worth noting how that number was established: PR #15918's author first claimed those nine were protected examples, and the end-to-end boot corrected it — a claim about which routes exist that only booting the thing could settle.

    ⛔ None of this says the ledger is wrong, and objectstack-ai/cloud#2526's own half-2 measurement concluded the opposite: the three declared-but-absent routes are configuration-dependent (absent with the admin plugin off, present and answering real vendor 403s with it on), BETTER_AUTH_MOUNTED_SURFACE is pinned at the maximal config, and its own header already says a smaller deployment serving a subset is correct. No ledger row was deleted. What is recorded here is narrower: the counting rule includes rows better-call cannot route, and that is a distinct question from whether a deployment serves a subset.

    Also relevant and filed separately: #15928 — the @objectstack/hono adapter's /auth/* mount carries the same unconditioned yield PR #15918 fixes in the plugin.


    Generated by Claude Code

  2. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊:domain:services / enhancement + finding / needs-user-decision / priority:p3

    ⛔ needs-user-decision 不与 pm:* 并存(先例 #15854 / #15617 / #15542)。

    Lane 判据

    AUTH_ROUTE_LEDGER / BETTER_AUTH_MOUNTED_SURFACE 的主落点在 packages/plugins/plugin-auth(src/auth-manager.ts、src/auth-plugin.ts),按 lane 表(plugin-auth / security / sharing / audit)⇒ domain:services。

    ⚠️ 该词表在 packages/client(auth-route-ledger-coverage.test.ts、client-url-conformance.test.ts、route-ledger-response-schema.test.ts)与 packages/cli(utils/console-route-ledger.ts)也有消费方。⇒ 若裁决走「让答案带上 requires 信息」那一支,修复会跨到 domain:cli;到时请由承接席补挂第二个 lane。本席现在只挂主落点。

    ⚠️ 本席未复现卡片的驱动实测(两种组合各一次 boot、9 vs 24 个 /admin/ 端点、成员与平台管理员两种会话下的 404/403 表)。那需要起一台带 OS_SCIM_ENABLED=true 的栈。⛔ 不要把本席的 lane 定位当成对那张表的复现继承下去。


    ⭐ 这张卡最值得记名的地方:它否掉了自己父卡的一半

    卡片开门就把「账本错了」这个最容易的结论排除掉,且理由是账本自己的档头:

    ⛔ The ledger is not wrong, and no row should be deleted. BETTER_AUTH_MOUNTED_SURFACE is pinned at the maximal LEDGERED_PLUGIN_CONFIG and its own header already says so in as many words — it answers "what does the catch-all expose", it is publication and not liveness, and "a deployment running fewer plugins serves a subset; that is correct".

    ⇒ 「发布面」与「活性」是两个不同的问题,而这张账本回答的是前者。删掉一行不但不修任何东西,还会让精确相等的一致性测试变红,并误报被挂载的攻击面——那正是这份清单存在的唯一意义。

    ⭐ 同样值得记名的是它把不是什么写了两遍:

    ⇒ 一张安全形状的卡,主动把两个最抓眼球的结论都排除掉,只留下真正剩下的那一点。 本席背书,并据此不挂 security——剩下的是一个可发现性问题,不是一个安全缺陷。


    剩下的那一点,本席复述以确保不被稀释

    在一个未启用 admin 插件的组合上,三条 SDK 能构造出 URL 的路由回答 404,零长度 body,无 content-type —— 与一条从不存在的路径逐字节相同。调用方无法区分「这个部署不挂载这个家族」「你把路由拼错了」「上游改名了」。账本知道区别(AUTH_ROUTE_LEDGER 为受门控家族携带 requires,正是为了让子集部署可被描述),但线路上什么都没带。

    ⭐ 而卡片指出它最不显眼处最要紧:better-auth 是第三方依赖、自有发版节奏,本仓已经追过 1.7 的漂移(#3624 / #3647)。一条被上游改名的 admin 路由,和一个未挂载的 admin 家族,对每一个调用方都是同一个 404。

    为什么是决定箱

    卡片把待答问题写成了一个明确的三选一,且指出第三支有活证据:

    Does an unmounted-by-configuration route deserve a distinguishable answer, and if so where does it come from — a requires-aware answer from the ledger, an ObjectStack-side envelope on the auth namespace's 404, or nothing at all because a 404 discloses correctly and the SDK is what should stop offering the family? The third reading is live: vendor-admin-refusal-envelope.ts already considered enveloping 404 on this surface and deliberately excluded it ("a 404 discloses nothing that needs a code"). Re-opening that narrowing is a maintainer call, not a lane decision.

    ⇒ 决定箱成立:第三支不是「不作为」,它是一条已经被做出过、且写下了理由的决定——重开它需要维护者。⛔ 本席不裁决(本会话 claude-opus-5,CONTRACT_REVIEW_TIER 硬门要求 fable)。

    四facet

    ① 事实 见上;核心是 404 零 body 无 content-type,与不存在的路径不可区分,而账本侧的 requires 已经能描述这个差别。
    ② 分叉 (a) 账本驱动的 requires-aware 应答;(b) 在 auth 命名空间的 404 上加 ObjectStack 侧信封;(c) 不做——404 披露得正确,该停止提供这个家族的是 SDK。
    ③ 各支的代价 (a) 需要把账本的知识送到应答路径上,跨 plugin-auth → 可能到 client/cli;(b) 需要重开 vendor-admin-refusal-envelope.ts 已写下的排除决定;(c) 零成本,但把负担推给每一个 SDK 消费者,且对直接打 HTTP 的调用方毫无帮助。
    ④ 需要裁决者提供的东西 一句话:「按配置未挂载」是否值得一个与「不存在」不同的线路答案?

    ⚠️ 卡片给的两条先例本席原样转达,并提请裁决者先读它们再选:

    定级理由(其余)

    • enhancement 而非 bug:main 上没有东西是假的——404 在「这条路径在这个部署上不可用」这个意义上是正确的;账本也是对的。缺的是区分度。任何修复都是新增线路信息 ⇒ 拓宽已发布行为 ⇒ Feature 侧,人工地板,与决定箱相符。
    • p3:无安全后果(卡片与父卡都证否了)、无数据风险、无运行期故障。损害是诊断困难,且只在部署未启用 admin 插件时出现。⛔ 不降更低:卡片指出的上游漂移场景是真实的,且本仓已经付过一次。

    父卡:#15417(另一半由 PR #15918 处理)。⛔ 本卡是它按预先记下的拆分判据拆出来的组合面问题,不重复。


    ⛔ 本席为 triage 席位:不认领、不派单、不写码、不合并、不裁决 decision-box。


    Generated by Claude Code

  3. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    Ruling recorded — option (c), no wire change (director seat, decision batch #64, 2026-09-07)

    Maintainer reply, verbatim: 「同意」 (all five batch #64 recommendations adopted).

    Ruling. A route that is unmounted because the deployment does not enable the better-auth admin plugin keeps answering a plain 404. The 404 discloses correctly ("this path is not served here"); the deliberate exclusion recorded in vendor-admin-refusal-envelope.ts ("a 404 discloses nothing that needs a code") stands and is not reopened; #9969 (consumer-less vendor /admin/ routes are not re-implemented) is not reopened. The place to ask "does this deployment mount the admin family" is the discovery endpoint, and an SDK caller should consult it before building those URLs. AUTH_ROUTE_LEDGER's requires rows stay as the publication record; no row is deleted.

    One conditional follow-up, not filed blind: if the discovery surface (spec/api/discovery.zod.ts and its route) does not report which auth families are mounted, that is a small domain:cli card — the first seat that measures it files it with the reading.

    Labels: needs-user-decision removed; closed (not planned). Ledger on #12708 (batch #64).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions