Skip to content

feat(service-storage): 无 http-server 的 kernel 上把 /api/v1/storage/* 挂到宿主 dispatcher —— 多租户宿主上附件上传整条链路无人应答 #17354

Description

@hotlong

框架 pin 8b37a09(本卡所有行号以此为准)。下游实例:cloud#1969。

现象

StorageServicePlugin 在 kernel:ready 向自己 kernel 的 http-server 服务注册 /api/v1/storage/* 的 10 条路由(packages/services/service-storage/src/storage-service-plugin.ts:432-440)。拿不到 http-server 就记一条日志、什么都不挂:

File storage is still accessible programmatically via kernel.getService("storage"). —— 同文件 :466

单租户 objectstack dev / serve 下没问题:kernel 拥有 socket。但在一个 socket、N 个 kernel 的多租户宿主下,每环境 kernel 按设计没有 http-server(宿主拥有 socket,按 hostname 路由到 kernel)。于是 /api/v1/storage/* 在这类部署上无人应答,文件/图片字段整条上传链路不可用 —— service 在、表在、生命周期钩子在,只有门没有。

为什么这属于框架而不是各宿主自己糊一份

三条,都在框架自己的注释里:

① 已有公开自注册槽,而且点名了这一类。 packages/runtime/src/domain-handler-registry.ts:21-28:

Registration stays dispatcher-owned on purpose: most service slots are multi-provider (e.g. i18n is served by I18nServicePlugin OR the AppPlugin in-memory fallback; analytics by service-analytics OR the ObjectQLPlugin fallback), so a route is the bridge to a SLOT, not the property of any one providing package — moving registration into one provider would 404 the others. External packages that DO own a slot exclusively can still self-register via HttpDispatcher.registerDomainHandler.

storage 槽没有第二个提供方 —— 保留 dispatcher 注册权的那条理由在这里不成立。registerDomainHandler 是公开方法(packages/runtime/src/http-dispatcher.ts:782),其单测自称「the public seam」(domain-handler-registry.test.ts:148)。

② dispatcher 早已知道自己要服务多租户宿主。 DomainHandlerDeps 带 kernelResolver,注释:「True when a host KernelResolver is registered (multi-tenant deployment)」(domain-handler-registry.ts:292);runtime/src/index.ts:100-102 把 KernelResolver 记为「retained framework contract; the multi-tenant implementation lives in cloud」。契约在框架、实现在宿主,是既定分工。

③ 同形状框架已经做过一遍,并把它认成下游的设计主路径。 packages/runtime/src/domains/share-links.ts:11-14:

NOTE (cross-repo, see #2462 step-① re-scope): for cloud's per-env kernels this is the DESIGNED PRIMARY surface (registerShareLinkRoutes: false; the host dispatcher serves it after kernel swap)

关于 #4087 的警告 —— 是条件,不是禁止

packages/runtime/src/route-ledger.ts:308-322 立着:

RETIRED (#4087) … A dispatcher /storage row coming back means a bridge is being rebuilt; make it speak the contract or don't build it.

⛔ 先读它删的是什么:#4087 删的是一座坏桥。upload 分支把 upload(key, data, options?) 当 upload(file, { request }) 调 —— 对任何实现都是 TypeError;download 分支 switch 一个没有实现返回过的结果形状。它删的是手搓的第二方言,不是这个模式。

本卡满足它那句条件的方式是:委托给 registerStorageRoutes 本体。那就是契约(packages/spec/src/api/storage.zod.ts 的 StorageApiContracts,枚举在 service-storage/src/storage-route-ledger.ts)。

⛔ 不接受任何重写一遍 handler 主体的做法。 那正是 #4087 删过一次的东西,再写一遍就是同一个缺陷回来。

建议形状(dev 可推翻,但推翻要写明依据)

在 StorageServicePlugin 里,http-server 缺席的那条分支(:432 附近)从「记日志、什么都不挂」改成:

  1. 用一个实现 IHttpServer 的路由收集器收下 registerStorageRoutes 的注册。同款可参考(但本卡的实现留在框架):cloud packages/objectos-runtime/src/env-settings-routes-plugin.ts 的 SettingsRouteCollector。
  2. 把收集到的 verb + path + handler 包成 DomainRoute { prefix: '/storage', match: 'prefix', handler },registerDomainHandler 自注册。
  3. handler 侧做 DomainRequest → IHttpRequest / IHttpResponse → HttpDispatcherResult 的适配;storage 与 store 从当轮请求解析出的环境 kernel 取。store 就是 new StorageMetadataStore(kernel.getService('objectql')) —— 公开导出(service-storage/src/index.ts:10),与插件自己的构造法一字不差(storage-service-plugin.ts:330,438)。

要素:

  • ⛔ 有 http-server 时行为一字不改,现有部署走原路,回归钉住。
  • 路径参数必须真支持(:fileId / :uploadId / :token)。
  • 405 + Allow 按 IHttpServer 适配器该有的契约给。

⚠️ 本卡唯一没解决的机制 —— 请先给结论再动手

插件今天拿不到 dispatcher 实例。 我核过:registerDomainHandler 的全部在仓调用点都在测试里;HttpDispatcher 由 DispatcherPlugin 内部 new(packages/runtime/src/dispatcher-plugin.ts:880),没有注册成 kernel service —— 全仓搜不到 'http-dispatcher' 之类的服务名。所以「插件自注册」这条路今天缺最后一跳。

两条出路,请 dev 先判再实现:

  • (a) 让 dispatcher(或仅 registerDomainHandler 这一能力)成为一个可解析的服务,StorageServicePlugin 用查 http-server 完全同一个姿势去查它。
  • (b) 照 share-links 的样子,在 runtime/src/domains/ 下建 storage.ts,由 dispatcher 侧注册。

⚠️ (b) 有一个我已经核实的障碍,别踩进去再退出来: @objectstack/service-storage 不在 @objectstack/runtime 的 dependencies,连 devDependencies 都不在(实测 packages/runtime/package.json)。走 (b) 就得新增 runtime → service-storage 这条边,而这正是 share-links.ts:39-46 明文记录过、并特意绕开的依赖倒置(它把 plugin-sharing 留作 devDep、拒绝 import,改把共享谓词搬去 spec)。

⇒ 倾向 (a),理由就是上面那条依赖方向 —— 也正是 registerDomainHandler 作为「外部包独占槽自注册」的公开缝存在的原因。但 (a) 引入插件挂载顺序依赖(storage 必须在 dispatcher 之后),这一点要在 PR 正文里给出处理方式与证据。

需要 dev 给结论的第二点

registerStorageRoutes 的 authorizeFileRead / resolveFileHolder 两个 opts —— 附件下载的鉴权闸(scope === 'attachments' 与字段拥有的文件是 gated 的,见 storage-routes.ts 的 authorizeDownload)。在无 http-server 的路径上这两个钩子由谁供给?

  • 倾向:框架侧,与 share-links 域取 per-env 鉴权上下文同源。
  • ⛔ 不要默默留空:留空的后果是附件下载要么全拒、要么绕过鉴权裸奔,而两者都不会有测试自己红。结论与依据写进 PR 正文。

验收

下游 / 相关

Activity

  1. hotlong commented on Sep 10, 2026

    @hotlong
    ContributorAuthor

    Closing as a duplicate of #15169 — the work described here landed two days before this card was filed. PM session_01FGca3Gt5irhBoVcVwFfoVY, 2026-09-10T08:5xZ.

    dd2184a — feat(service-storage): mountStorageRoutes — one host door for kernels with no http-server service (#16741), merged 2026-09-08T05:56:03Z, closing #15169 (state_reason: completed, closed by os-zhuang 2026-09-08T06:16:49Z). mountStorageRoutes is exported at packages/services/service-storage/src/index.ts:30 on origin/main.

    It also answers, better, the two points this card flagged as unresolved:

    How this card came to be filed. The PM checkout it was written from is pinned at 8b37a09 (2026-09-08T03:59Z) and is 288 commits behind origin/main; #16741 merged ~2 hours after that pin. Every source reading in this card is accurate for that tree and stale for today's. It surfaced on the mandatory pre-dispatch checks — dispatch-gates.mjs --tier printed a STALE TREE warning, and the premise check git log origin/main -- <paths> returned dd2184a in its first ten lines — so nothing was dispatched against it. The lesson for this seat is that the premise check has to run before the card is WRITTEN, not only before it is dispatched.

    Nothing here is actionable in this repository. The remaining work is entirely downstream in cloud (bump the .objectstack-sha pin past dd2184a, then call mountStorageRoutes from the host) and is tracked on cloud#1969.


    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

    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