Skip to content

plugin-hono-server 的 registerStandardEndpoints 把「重复供给」和「独家供给」绑在同一个开关上 —— 拆开是退役的前置条件 #4073

Closed

Description

@os-zhuang

⚠️ 本 issue 已订正(见下方评论)。 初版标题与建议是"零非测试消费者,默认翻 false" —— 错的:我当时查的是"谁传这个选项",没查"谁依赖这个默认值"。os serve 正是靠默认值拿到这个面,且其中三个端点没有第二个提供者。照初版执行会打断 console。下文已按证据重写。

按 Prime Directive #10 记录,#4018(静态 discovery 按注册计算,#4063 已合并)实施中发现。#4018 只收口了 discovery 那一块。

真实现象

HonoPluginOptions.registerStandardEndpoints(默认 true)用一个开关同时控制两类性质完全不同的路由:

路由 性质 谁还提供
POST/GET /api/v1/data/:object、GET /data/:object/:id(只有 C+R) 重复供给 @objectstack/rest 的 /data(完整 CRUD + 全部闸门),且因先注册先赢而实际生效
GET /api/v1/discovery、/.well-known/objectstack 重复供给 dispatcher / REST(#4018 后已显式 cede)
GET /api/v1/auth/me/permissions 独家供给 无
GET /api/v1/auth/me/localization 独家供给 无
GET /api/v1/me/apps 独家供给 无

三条独家供给的证据:

  • os serve 靠默认值拿到它们 —— packages/cli/src/commands/serve.ts:1057 是 new HonoServerPlugin({ port }),不传这个选项。
  • console 硬依赖其中两条 —— objectui packages/permissions/src/MePermissionsProvider.tsx:72 的 DEFAULT_ENDPOINT = '/api/v1/auth/me/permissions'(整套 PermissionProvider / ObjectForm / RecordDetailView / 相关列表的 apiOperations 都建在这份 payload 上);apps/console/src/AppContent.tsx:121 取 /api/v1/auth/me/localization。
  • core 把它们当一等公民 —— packages/core/src/security/auth-gate.ts:30 的 ALLOW_SUFFIXES 含 /me/apps、/me/localization,注释写明是"被门禁拦住的用户必须仍能访问,用以补救或引导补救 UI"。
  • grep 确认 packages/rest、packages/runtime 都不挂任何 /me/*。

所以默认值不能直接翻 false —— 那会让 os serve 上的 console 权限层与本地化整个断掉。

仍然成立的部分

「重复供给」那一半的论据不变,而且是这个面反复付税的根源 —— 每条平台级不变量都要在这里再实现一遍,且每次都是事后补的:

修法:先拆,才谈退役

前置步骤(本 issue 的核心):把三条 /me/* 从 registerStandardEndpoints 门里挪出去,让这个开关名副其实地只覆盖重复供给。两条路线:

  • A. 就地拆分 —— 在 plugin-hono-server 内部改为无条件注册(需把 registerDiscoveryAndCrudEndpoints 里的 resolveCtx / getObjectQL / denyAnonymous 提成共享 helper)。小、安全、对 os serve 行为不变。
  • B. 归还真正的 owner —— /auth/me/* 归 plugin-auth(它已经拥有 /api/v1/auth/*),/me/apps 归 REST 或 dispatcher。契合 ADR-0076 D11「单一 owner」,但跨插件,且 /me/permissions 带着约 195 行权限合并逻辑(含 foldWildcardSuperUser)与 objectql/spec 依赖。

拆完之后才是原计划:默认值翻 false → 观察一个 release → 删除 registerDiscoveryAndCrudEndpoints 余下部分。

顺带发现:/auth/me/* 目前是靠加载顺序活着的

plugin-auth 在 kernel:ready 里注册 rawApp.all('${basePath}/*')(auth-plugin.ts:1817),终结式转发给 better-auth、不会 next();hono 的 /auth/me/permissions 也在 kernel:ready 注册。两者谁先,取决于 kernel.use() 顺序(今天是 HonoServerPlugin 先于 AuthPlugin,所以 hono 的具体路由先注册、赢下匹配)。一旦顺序调换,/auth/me/permissions 会落进 better-auth 的 catch-all 拿到 404。

这与 #2567、#4018 是同一类「靠顺序侥幸成立」的问题 —— 也是选 B 的最强论据:让 auth 拥有 /auth/* 下的全部路由,这个碰撞根本不存在。

顺带:三个死常量

hono-plugin.ts 的 DEFAULT_ENDPOINT_PRIORITY = 100 / CORE_ENDPOINT_PRIORITY = 950 / DISCOVERY_ENDPOINT_PRIORITY = 900 声明后全仓零引用。DISCOVERY_ENDPOINT_PRIORITY 尤其误导:它暗示存在一套 discovery 优先级机制,而真实机制是 Hono 的先注册先赢。

相邻的、可独立处理的缝

该面的 discovery payload 是 { version, apiName, routes, capabilities },不满足 DiscoverySchema(缺 name / environment / locale / services)。services 恰是 D12 定的 single source of truth(handlerReady),standalone 客户端因此读不到 D12 的那一半信息。走完退役路线后这条自动消失。

关联:#4018(#4063 已合并)、#2567、#3298、ADR-0076 D11/D12、OQ#9。

Activity

  1. self-assigned this
    on Jul 30, 2026
  2. changed the title [-]plugin-hono-server 的 registerStandardEndpoints 便捷面:默认 true、零非测试消费者,且每条不变量都要在这里补第二遍 —— 建议退役[/-] [+]plugin-hono-server 的 registerStandardEndpoints 把「重复供给」和「独家供给」绑在同一个开关上 —— 拆开是退役的前置条件[/+] on Jul 30, 2026
  3. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    订正记录(标题与正文已按下述证据重写)。

    初版的核心论断是"registerStandardEndpoints 零非测试消费者 → 默认翻 false"。这是错的,而且照它执行会打断生产:我当时 grep 的是"谁传这个选项"(结论没错:只有 4 个测试传),却没查"谁依赖这个默认值"。

    准备动手实施时查到的:

    1. packages/cli/src/commands/serve.ts:1057 —— new HonoServerPlugin({ port }),不传该选项。os serve 走的正是默认 true。
    2. packages/rest、packages/runtime 都不挂任何 /me/* 路由 —— /auth/me/permissions、/auth/me/localization、/me/apps 只有这个块提供。
    3. objectui console 硬依赖其中两条:packages/permissions/src/MePermissionsProvider.tsx:72 的 DEFAULT_ENDPOINT = '/api/v1/auth/me/permissions',apps/console/src/AppContent.tsx:121 的 /api/v1/auth/me/localization。
    4. packages/core/src/security/auth-gate.ts:30 把 /me/apps、/me/localization 放进 ALLOW_SUFFIXES,注释是"被门禁拦住的用户必须仍能访问"。

    即:这个开关把重复供给(/data C+R、discovery)和独家供给(三条 /me/*)绑在了一起,而后者是 console 权限层与本地化的唯一来源。"退役"因此不是翻个默认值,前置条件是先把两类拆开。

    顺带查到一条相邻的隐患(已写进正文):/auth/me/* 落在 plugin-auth 的终结式 catch-all rawApp.all('/api/v1/auth/*') 覆盖范围内,两者都在 kernel:ready 注册,谁赢取决于 kernel.use() 顺序。今天 HonoServerPlugin 先于 AuthPlugin 所以正常,但这是"靠顺序侥幸成立",与 #2567 / #4018 同类。


    Generated by Claude Code

  4. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    前置步骤(路线 A:就地拆分)已随 #4079 合并(43ff598)。

    三条 current-user 端点现在无条件注册,走自己的 kernel:ready hook 且排在被门控那块之前;registerStandardEndpoints 从此只覆盖重复供给 —— 也就是它的名字和文档一直声称的那一半。顺带删了三个零引用的 *_ENDPOINT_PRIORITY 常量。

    CI 16 项全绿,含两个 Dogfood Regression Gate(对"os serve 挂哪些路由"的改动,这是最实的验证)。

    本 issue 保持开启,余下两步现在才是安全的:

    1. registerStandardEndpoints 默认值 true → false(附 FROM → TO changeset;对 os serve 而言这一步只移除重复供给的 /data C+R 与被 cede 的 discovery);
    2. 观察一个 release 后删除 registerDiscoveryAndCrudEndpoints 余下部分,届时那条 DiscoverySchema 不符合(缺 name / environment / locale / services)自动消失。

    另外,实施中发现的那条加载顺序隐患(/auth/me/* 落在 plugin-auth 终结式 catch-all 覆盖范围内,靠 kernel.use() 顺序才赢下匹配)在本次拆分后依然存在 —— #4079 的测试只钉住了 hono 插件内部的注册顺序,跨插件的 hono-先于-auth 没有任何东西保证。它比余下两步更值得优先处理,建议单独立项。


    Generated by Claude Code

  5. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    前置拆分已完成,但下一步(翻默认值)不能照计划走 —— 有实测证据

    前置步骤已经做完了:#4144 采用了路线 A(就地拆分),三条 /me/* 现在无条件注册,hono-plugin.ts:595 的注释直接引了本 issue。所以按计划,下一步就是翻默认值。

    我去做这一步,先验证它是不是计划所说的 no-op。不是。 证据见 #4192。

    实测矩阵

    真实 HonoServerPlugin + 真实 dispatcher + 真实 createRestApiPlugin,真实监听端口,按 serve.ts 的注册顺序:

    REST flag GET /api/v1/data/account
    ✓ ON 401 —— 已挂载,#2567 匿名闸门生效
    ✓ OFF 404 —— 无人接手
    ✗ ON 401
    ✗ OFF 404

    这个面的两半,性质完全不同

    /discovery + /.well-known/objectstack —— 结论成立,可以退役。
    它们靠 registerDiscoveryEndpoints 里的 kernel.hasPlugin(rest|dispatcher) 显式 cede(#4018)。这是一个与注册顺序无关的机制,所以无论 flag 开关,应答的都是 dispatcher 计算出的 payload。两个方向都验证过。

    /data/:object —— 结论不成立。
    它没有 cede 判断。issue 正文里对它的论断是「@objectstack/rest 的 /data……因先注册先赢而实际生效」,hono-plugin.ts:657 的注释也这么写("both of those register during plugin start() — i.e. before this kernel:ready hook — so they already shadowed this handler")。

    真实监听器上不是这样。翻掉默认值不会把这条路径交给 REST,而是让它变成 404。

    这是同一类错误的第二次出现

    本 issue 顶部已经订正过一次:

    初版标题与建议是"零非测试消费者,默认翻 false" —— 错的:我当时查的是"谁传这个选项",没查"谁依赖这个默认值"。

    这次是同构的下一层:查的是"谁声称提供这条路径",没查"真实监听器上谁应答"。 cede 机制(显式、可验证)和先注册先赢(隐式、依赖生命周期顺序)看起来都像"被遮蔽了",但只有前者真的成立。

    建议的修订路线

    1. 先拆开这两半。/discovery 那半已经是显式 cede,实际上已经退役了 —— flag 对它没有影响。可以先把它从 registerDiscoveryAndCrudEndpoints 里摘出去,让这个 flag 名副其实地只剩 /data(与本 issue 当初把 /me/* 摘出去是同一个动作)。
    2. 再回答 /data 的真问题:完整供给的 REST(有 metadata/protocol 服务、真实配置)到底挂不挂 /api/v1/data/:object?test(runtime): 把「谁在服务 /data 与 /discovery」钉成事实,用证据挡住 #4073 的默认值翻转 #4192 的 harness 里 REST 只配了最小服务集,所以它证明的是**「flag 关掉后这些装配里没有东西服务 /data」**,不是「REST 从不服务 /data」。这个区别决定了下一步是"翻默认值"还是"先让 REST 真正接管"。
    3. /data 的两条断言失败时会直接提示该回头重读本 issue。

    顺带确认仍然成立的部分

    正文里"这个面反复付税"的论据没有被动摇 —— #2567、#3298、#4018 三次都是在这里再实现一遍平台级不变量。/data 那条 401 正是 #2567 补的闸门在生效。退役方向是对的,只是不能用"翻默认值"这一步走完。

    关联:#4192(本条的证据)、#4144(前置拆分,已合并)、#4018、#2567。


    Generated by Claude Code

  6. os-zhuang commented on Jul 31, 2026

    @os-zhuang
    ContributorAuthor

    撤回我上一条的阻塞结论 —— /data 那一半我搞错了

    我在上一条评论里说「/data 的前提不成立,翻默认值会让它变 404」,并用 #4192 把这个结论固化成了测试。那是错的。 订正见 #4260。

    错在哪

    #4192 的 harness 用 createRestApiPlugin({}) 配一个 stub 的 objectql 服务。REST 的 CRUD 是从对象注册表生成的 —— 需要真实引擎(driver + 已注册对象)以及自己的 api.api 配置。供给不全时它什么都不挂。那个 404 是关于 harness 的事实,不是关于 REST 的。

    而且证据一直在仓库里:packages/client/src/client.environment-scoping.test.ts 就是用 registerStandardEndpoints: false 启动,断言 GET /api/v1/data/task 返回 200 由 REST 服务,注释写明这正是重点。我当时没找到它。

    按同样方式供给后

    /api/v1/data/task?top=5     flag ON == flag OFF
    /api/v1/discovery           flag ON == flag OFF
    /.well-known/objectstack    flag ON == flag OFF
    

    响应逐字节相同。 两半都是 duplicate supply,翻默认值对任何挂了 REST 或 dispatcher 的宿主都是 no-op —— 所有 composed 部署,含 os serve。

    还有一处细节值得记:/data/task 返回的 404 是 OBJECT_NOT_FOUND,那是引擎的回答(路由通的);路由未命中会是 {"error":"Not found"}。我上次把这两种 404 当成了同一件事。 新测试因此改为直接比对两个位置的响应,不再依赖状态码。

    所以这个 issue 现在的状态

    步骤 状态
    前置:把三条 /me/* 移出 flag ✅ #4144 已合并(路线 A)
    证据:flag 覆盖的确实只剩 duplicate supply ✅ #4260(本次订正)
    翻默认值 true → false 已解除阻塞,待决策
    观察一个 release 待
    删除 registerDiscoveryAndCrudEndpoints 待

    翻之前唯一需要决定的事

    对 composed 宿主是 no-op(已证)。但对只挂 HonoServerPlugin、既无 REST 也无 dispatcher 的裸宿主,翻默认值会真的拿走 /data 与 /discovery —— 而正文写得很清楚,"The flag exists for a bare host that mounts neither"。

    所以这不是技术问题而是 API 决策:裸宿主应该继续默认拿到这个便利面,还是应该显式 opt-in? 我没有替这个 issue 做这个决定 —— 需要一个 major/minor changeset 和明确的 FROM → TO。定了方向我可以直接实施。

    三次答错同一个问题

    这个 issue 现在有一条挺完整的记录,值得留着:

    1. 查「谁传这个选项」,没查「谁依赖这个默认值」 —— 正文顶部的订正;
    2. 查「谁声称提供这条路径」,没查「真实监听器上谁应答」 —— test(runtime): 把「谁在服务 /data 与 /discovery」钉成事实,用证据挡住 #4073 的默认值翻转 #4192,我;
    3. 查「一个能启动的最小 harness」,没查「一个被真实供给的运行时」 —— test(runtime): 订正 #4073 的证据 —— 翻默认值对 composed 宿主是 no-op(推翻我在 #4192 的结论) #4260,还是我。

    「谁在服务这条路径」始终是一个关于 composed 且 provisioned 的运行时的问题。 能 boot 的 harness ≠ 能回答这个问题的 harness。


    Generated by Claude Code

  7. os-zhuang commented on Jul 31, 2026

    @os-zhuang
    ContributorAuthor

    第 1 步已落地:默认值翻 false(#4280,623e555)

    决策:裸宿主改为显式 opt-in —— 从平台长期合理性(D11 单一 owner、这个面三次重复付税 #2567/#3298/#4018、次等供给不该默认在场)与降低 agent 出错率(grep 真相 = 运行时真相、显式组合优于默认魔法、缺席要响亮)两个角度定的,权衡记录在 #4280。

    落地内容:

    验证:plugin-hono-server 155/155(含新 pin)、runtime 挂 hono 套件 23/23、client 16/16、http-conformance 46/46、CI 17/17。

    剩余步骤

    步骤 状态
    前置拆分(/me/* 出门) ✅ #4144
    证据(composed 宿主 byte-identical) ✅ #4260
    默认值翻 false ✅ #4280
    观察一个 release ⏳ 从下一个 release 起算;关注点:外部裸宿主对 boot warn 的反馈
    删除 registerDiscoveryAndCrudEndpoints + flag + 三个死优先级常量 待观察期结束 —— 插件回归纯传输适配器(ADR-0076 D11),顺带把 §「三次答错」沉淀的设计规则补进 AGENTS.md

    Generated by Claude Code

  8. os-zhuang commented on Jul 31, 2026

    @os-zhuang
    ContributorAuthor

    退役完成 —— #4296(e5a4d26)已合并,本 issue 关闭

    HonoServerPlugin 现在只拥有 socket、中间件和三条 current-user 端点。数据面与 discovery 各自单一 owner(ADR-0076 D11)。净 −678 行。

    四步全部落地

    步骤 PR
    前置拆分:三条 /me/* 移出 flag(路线 A) #4144
    证据:composed 宿主两种 flag 位置响应逐字节相同 #4260
    默认值翻 false + boot 提示 + pin 测试 #4280
    删除便利面、flag、死常量 #4296

    顺带清掉了正文里点名的三个死优先级常量,以及 discovery payload 不满足 DiscoverySchema 那条缝(随退役自动消失)。「/auth/me/* 靠加载顺序活着」那条也已由 #4088 独立修好(catch-all 改为可穿透)。

    三道门禁抓出了删除欠下的账

    这部分比删除本身更值得记 —— 每一处都是被工具抓出来的,不是预先想到的:

    1. [security] Anonymous posture inconsistent across surfaces: dispatcher /graphql and raw hono /data bypass requireAuth (/meta now gated) #2567 anonymous-deny ledger 报 STALE covers。台账行随其面删除、id 移出 HIGH_RISK —— 但 ratchet 自己的反向测试拿这一行当 fixture。若只删台账行,那条测试会空转通过,等于把刚刚抓到问题的检查拆掉引信。已改指一个存活的行。
    2. Docs drift 抓到 authorization.mdx —— 它把已删的面两次列为一条安全不变量的组成部分。不改的话,权限文档会承诺一道不存在的门。改法沿用该句已有的写法:那个面离开了矩阵,不变量本身没变,只是少了一扇要关的门。
    3. 合并冲突(fix(hono,plugin-hono-server,runtime): one CORS source and one registry key — the last derivable copies from the #3786 sweep #4282 的 CORS 收敛)—— 两边意图都保留,并核对了 main 没有重新引用被删符号。

    AGENTS.md

    新增 §「Route & surface ownership」四条规则:一条路由一个 owner · 显式组合优于默认魔法 · 缺席要响亮 · 机器可读面不许撒谎。

    外加一条验证方法,是这条线最贵的一课:「谁在服务这条路径」是关于 composed 且 provisioned 运行时的问题 —— 不是关于谁声明了它、不是关于注册顺序、也不是关于一个能启动的最小 harness。本 issue 被答错三次,每次正好对应绕过其中一条:

    1. 查「谁传这个选项」,没查「谁依赖这个默认值」(正文顶部订正)
    2. 查「谁声称提供」,没查「真实监听器上谁应答」(test(runtime): 把「谁在服务 /data 与 /discovery」钉成事实,用证据挡住 #4073 的默认值翻转 #4192,已由 test(runtime): 订正 #4073 的证据 —— 翻默认值对 composed 宿主是 no-op(推翻我在 #4192 的结论) #4260 撤回)
    3. 查「一个能启动的 harness」,没查「一个被真实供给的运行时」(test(runtime): 订正 #4073 的证据 —— 翻默认值对 composed 宿主是 no-op(推翻我在 #4192 的结论) #4260)

    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

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions