From b785436fe158d764ccf5689acd7092813de36c4e Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 4 Aug 2026 05:14:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(runtime,hono):=20mount=20seam=20=E2=80=94?= =?UTF-8?q?=20setFallbackHandler=20+=20declarative=20endpoint=20dispatch?= =?UTF-8?q?=20step=20(#5090)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `IHttpServer.setFallbackHandler` on the Hono adapter (mapped onto `app.notFound`, never a wildcard route) and registers the declarative-endpoint dispatch step on it from the runtime dispatcher plugin. Part of #5040 (E3). Zero live behavior change: a non-empty `apis:` is still rejected at publish, so the whole surface is structurally unreachable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- .changeset/endpoint-mount-fallback-seam.md | 52 ++ .../plugins/plugin-hono-server/src/adapter.ts | 466 +++++++++++++----- .../src/fallback-seam.test.ts | 235 +++++++++ .../src/hono-plugin.test.ts | 6 + .../plugin-hono-server/src/hono-plugin.ts | 42 +- .../runtime/src/api-endpoint-step.test.ts | 145 ++++++ packages/runtime/src/api-endpoint-step.ts | 145 ++++++ ...ugin.endpoint-fallback.integration.test.ts | 252 ++++++++++ packages/runtime/src/dispatcher-plugin.ts | 85 +++- .../src/error-envelope.conformance.test.ts | 5 + .../src/route-ledger.conformance.test.ts | 38 +- packages/runtime/src/route-ledger.ts | 44 +- 12 files changed, 1345 insertions(+), 170 deletions(-) create mode 100644 .changeset/endpoint-mount-fallback-seam.md create mode 100644 packages/plugins/plugin-hono-server/src/fallback-seam.test.ts create mode 100644 packages/runtime/src/api-endpoint-step.test.ts create mode 100644 packages/runtime/src/api-endpoint-step.ts create mode 100644 packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts diff --git a/.changeset/endpoint-mount-fallback-seam.md b/.changeset/endpoint-mount-fallback-seam.md new file mode 100644 index 0000000000..c37b168588 --- /dev/null +++ b/.changeset/endpoint-mount-fallback-seam.md @@ -0,0 +1,52 @@ +--- +"@objectstack/plugin-hono-server": minor +"@objectstack/runtime": minor +--- + +feat(runtime,hono): 挂载 seam —— `setFallbackHandler` 实现 + 声明式端点派发步(#5040 E3, #5090) + +给声明式 `apis:` 端点铺上**唯一一条**能进入处理器的通路,并且这条通路在构造上不可能遮蔽任何 +已注册路由。执行器本身尚未落地,本次改动**零现网行为变更**:任何 stack 目前都无法发布非空 +`apis:`(publish 硬拒,直到 #5040 E7 翻转),所以这里新增的一切在真实组合里结构性不可达。 + +**`@objectstack/plugin-hono-server` —— `IHttpServer.setFallbackHandler` 的实现** + +契约(#5080 落在 `@objectstack/spec/contracts`)的四条保证逐条兑现: + +- 映射到 Hono 的 `app.notFound` 钩子,**不是**通配路由。这是全部要点:通配路由要与之后注册 + 的每一条路由竞争,而 Hono 按先注册者赢裁决,归属就变成插件 `start()` 顺序的函数 —— + ADR-0076 D11 正是为此存在。兜底器只在全部显式路由未命中后运行,**零注册顺序依赖**。 +- handler 拿到的 `req.body` **可读**(与 `use()` 中间件 seam 相反,后者的契约明确不填充 + body),按 content-type 解析,与真实路由处理器走同一段代码。 +- 重复安装即**替换**,不成链。 +- handler 不写响应 → 适配器既有的未命中答案(404,或方法不匹配时 405 + `Allow`)原样保留。 + +配套的一处属主收敛:404/405 应答此前由 `HonoServerPlugin.start()` 直接写在 +`getRawApp().notFound(...)` 上。`app.notFound` 是后调用者覆盖,兜底 seam 落在同一个钩子上, +两个写入方意味着幸存者由插件启动顺序决定 —— 应答本体因此移入 `HonoHttpServer` +(`installNotFoundSeam()` / `setFallbackHandler()` 在其中组合),一个钩子一个属主。行为 +逐字节不变(`notfound-405.test.ts` 原样通过)。 + +顺带修好同一段代码上的两处不一致:适配器构造的 `IHttpRequest` 现在一律带 +`remoteAddress`(此前只有中间件 seam 有,同一个契约有两种形状);处理器**同步**抛出与 +异步 reject 现在报同一种结果(此前同步抛出会逃到 Hono 自己的错误页)。 + +**`@objectstack/runtime` —— dispatcher 端点派发步** + +dispatcher-plugin 在 `start()` 中探测 `typeof server.setFallbackHandler === 'function'` +并注册兜底器。对落在 ADR-0121 D1 保留段 `/apps/<命名空间>/<子路径>` 下的请求, +探测 `metadata` 服务的 `matchEndpoint`(#5089 的实现在并行开发,探测缺席即穿透): + +- **命中** → `501 NOT_IMPLEMENTED`,包络说明执行器随 17.x 落地(#5040 E4–E5 接策略键与 + 执行目标); +- **未命中 / 无 matcher / 无 metadata 服务 / 路径不在挂载前缀下** → **不写任何响应**, + 传输层既有的 404/405 答案原样成立(有回归测试逐字节钉住); +- `matchEndpoint` 抛错按 5xx 出口应答,不降级为 404 —— 故障不得伪装成「没有这条路由」。 + +派发步**不重入** `dispatch()`:那条管线会解析环境与 `executionContext`、跑匿名拒绝门、并以 +语义 404 收尾,把全部未命中请求灌进去会改变今天未命中请求的答案。裸 404 与语义 404 的收口 +是另一个决定,本次刻意不做。 + +`route-ledger.ts` 新增 `* /apps/**` 登记行与 `NON_DISPATCH_MOUNT_PREFIXES`(本包在 +`dispatch()` 之外挂载的前缀),注记如实描述已接线的部分与**尚未**接线的执行部分;新增 +一致性测试钉住 ADR-0121 D1 赖以成立的事实 —— `/apps` 不属于任何内建域。 diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index f28fc06b1d..232608cd5e 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -8,6 +8,7 @@ import { RouteHandler, Middleware } from '@objectstack/core'; +import type { Context } from 'hono'; import { currentPerfTiming } from '@objectstack/observability'; import { Hono } from 'hono'; import { serve } from '@hono/node-server'; @@ -98,6 +99,24 @@ function readRemoteAddress(c: any): string | undefined { } } +/** + * The matched route's path parameters, or `{}` when there is no matched route. + * + * `c.req.param()` reads the router's match result, and in the `notFound` hook + * there ISN'T one — Hono throws `Cannot read properties of undefined` rather + * than returning empty (verified against hono 4.12, `HonoRequest.param` → + * `#getAllDecodedParams`). The fallback seam (#5090) runs handlers in exactly + * that context, so the read is guarded here rather than at one call site: an + * unmatched request HAS no path params, which is `{}`, not a crash. + */ +function readRouteParams(c: any): Record { + try { + return c?.req?.param?.() ?? {}; + } catch { + return {}; + } +} + /** * Hono Implementation of IHttpServer */ @@ -117,6 +136,14 @@ export class HonoHttpServer implements IHttpServer { private middlewares: Array<{ path?: string; handler: Middleware }> = []; /** Whether the Hono middleware that runs {@link middlewares} is mounted. */ private middlewareSeamInstalled = false; + /** + * The LAST-RESORT handler installed by {@link setFallbackHandler}, or + * `undefined` when no consumer installed one. Exactly one — installing + * again REPLACES, per the contract. + */ + private fallbackHandler: RouteHandler | undefined; + /** Whether the Hono `notFound` hook that runs {@link unmatchedResponse} is mounted. */ + private notFoundSeamInstalled = false; constructor( private port: number = 3000, @@ -134,158 +161,218 @@ export class HonoHttpServer implements IHttpServer { // internal helper to convert standard handler to Hono handler private wrap(handler: RouteHandler) { return async (c: any) => { - let body: any = {}; - - // Ambient per-request timing collector — present only when the - // Server-Timing / perf-tuning middleware established one for this - // request. All marks below are no-ops otherwise (zero overhead). - const _perf = currentPerfTiming(); - const _endParse = _perf?.start('parse', 'Body parse'); - - const contentType = c.req.header('content-type') ?? ''; - const isOctetStream = contentType.includes('application/octet-stream'); + const { response } = await this.runHandler(c, handler); + return response ?? c.json({ error: 'No response from handler' }, 500); + }; + } - // Try to parse JSON body first if content-type is JSON - if (contentType.includes('application/json')) { - try { - body = await c.req.json(); - } catch(e) { - // If JSON parsing fails, try parseBody - try { - body = await c.req.parseBody(); - } catch(e2) {} - } - } else if (!isOctetStream) { - // For non-JSON / non-binary content types, use parseBody - // (Skipping for octet-stream so the raw stream stays consumable - // via `req.rawBody()` for binary uploads.) + /** + * Run ONE {@link RouteHandler} against a Hono context and report what it + * produced: a `Response` when it answered (buffered or streamed), `null` + * when it wrote nothing, plus whether it threw. + * + * ## Why this is its own method (#5090) + * + * Two call sites now drive a `RouteHandler`: {@link wrap} (a registered + * route) and the `notFound` seam ({@link installNotFoundSeam}, for the + * handler installed by {@link setFallbackHandler}). The contract on + * `IHttpServer.setFallbackHandler` promises the fallback a FULLY POPULATED + * `IHttpRequest` — `req.body` included, parsed by content-type exactly as a + * route handler gets it — and the only way to promise that credibly is for + * both paths to build the request with the same code. A second, parallel + * request-builder for the fallback is how the two would drift into + * disagreeing about, say, `application/octet-stream`. + * + * The two callers differ only in what they make of "wrote nothing": + * a route handler that answers nothing is a bug (500), while a FALLBACK + * that answers nothing is the documented way to say "not mine" and leaves + * the adapter's standard unmatched answer in place. + */ + private async runHandler( + c: any, + handler: RouteHandler, + ): Promise<{ response: Response | null; failed: boolean }> { + let body: any = {}; + + // Ambient per-request timing collector — present only when the + // Server-Timing / perf-tuning middleware established one for this + // request. All marks below are no-ops otherwise (zero overhead). + const _perf = currentPerfTiming(); + const _endParse = _perf?.start('parse', 'Body parse'); + + const contentType = c.req.header('content-type') ?? ''; + const isOctetStream = contentType.includes('application/octet-stream'); + + // Try to parse JSON body first if content-type is JSON + if (contentType.includes('application/json')) { + try { + body = await c.req.json(); + } catch(e) { + // If JSON parsing fails, try parseBody try { body = await c.req.parseBody(); - } catch(e) {} + } catch(e2) {} } + } else if (!isOctetStream) { + // For non-JSON / non-binary content types, use parseBody + // (Skipping for octet-stream so the raw stream stays consumable + // via `req.rawBody()` for binary uploads.) + try { + body = await c.req.parseBody(); + } catch(e) {} + } - _endParse?.(); + _endParse?.(); - const rawHeaders = c.req.header(); - // Fetch API `Request` objects don't expose the `Host` header - // (it's a forbidden header — derived from the URL by the - // transport). Hostname-based routing in REST/dispatcher - // depends on it, so we backfill from `c.req.url`. - if (!rawHeaders.host) { - try { - const u = new URL(c.req.url); - if (u.host) rawHeaders.host = u.host; - } catch { /* non-URL request, leave headers as-is */ } - } + const rawHeaders = c.req.header(); + // Fetch API `Request` objects don't expose the `Host` header + // (it's a forbidden header — derived from the URL by the + // transport). Hostname-based routing in REST/dispatcher + // depends on it, so we backfill from `c.req.url`. + if (!rawHeaders.host) { + try { + const u = new URL(c.req.url); + if (u.host) rawHeaders.host = u.host; + } catch { /* non-URL request, leave headers as-is */ } + } - const req = { - params: c.req.param(), - query: c.req.query(), - body, - headers: rawHeaders, - method: c.req.method, - path: c.req.path, - rawBody: async () => { - const ab = await c.req.arrayBuffer(); - return Buffer.from(ab); - }, - }; + const req = { + params: readRouteParams(c), + query: c.req.query(), + body, + headers: rawHeaders, + method: c.req.method, + path: c.req.path, + rawBody: async () => { + const ab = await c.req.arrayBuffer(); + return Buffer.from(ab); + }, + /** + * The transport's own peer address (`IHttpRequest.remoteAddress`) + * — the unforgeable half of caller identification, never a + * header. The middleware seam has always populated it; handlers + * did not, so the one contract had two shapes depending on which + * seam you entered through. Filled here (#5090) so every + * `IHttpRequest` this adapter builds carries the same members, + * which is what lets `setFallbackHandler`'s contract promise a + * FULLY populated request without qualification. `undefined` on + * a runtime that exposes no socket — consumers must degrade + * deliberately, never substitute a header. + */ + remoteAddress: readRemoteAddress(c), + }; - let capturedResponse: any; - let streamController: ReadableStreamDefaultController | null = null; - let streamEncoder: TextEncoder | null = null; - let streamHeaders: Record = {}; - let isStreaming = false; - let streamClosed = false; - - // The unused stream is always created (see below) and may be closed - // from two places — `res.end()` and the post-handler cleanup — so - // guard against the double-close that crashes the event loop with - // `ERR_INVALID_STATE: Controller is already closed`. - const closeStream = () => { - if (streamController && !streamClosed) { - streamClosed = true; - try { streamController.close(); } catch { /* already closed */ } + let capturedResponse: any; + let streamController: ReadableStreamDefaultController | null = null; + let streamEncoder: TextEncoder | null = null; + let streamHeaders: Record = {}; + let isStreaming = false; + let streamClosed = false; + + // The unused stream is always created (see below) and may be closed + // from two places — `res.end()` and the post-handler cleanup — so + // guard against the double-close that crashes the event loop with + // `ERR_INVALID_STATE: Controller is already closed`. + const closeStream = () => { + if (streamController && !streamClosed) { + streamClosed = true; + try { streamController.close(); } catch { /* already closed */ } + } + }; + + const res = { + json: (data: any) => { + // `serialize` Server-Timing span — JSON-encoding the body is + // the one adapter-owned cost between "handler done" and + // "bytes on the wire". No-op when perf-tuning is off. + const endSerialize = _perf?.start('serialize', 'Response serialize'); + capturedResponse = c.json(data); + endSerialize?.(); + }, + send: (data: string | Uint8Array | ArrayBuffer | Buffer) => { + if (data instanceof Uint8Array || data instanceof ArrayBuffer || (typeof Buffer !== 'undefined' && Buffer.isBuffer?.(data))) { + const body = data instanceof ArrayBuffer ? data : (data as Uint8Array).buffer.slice((data as Uint8Array).byteOffset, (data as Uint8Array).byteOffset + (data as Uint8Array).byteLength); + capturedResponse = c.body(body as ArrayBuffer); + } else { + capturedResponse = c.html(data as string); } - }; + }, + status: (code: number) => { c.status(code); return res; }, + header: (name: string, value: string) => { + c.header(name, value); + streamHeaders[name] = value; + return res; + }, + write: (chunk: string | Uint8Array) => { + isStreaming = true; + if (streamController && streamEncoder) { + const data = typeof chunk === 'string' ? streamEncoder.encode(chunk) : chunk; + streamController.enqueue(data); + } + }, + end: () => { + // Body-less response (e.g. 204 No Content) honoring any + // status already set via `res.status()`. A null body avoids + // the undici "Invalid response status code 204" thrown when + // an empty *string* body is paired with a null-body status. + if (!isStreaming && capturedResponse === undefined) { + capturedResponse = c.body(null); + } + closeStream(); + }, + }; - const res = { - json: (data: any) => { - // `serialize` Server-Timing span — JSON-encoding the body is - // the one adapter-owned cost between "handler done" and - // "bytes on the wire". No-op when perf-tuning is off. - const endSerialize = _perf?.start('serialize', 'Response serialize'); - capturedResponse = c.json(data); - endSerialize?.(); - }, - send: (data: string | Uint8Array | ArrayBuffer | Buffer) => { - if (data instanceof Uint8Array || data instanceof ArrayBuffer || (typeof Buffer !== 'undefined' && Buffer.isBuffer?.(data))) { - const body = data instanceof ArrayBuffer ? data : (data as Uint8Array).buffer.slice((data as Uint8Array).byteOffset, (data as Uint8Array).byteOffset + (data as Uint8Array).byteLength); - capturedResponse = c.body(body as ArrayBuffer); - } else { - capturedResponse = c.html(data as string); - } - }, - status: (code: number) => { c.status(code); return res; }, - header: (name: string, value: string) => { - c.header(name, value); - streamHeaders[name] = value; - return res; - }, - write: (chunk: string | Uint8Array) => { - isStreaming = true; - if (streamController && streamEncoder) { - const data = typeof chunk === 'string' ? streamEncoder.encode(chunk) : chunk; - streamController.enqueue(data); - } - }, - end: () => { - // Body-less response (e.g. 204 No Content) honoring any - // status already set via `res.status()`. A null body avoids - // the undici "Invalid response status code 204" thrown when - // an empty *string* body is paired with a null-body status. - if (!isStreaming && capturedResponse === undefined) { - capturedResponse = c.body(null); - } - closeStream(); + // Create a streaming response wrapper — if handler calls res.write(), + // we return a ReadableStream; otherwise fall back to capturedResponse. + const streamPromise = new Promise<{ response: Response | null; failed: boolean }>((resolve) => { + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + streamEncoder = new TextEncoder(); }, - }; + }); - // Create a streaming response wrapper — if handler calls res.write(), - // we return a ReadableStream; otherwise fall back to capturedResponse. - const streamPromise = new Promise((resolve) => { - const stream = new ReadableStream({ - start(controller) { - streamController = controller; - streamEncoder = new TextEncoder(); - }, - }); - - // Run the handler; once it's done, check if streaming was used - const _endHandler = _perf?.start('handler', 'Route handler'); - const result = handler(req as any, res as any); - const done = result instanceof Promise ? result : Promise.resolve(result); - done.then(() => { - _endHandler?.(); - if (isStreaming) { - resolve(new Response(stream, { + // Run the handler; once it's done, check if streaming was used. + // + // `Promise.try`-shaped on purpose: invoking the handler inside + // this executor means a SYNCHRONOUS throw would reject the + // promise instead of resolving it, while an async rejection + // lands in `.catch` below — the same failure reported two + // different ways depending on whether the handler happened to + // be `async`. That asymmetry was survivable while the only + // consumer was `wrap` (both ended as a 500, with different + // bodies); it is not survivable for the `notFound` seam, whose + // hook MUST return a Response — Hono answers a rejected + // notFound with its own opaque error page, so a throwing + // fallback could neither be reported nor declined (#5090). + const _endHandler = _perf?.start('handler', 'Route handler'); + const done = (async () => handler(req as any, res as any))(); + done.then(() => { + _endHandler?.(); + if (isStreaming) { + resolve({ + response: new Response(stream, { status: 200, headers: streamHeaders, - })); - } else { - // Not streaming — close the unused stream and return null - closeStream(); - resolve(null); - } - }).catch((err) => { - _endHandler?.(); + }), + failed: false, + }); + } else { + // Not streaming — close the unused stream and return null closeStream(); - resolve(null); - }); + resolve({ response: null, failed: false }); + } + }).catch((_err) => { + _endHandler?.(); + closeStream(); + resolve({ response: null, failed: true }); }); + }); - const streamResponse = await streamPromise; - return streamResponse ?? capturedResponse ?? c.json({ error: 'No response from handler' }, 500); + const outcome = await streamPromise; + return { + response: outcome.response ?? capturedResponse ?? null, + failed: outcome.failed, }; } @@ -326,6 +413,115 @@ export class HonoHttpServer implements IHttpServer { return Array.from(methods).sort(); } + /** + * Install the LAST-RESORT handler — see the CONTRACT on + * `IHttpServer.setFallbackHandler` in `@objectstack/spec/contracts` + * (#5040 §1-C). This implementation honours it as follows: + * + * 1. **Only after every registered route has missed.** It is mounted on + * Hono's `app.notFound` hook — NEVER as a `/*` route. Hono runs + * `notFound` only when its router matched no handler, so a fallback is + * structurally incapable of shadowing a registered route and carries + * ZERO registration-order dependency (ADR-0076 D11: one route, one + * owner, by construction rather than by convention). Empirically + * confirmed against this Hono version in `fallback-seam.test.ts`, + * including the case the design flagged as a risk (#5040 §7-1): a + * METHOD mismatch on an existing path routes to the same `notFound` + * sink, so the fallback sees those too — and declining to answer leaves + * the 405 below intact. + * 2. **`req.body` IS readable.** Nothing consumed the request stream — + * no route handler ran — so {@link runHandler} parses it by + * content-type exactly as it does for a route. That is the whole + * reason this seam exists and `use()` middleware cannot serve: the + * middleware contract explicitly does NOT populate `body`. + * 3. **Replacement, not a chain.** One handler; calling again replaces it. + * The Hono hook is mounted once (idempotent) and reads the field per + * request, so replacing never re-mounts and can never stack. + * 4. **A handler that writes nothing leaves the standard answer.** See + * {@link unmatchedResponse} — the 404/405 semantics this interface + * documents are produced there, after the fallback declines. + */ + setFallbackHandler(handler: RouteHandler): void { + this.fallbackHandler = handler; + this.installNotFoundSeam(); + } + + /** + * Mount the single Hono `notFound` hook that produces this adapter's + * unmatched-request answer, running {@link fallbackHandler} first when one + * is installed. Idempotent. + * + * WHY THE ADAPTER OWNS THIS (#5090). The 405/404 answer used to be written + * by `HonoServerPlugin.start()` calling `getRawApp().notFound(...)` itself. + * `app.notFound` is LAST-CALL-WINS (verified, not assumed), so with the + * fallback seam added there would have been two writers of one hook and the + * survivor would depend on plugin start order — the loser silently losing + * either the fallback or the 405. One hook, one owner: the plugin now calls + * this method instead, the two behaviours COMPOSE inside it, and + * `setFallbackHandler` may be called at any moment, before or after. + * + * A bare `HonoHttpServer` (no plugin, e.g. cloud's serverless entrypoints) + * still gets Hono's own 404 unless it calls this — or installs a fallback, + * which mounts the seam for it. + */ + installNotFoundSeam(): void { + if (this.notFoundSeamInstalled) return; + const app = this.app as unknown as { notFound?: (h: (c: Context) => unknown) => void }; + // Feature-detected rather than assumed: `getRawApp()` is an escape + // hatch and a host may hand back something Hono-shaped but not Hono. + if (typeof app.notFound !== 'function') return; + this.notFoundSeamInstalled = true; + + app.notFound(async (c: any) => { + const handler = this.fallbackHandler; + if (handler) { + const { response, failed } = await this.runHandler(c, handler); + if (response) return response; + if (failed) { + // Prefer failing to falling back: a fallback that THREW is + // a broken consumer, and reporting its failure as this + // adapter's ordinary 404 would hide it behind the most + // unremarkable status on the wire. + return c.json({ error: 'Fallback handler failed' }, 500); + } + // Wrote nothing — the documented way to say "not mine". Fall + // through to the standard answer, unchanged. + } + return this.unmatchedResponse(c); + }); + } + + /** + * This adapter's standard answer for a request that matched no route — + * the `IHttpServer` unmatched-request CONTRACT (#3607 / ADR-0076 OQ#10), + * validated across adapters by `@objectstack/http-conformance`. + * + * Hono routes a method mismatch to the SAME `notFound` sink as a genuinely + * missing path, so a `POST` to a `PUT`-only route (e.g. the metadata save + * endpoint, see #2684) would otherwise return an opaque + * `{ error: 'Not found' }` 404 with no hint that the path exists under + * another verb. We re-match the request path against the registered route + * patterns: if it lines up with routes under other methods, answer `405 + * Method Not Allowed` with an accurate `Allow` header so callers can + * self-correct. A path that matches nothing stays a 404. This is + * framework-wide — every registered endpoint benefits, not just metadata. + */ + private unmatchedResponse(c: any) { + const allowed = this.allowedMethodsForPath(c.req.path); + if (allowed.length > 0 && !allowed.includes(c.req.method)) { + c.header('Allow', allowed.join(', ')); + return c.json({ + error: 'Method Not Allowed', + code: 'METHOD_NOT_ALLOWED', + message: `${c.req.method} is not supported for ${c.req.path}. Allowed: ${allowed.join(', ')}.`, + method: c.req.method, + path: c.req.path, + allowed, + }, 405); + } + return c.json({ error: 'Not found' }, 404); + } + /** * Register middleware — see the CONTRACT on `IHttpServer.use` in * `@objectstack/spec/contracts`. diff --git a/packages/plugins/plugin-hono-server/src/fallback-seam.test.ts b/packages/plugins/plugin-hono-server/src/fallback-seam.test.ts new file mode 100644 index 0000000000..097aa5cbb2 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/fallback-seam.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `HonoHttpServer.setFallbackHandler()` — the unmatched-request seam, exercised + * through the real Hono app (`app.fetch`), never a mock. + * + * ## Why this file exists (#5090, #5040 E3) + * + * The contract on `IHttpServer.setFallbackHandler` makes four promises, and + * each one is a thing an implementation could plausibly get wrong: + * + * 1. it runs ONLY after every registered route missed — the reason this is a + * `notFound` hook and not a `${prefix}/*` wildcard route, which Hono would + * resolve by first-registration-wins across plugin `start()` order + * (ADR-0076 D11); + * 2. `req.body` IS readable here, unlike the `use()` middleware seam whose + * contract explicitly does not populate it; + * 3. installing again REPLACES — one fallback, never a chain; + * 4. a handler that writes NOTHING leaves the adapter's standard answer in + * place — 404, or 405 + `Allow` for a method mismatch. + * + * (4) is also the design-flagged risk (#5040 §7-1) made concrete: Hono routes a + * method mismatch to the SAME `notFound` sink as a missing path, so the + * fallback sees those requests too and must be able to decline them without + * costing the 405. `notfound-405.test.ts` pins the no-fallback baseline; this + * file pins it with a fallback installed. + */ + +import { describe, it, expect } from 'vitest'; +import type { IHttpRequest, IHttpResponse } from '@objectstack/core'; + +import { HonoHttpServer } from './adapter'; + +/** A server with routes + the standard unmatched-request seam mounted. */ +function serverWithRoutes() { + const server = new HonoHttpServer(0); + server.get('/api/v1/thing', (_req, res) => { res.status(200); res.json({ route: 'get' }); }); + server.post('/api/v1/thing', (_req, res) => { res.status(201); res.json({ route: 'post' }); }); + server.put('/api/v1/only-put', (_req, res) => { res.status(200); res.json({ route: 'put' }); }); + server.installNotFoundSeam(); + return server; +} + +const call = (server: HonoHttpServer, path: string, init?: RequestInit) => + server.getRawApp().fetch(new Request(`http://localhost${path}`, init)); + +const postJson = (server: HonoHttpServer, path: string, body: unknown) => + call(server, path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + +describe('guarantee 1 — a fallback can never shadow a registered route', () => { + it('does not run for a path+method a route owns', async () => { + const server = serverWithRoutes(); + let ran = false; + server.setFallbackHandler((_req, res) => { ran = true; res.status(200); res.json({ from: 'fallback' }); }); + + const res = await call(server, '/api/v1/thing'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ route: 'get' }); + expect(ran, 'fallback ran for a request a registered route matched').toBe(false); + }); + + it('is order-independent — installing BEFORE the routes changes nothing', async () => { + // The whole point of mapping onto `notFound` instead of a wildcard + // route: with a `${prefix}/*` route this test is the one that fails. + const server = new HonoHttpServer(0); + server.setFallbackHandler((_req, res) => { res.status(200); res.json({ from: 'fallback' }); }); + server.get('/api/v1/thing', (_req, res) => { res.status(200); res.json({ route: 'get' }); }); + + expect(await (await call(server, '/api/v1/thing')).json()).toEqual({ route: 'get' }); + expect(await (await call(server, '/api/v1/other')).json()).toEqual({ from: 'fallback' }); + }); + + it('runs for a path no route owns', async () => { + const server = serverWithRoutes(); + const seen: Array<{ method: string; path: string }> = []; + server.setFallbackHandler((req, res) => { + seen.push({ method: req.method, path: req.path }); + res.status(200); + res.json({ from: 'fallback' }); + }); + + const res = await call(server, '/api/v1/apps/showcase/tasks'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ from: 'fallback' }); + expect(seen).toEqual([{ method: 'GET', path: '/api/v1/apps/showcase/tasks' }]); + }); +}); + +describe('guarantee 2 — the fallback receives a fully populated request', () => { + it('reads a JSON body (the difference from the `use()` middleware seam)', async () => { + const server = serverWithRoutes(); + let received: IHttpRequest | undefined; + server.setFallbackHandler((req, res) => { received = req; res.status(200); res.json({ ok: true }); }); + + await postJson(server, '/api/v1/apps/showcase/inquiries/purge?dry=1', { reason: 'stale', count: 3 }); + + expect(received?.body).toEqual({ reason: 'stale', count: 3 }); + expect(received?.query).toEqual({ dry: '1' }); + expect(received?.method).toBe('POST'); + expect(received?.path).toBe('/api/v1/apps/showcase/inquiries/purge'); + expect(received?.headers['content-type']).toContain('application/json'); + // Backfilled from the URL — Fetch `Request` hides the Host header, and + // hostname-based environment routing depends on it. + expect(received?.headers.host).toBe('localhost'); + expect(typeof received?.rawBody).toBe('function'); + }); + + it('parses a form body by content-type, exactly as a route handler would', async () => { + const server = serverWithRoutes(); + let routeBody: unknown; + let fallbackBody: unknown; + server.post('/api/v1/echo', (req, res) => { routeBody = req.body; res.status(200); res.json({ ok: true }); }); + server.setFallbackHandler((req, res) => { fallbackBody = req.body; res.status(200); res.json({ ok: true }); }); + + const form = () => { + const body = new URLSearchParams({ a: '1', b: 'two' }); + return { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body }; + }; + await call(server, '/api/v1/echo', form() as RequestInit); + await call(server, '/api/v1/apps/showcase/form', form() as RequestInit); + + expect(fallbackBody).toEqual({ a: '1', b: 'two' }); + expect(fallbackBody).toEqual(routeBody); + }); + + it('leaves `body` an empty object when there is none', async () => { + const server = serverWithRoutes(); + let received: IHttpRequest | undefined; + server.setFallbackHandler((req, res) => { received = req; res.status(200); res.json({ ok: true }); }); + + await call(server, '/api/v1/apps/showcase/tasks'); + expect(received?.body).toEqual({}); + }); +}); + +describe('guarantee 3 — installing again REPLACES', () => { + it('runs only the most recently installed handler', async () => { + const server = serverWithRoutes(); + const ran: string[] = []; + server.setFallbackHandler((_req, res) => { ran.push('first'); res.status(200); res.json({ which: 'first' }); }); + server.setFallbackHandler((_req, res) => { ran.push('second'); res.status(200); res.json({ which: 'second' }); }); + + const res = await call(server, '/api/v1/apps/showcase/tasks'); + expect(await res.json()).toEqual({ which: 'second' }); + expect(ran).toEqual(['second']); + }); + + it('does not stack the `notFound` hook when installed repeatedly', async () => { + const server = serverWithRoutes(); + let calls = 0; + for (let i = 0; i < 3; i++) { + server.setFallbackHandler((_req, res) => { calls++; res.status(200); res.json({ i }); }); + } + await call(server, '/api/v1/apps/showcase/tasks'); + expect(calls).toBe(1); + }); +}); + +describe('guarantee 4 — a fallback that writes nothing leaves the standard answer', () => { + it('keeps the 404 body byte-for-byte', async () => { + const withoutFallback = serverWithRoutes(); + const baseline = await call(withoutFallback, '/api/v1/apps/showcase/tasks'); + + const server = serverWithRoutes(); + let ran = false; + server.setFallbackHandler(() => { ran = true; /* declines */ }); + const res = await call(server, '/api/v1/apps/showcase/tasks'); + + expect(ran).toBe(true); + expect(res.status).toBe(baseline.status); + expect(res.status).toBe(404); + expect(await res.text()).toBe(await baseline.text()); + expect(JSON.parse(await (await call(serverWithRoutes(), '/api/v1/nope')).text())) + .toEqual({ error: 'Not found' }); + }); + + it('keeps the 405 + `Allow` answer for a method mismatch (#5040 §7-1)', async () => { + // Hono sends a method mismatch to the SAME notFound sink, so the + // fallback is consulted for these too — and declining must not cost the + // 405 that `notfound-405.test.ts` pins without a fallback installed. + const server = serverWithRoutes(); + const seen: string[] = []; + server.setFallbackHandler((req) => { seen.push(`${req.method} ${req.path}`); }); + + const res = await call(server, '/api/v1/only-put', { method: 'DELETE' }); + expect(res.status).toBe(405); + expect(res.headers.get('Allow')).toBe('PUT'); + const body = await res.json(); + expect(body.code).toBe('METHOD_NOT_ALLOWED'); + expect(body.allowed).toEqual(['PUT']); + expect(seen).toEqual(['DELETE /api/v1/only-put']); + }); + + it('answers 405 rather than the fallback even when the fallback WOULD answer', async () => { + // Declining is the fallback's own choice; a fallback that answers a + // method mismatch is allowed to (it saw the request first, by contract). + // Pinned so the precedence is a decision on record, not an accident. + const server = serverWithRoutes(); + server.setFallbackHandler((req, res) => { + if (req.path.startsWith('/api/v1/apps/')) { res.status(200); res.json({ from: 'fallback' }); } + }); + const res = await call(server, '/api/v1/only-put', { method: 'DELETE' }); + expect(res.status).toBe(405); + }); +}); + +describe('failure modes', () => { + it('reports a THROWING fallback as a 500 instead of hiding it in the 404', async () => { + const server = serverWithRoutes(); + server.setFallbackHandler(() => { throw new Error('boom'); }); + const res = await call(server, '/api/v1/apps/showcase/tasks'); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Fallback handler failed' }); + }); + + it('honours a status set without a body (the `res.end()` shape)', async () => { + const server = serverWithRoutes(); + server.setFallbackHandler((_req, res: IHttpResponse) => { res.status(204); res.end?.(); }); + const res = await call(server, '/api/v1/apps/showcase/tasks'); + expect(res.status).toBe(204); + }); + + it('a bare server without the seam keeps Hono\'s own 404 (no silent behavior change)', async () => { + const server = new HonoHttpServer(0); + server.get('/api/v1/thing', (_req, res) => { res.json({ ok: true }); }); + const res = await server.getRawApp().fetch(new Request('http://localhost/api/v1/nope')); + expect(res.status).toBe(404); + // Hono's built-in answer — NOT this adapter's `{ error: 'Not found' }`. + expect(await res.text()).toBe('404 Not Found'); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts index 55bda0d50d..7af5186de4 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -35,6 +35,12 @@ vi.mock('./adapter', async (importOriginal) => ({ // end of init(). Real behaviour is covered against the REAL adapter // in `middleware-seam.test.ts`; here it only has to exist. installMiddlewareSeam: vi.fn(), + // [#5090] Same deal for the unmatched-request seam: `start()` mounts + // it through the adapter now (one owner for `app.notFound`, which is + // last-call-wins). The real 404/405/fallback composition is covered + // against the REAL adapter in `notfound-405.test.ts` and + // `fallback-seam.test.ts`; here it only has to exist. + installNotFoundSeam: vi.fn(), getRawApp: vi.fn().mockReturnValue({ get: vi.fn(), use: vi.fn(), diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 3021570a13..ee0484b285 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -522,37 +522,21 @@ export class HonoServerPlugin implements Plugin { } } + // ─── Unmatched-request seam ─────────────────────────────────────────── // Catch-all: ensure unmatched requests always get a proper Response - // (prevents Hono "Context is not finalized" error). + // (prevents Hono "Context is not finalized" error), and answer a method + // mismatch with 405 + `Allow` rather than an opaque 404 (#2684). // - // Hono routes a method mismatch to the SAME `notFound` sink as a - // genuinely missing path, so a `POST` to a `PUT`-only route (e.g. the - // metadata save endpoint, see #2684) used to return an opaque - // `{ error: 'Not found' }` 404 with no hint that the path exists under - // another verb. Here we re-match the request path against the set of - // registered route patterns: if it lines up with routes under other - // methods, answer `405 Method Not Allowed` with an accurate `Allow` - // header so callers can self-correct. A path that matches nothing - // stays a 404. This is framework-wide — every registered endpoint - // benefits, not just metadata. - const rawAppForNotFound = this.server.getRawApp(); - if (typeof rawAppForNotFound.notFound === 'function') { - rawAppForNotFound.notFound((c: any) => { - const allowed = this.server.allowedMethodsForPath(c.req.path); - if (allowed.length > 0 && !allowed.includes(c.req.method)) { - c.header('Allow', allowed.join(', ')); - return c.json({ - error: 'Method Not Allowed', - code: 'METHOD_NOT_ALLOWED', - message: `${c.req.method} is not supported for ${c.req.path}. Allowed: ${allowed.join(', ')}.`, - method: c.req.method, - path: c.req.path, - allowed, - }, 405); - } - return c.json({ error: 'Not found' }, 404); - }); - } + // The answer itself now lives in `HonoHttpServer.unmatchedResponse` and + // this call only MOUNTS the hook (#5090). It moved because `notFound` is + // last-call-wins and gained a second writer: `setFallbackHandler` — the + // `IHttpServer` seam the declarative-endpoint dispatcher installs — maps + // onto the same hook. Two writers of one hook would have meant the + // survivor was decided by plugin start order, silently costing whichever + // lost. One hook, one owner: the adapter composes fallback-then-standard + // answer inside it, so this call and any `setFallbackHandler()` are + // order-independent. + this.server.installNotFoundSeam(); // Register endpoints during kernel:ready so they're wired up alongside // other plugins' route registrations. diff --git a/packages/runtime/src/api-endpoint-step.test.ts b/packages/runtime/src/api-endpoint-step.test.ts new file mode 100644 index 0000000000..efcf67ca32 --- /dev/null +++ b/packages/runtime/src/api-endpoint-step.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The endpoint dispatch step in isolation (#5040 E3 / #5090). + * + * Every case here is about ONE question: when does this step answer, and when + * does it write nothing so the transport's existing unmatched answer stands? + * Getting that wrong in either direction is a live behavior change on a surface + * that is supposed to be inert until the #5040 E7 flip. + * + * `matchEndpoint` is driven by a stub implementing the contract in + * `@objectstack/spec/contracts` — deliberately, not by the real matcher: that + * implementation is #5089, developed in parallel, and this seam must not depend + * on its landing order (nor its absence be untested — probe-absent is the + * passthrough case below). + */ + +import { describe, it, expect } from 'vitest'; +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; + +import { + APP_ENDPOINT_SEGMENT, + appEndpointMountPrefix, + isAppEndpointPath, + runAppEndpointStep, +} from './api-endpoint-step.js'; + +/** A declared endpoint in the ADR-0121 D1 shape, defaults materialized. */ +const TASKS: ApiEndpoint = ApiEndpointSchema.parse({ + name: 'showcase_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, +}); + +/** A metadata service that owns exactly the endpoints it is given. */ +function matcherFor(endpoints: ApiEndpoint[]) { + const calls: Array<{ path: string; method: string }> = []; + return { + calls, + service: { + matchEndpoint: async (query: { path: string; method: string }): Promise => { + calls.push(query); + const hit = endpoints.find( + (e) => e.path === query.path.replace(/\/$/, '') && e.method === query.method.toUpperCase(), + ); + return hit ? { endpoint: hit, params: {} } : undefined; + }, + }, + }; +} + +const step = (path: string, method = 'GET', service?: unknown) => + runAppEndpointStep({ method, path, prefix: '/api/v1', metadataService: service as never }); + +describe('the mount prefix is spelled once (ADR-0121 D1)', () => { + it('is `/apps/`, trailing slash included', () => { + expect(APP_ENDPOINT_SEGMENT).toBe('apps'); + expect(appEndpointMountPrefix('/api/v1')).toBe('/api/v1/apps/'); + expect(appEndpointMountPrefix('/api/v1/')).toBe('/api/v1/apps/'); + expect(appEndpointMountPrefix('/custom')).toBe('/custom/apps/'); + }); + + it('scopes by segment, not by string prefix', () => { + expect(isAppEndpointPath('/api/v1/apps/showcase/tasks', '/api/v1')).toBe(true); + expect(isAppEndpointPath('/api/v1/apps/a', '/api/v1')).toBe(true); + // The bare mount itself declares nothing — and `appsx` is a different + // word, which a `startsWith('/api/v1/apps')` test would have missed. + expect(isAppEndpointPath('/api/v1/apps/', '/api/v1')).toBe(false); + expect(isAppEndpointPath('/api/v1/apps', '/api/v1')).toBe(false); + expect(isAppEndpointPath('/api/v1/appsx/thing', '/api/v1')).toBe(false); + expect(isAppEndpointPath('/api/v1/data/showcase_task', '/api/v1')).toBe(false); + // A deployment on a non-default prefix scopes to ITS prefix only. + expect(isAppEndpointPath('/api/v1/apps/showcase/tasks', '/custom')).toBe(false); + }); +}); + +describe('the step writes nothing unless a declaration owns the request', () => { + it('never asks about a path outside the mount', async () => { + const { service, calls } = matcherFor([TASKS]); + expect(await step('/api/v1/data/showcase_task', 'GET', service)).toBeUndefined(); + expect(await step('/api/v1/health', 'GET', service)).toBeUndefined(); + expect(await step('/nope', 'GET', service)).toBeUndefined(); + expect(calls, 'the metadata service was consulted for a non-endpoint path').toEqual([]); + }); + + it('passes through when the kernel has no metadata service', async () => { + expect(await step('/api/v1/apps/showcase/tasks', 'GET', undefined)).toBeUndefined(); + }); + + it('passes through when the metadata service carries no matchEndpoint (#5089 not landed)', async () => { + // The contract's own probe convention: an occupant of the slot with no + // endpoint index simply omits the member. That must be a fully working + // passthrough, not a crash and not a 501. + const withoutMatcher = { get: async () => undefined, list: async () => [] }; + expect(await step('/api/v1/apps/showcase/tasks', 'GET', withoutMatcher)).toBeUndefined(); + }); + + it('passes through on a miss', async () => { + const { service, calls } = matcherFor([TASKS]); + expect(await step('/api/v1/apps/showcase/unknown', 'GET', service)).toBeUndefined(); + // Method is part of the identity: the same path under another verb is a + // different endpoint, and a miss. + expect(await step('/api/v1/apps/showcase/tasks', 'DELETE', service)).toBeUndefined(); + expect(calls).toEqual([ + { path: '/api/v1/apps/showcase/unknown', method: 'GET' }, + { path: '/api/v1/apps/showcase/tasks', method: 'DELETE' }, + ]); + }); + + it('lets a matcher failure propagate — an outage must not read as a 404', async () => { + const broken = { matchEndpoint: async () => { throw new Error('metadata store unreachable'); } }; + await expect(step('/api/v1/apps/showcase/tasks', 'GET', broken)).rejects.toThrow('metadata store unreachable'); + }); +}); + +describe('a match answers 501 until the executor lands (#5040 E5)', () => { + it('reports NOT_IMPLEMENTED in the declared error envelope', async () => { + const { service } = matcherFor([TASKS]); + const answer = await step('/api/v1/apps/showcase/tasks', 'GET', service); + + expect(answer?.status).toBe(501); + const body = answer!.body as { success: boolean; error: Record }; + expect(body.success).toBe(false); + expect(body.error.code).toBe('NOT_IMPLEMENTED'); + expect(body.error.httpStatus).toBe(501); + // Names the endpoint it matched and says plainly that nothing ran — + // "matched but not executed" must never read as "executed and empty". + expect(body.error.message).toContain('showcase_tasks'); + expect(body.error.message).toContain('not enabled'); + expect(String(body.error.hint)).toContain('#5040'); + }); + + it('passes the request coordinates through untouched', async () => { + const { service, calls } = matcherFor([TASKS]); + await step('/api/v1/apps/showcase/tasks', 'GET', service); + // No normalization here: `matchEndpoint`'s contract owns trailing-slash + // trimming and case folding, and a second, weaker copy in the consumer + // is how two spellings of "the same path" start to disagree. + expect(calls).toEqual([{ path: '/api/v1/apps/showcase/tasks', method: 'GET' }]); + }); +}); diff --git a/packages/runtime/src/api-endpoint-step.ts b/packages/runtime/src/api-endpoint-step.ts new file mode 100644 index 0000000000..f47890e24c --- /dev/null +++ b/packages/runtime/src/api-endpoint-step.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ENDPOINT DISPATCH STEP for declarative `apis:` endpoints (#5040 E3). + * + * ## Where this runs, and why not in `dispatch()` + * + * It runs inside the `IHttpServer.setFallbackHandler` seam that + * `dispatcher-plugin` installs — i.e. only for a request that matched NO + * registered route on the whole server (ADR-0076 D11: a fallback cannot shadow + * a route, by construction rather than by convention). It deliberately does + * NOT re-enter `HttpDispatcher.dispatch()`: that pipeline resolves an + * environment, an `executionContext` and an anonymous-deny gate, and ends in a + * SEMANTIC 404 — so routing every unmatched request through it would change + * what today's unmatched requests answer (a 404 could become a 401, and the + * bare Hono 404 body would become the `ROUTE_NOT_FOUND` envelope). #5090 keeps + * that collapse explicitly out of scope: a miss here writes NOTHING and the + * transport's existing unmatched answer stands, byte for byte. + * + * ## What it does today, and what it does not + * + * Today it answers **501 NOT_IMPLEMENTED** on a match. That is the honest + * report of the state of the executor: the endpoint IS declared and IS matched, + * and the thing that would run it lands in 17.x (#5040 E5). It is also + * structurally unreachable — a non-empty `apis:` is rejected at publish / + * validate until the E7 flip — so no deployment can observe it; the tests below + * drive `matchEndpoint` through a stub, exactly as #5040 §5 prescribes for + * every E-series unit that lands before the flip. + * + * What it does NOT do yet, so nobody reads more into it than is here: + * `rateLimit`, `authRequired`, `cacheTtl`, `inputMapping` / `outputMapping` + * (E4) and target execution — `object_operation` via `callData`, `flow` via the + * automation service (E5). Those insert BETWEEN the match and the answer, in + * the order #5040 §3 fixes. + */ + +import { DispatcherErrorCode } from '@objectstack/spec/api'; +import type { ApiEndpointMatch, IMetadataService } from '@objectstack/spec/contracts'; +import { apiErrorResponse } from './error-envelope.js'; + +/** + * The platform's single reserved carve-out segment for app-declared endpoints + * (ADR-0121 D1). A declared `path` is `/apps//`, the namespace segment derived from stack identity (D2) rather than + * authored freely. + * + * Spelled ONCE, here, and read by everything that needs it — the fallback's + * scoping test, and the tests that pin it. The E7 publish gate that rejects a + * path outside this shape lives in `packages/spec` and cannot import runtime; + * that is a deliberate second spelling on the other side of a package boundary, + * not a copy to keep in sync by hand (ADR-0121 makes the RULE the contract, so + * the two sides agree on a rule rather than on a list of prefixes). + */ +export const APP_ENDPOINT_SEGMENT = 'apps'; + +/** + * The URL prefix under which app-declared endpoints live for a deployment + * serving `runtimePrefix` (default `/api/v1`) — `/apps/`, trailing + * slash included so a path test cannot match a sibling like `/api/v1/appsx`. + */ +export function appEndpointMountPrefix(runtimePrefix: string): string { + return `${runtimePrefix.replace(/\/+$/, '')}/${APP_ENDPOINT_SEGMENT}/`; +} + +/** + * Whether a request path is even a CANDIDATE for the endpoint step. + * + * This is a routing question ("is it worth asking the metadata service about + * this path?"), not a validity question. The exact legal shape of a declared + * path — namespace segment derived from `manifest.namespace`, non-empty + * subpath — is enforced where it belongs, at publish (ADR-0121 D1, landing with + * #5040 E7). Re-deciding validity here would put a second, weaker copy of that + * rule in a consumer, which is how a runtime dialect starts. + */ +export function isAppEndpointPath(path: string, runtimePrefix: string): boolean { + const mount = appEndpointMountPrefix(runtimePrefix); + return path.startsWith(mount) && path.length > mount.length; +} + +/** What the step decided: an answer to write, or `undefined` for "not mine". */ +export interface AppEndpointStepAnswer { + status: number; + body: unknown; +} + +export interface AppEndpointStepInput { + /** Request method, as the transport reports it. */ + method: string; + /** Request path, as the transport reports it (prefix included). */ + path: string; + /** The deployment's dispatcher prefix — `DispatcherPluginConfig.prefix`. */ + prefix: string; + /** + * The `metadata` slot occupant, or `undefined` when the kernel has none. + * Passed in rather than resolved here so the caller owns service lookup + * (and so this module stays a pure function of its inputs, testable with a + * stub — #5089's real matcher is being built in parallel and this must not + * depend on its landing). + */ + metadataService: Pick | undefined; +} + +/** + * Run the endpoint step for one unmatched request. + * + * Returns `undefined` — meaning "write nothing, leave the transport's own + * unmatched answer alone" — in every case except a genuine match: + * + * - the path is not under `/apps/`; + * - the kernel has no `metadata` service; + * - the occupant of that slot carries no `matchEndpoint` (probed with + * `typeof === 'function'`, the contract's own convention — an + * implementation without an endpoint index simply omits it, and #5089's + * real matcher may land before or after this seam); + * - `matchEndpoint` reported a miss. + * + * A THROW from `matchEndpoint` is deliberately NOT swallowed: its contract + * states that an implementation which cannot read its store must throw rather + * than report a miss, precisely so an outage cannot masquerade as a 404. The + * caller turns it into a 5xx through the dispatcher's normal error exit. + */ +export async function runAppEndpointStep( + input: AppEndpointStepInput, +): Promise { + const { method, path, prefix, metadataService } = input; + + if (!isAppEndpointPath(path, prefix)) return undefined; + if (!metadataService || typeof metadataService.matchEndpoint !== 'function') return undefined; + + const match: ApiEndpointMatch | undefined = await metadataService.matchEndpoint({ path, method }); + if (!match) return undefined; + + return apiErrorResponse({ + code: DispatcherErrorCode.enum.NOT_IMPLEMENTED, + httpStatus: 501, + message: + `Declarative endpoint '${match.endpoint.name}' claims ${method} ${path}, but the endpoint ` + + 'executor is not enabled in this build. It lands in 17.x (#5040).', + extra: { + hint: 'The mounting seam is in place; execution (target dispatch, authRequired / rateLimit / ' + + 'cacheTtl / mappings) lands with #5040 E4–E5. Until then a non-empty `apis:` is rejected ' + + 'at publish, so no reachable deployment can produce this answer.', + }, + }); +} diff --git a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts new file mode 100644 index 0000000000..868e561765 --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The declarative-endpoint mount seam, through a REAL boot (#5040 E3 / #5090). + * + * `api-endpoint-step.test.ts` covers the decision; this file covers the + * WIRING — LiteKernel + the real Hono transport + the real dispatcher plugin, + * driven over a real socket. That distinction is the whole reason #4936 found + * two broken links at once: the old `handleApiEndpoint` branch was unit-tested + * by calling `dispatch()` directly, which is exactly the shortcut that hid the + * fact that nothing ever mounted the paths it claimed to serve. "Who serves + * this path" is a question about the composed runtime; ask it there. + * + * The load-bearing assertion in most of these is a NEGATIVE one: that adding + * this seam changed nothing for anybody. Today's unmatched answers — the bare + * 404 and the 405 + `Allow` — must come back byte for byte, since a stack + * cannot declare an endpoint at all until the E7 flip. + * + * NOTE on the body guarantee: that the fallback receives a READABLE `req.body` + * (the difference from the `use()` middleware seam) is a transport promise, and + * is asserted against the real adapter in + * `packages/plugins/plugin-hono-server/src/fallback-seam.test.ts`. Here the + * body-carrying case is driven end to end (a POST with JSON reaching the step) + * so the two halves are known to compose. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { LiteKernel, Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import type { ApiEndpointMatch, IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +/** Two declared endpoints in the ADR-0121 D1 shape (`/apps//…`). */ +const DECLARED: ApiEndpoint[] = [ + ApiEndpointSchema.parse({ + name: 'showcase_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + }), + ApiEndpointSchema.parse({ + name: 'showcase_purge_inquiries', + path: '/api/v1/apps/showcase/inquiries/purge', + method: 'POST', + type: 'flow', + target: 'showcase_inquiry_janitor', + }), +]; + +/** Every `matchEndpoint` query the boot made, in order. */ +const queries: Array<{ path: string; method: string }> = []; + +/** + * A `metadata` slot occupant. `withMatcher: false` is the #5089-not-landed + * shape — the member simply absent, which the contract says consumers probe for + * with `typeof === 'function'`. + */ +function fakeMetadataPlugin(options: { withMatcher: boolean; endpoints?: ApiEndpoint[] }): Plugin { + const endpoints = options.endpoints ?? DECLARED; + return { + name: 'com.objectstack.test.fake-metadata', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('metadata', { + list: async () => [], + ...(options.withMatcher + ? { + matchEndpoint: async (q: { path: string; method: string }): Promise => { + queries.push(q); + const hit = endpoints.find( + (e) => e.path === q.path.replace(/\/$/, '') && e.method === q.method.toUpperCase(), + ); + return hit ? { endpoint: hit, params: {} } : undefined; + }, + } + : {}), + }); + }, + }; +} + +/** Stands in for any plugin that mounts routes — here, one PUT-only path. */ +function routePlugin(): Plugin { + return { + name: 'com.objectstack.test.routes', + version: '1.0.0', + init: async () => { /* nothing */ }, + start: async (ctx: PluginContext) => { + const server = ctx.getService('http.server'); + server.put('/api/v1/apps/showcase/tasks', (_req, res) => { res.status(200); res.json({ from: 'route' }); }); + }, + }; +} + +async function boot(plugins: Plugin[]) { + const kernel = new LiteKernel(); + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + for (const plugin of plugins) kernel.use(plugin); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); + await kernel.bootstrap(); + const httpServer = kernel.getService('http.server'); + return { kernel, baseUrl: `http://127.0.0.1:${httpServer.getPort!()}` }; +} + +async function shutdown(kernel: LiteKernel | undefined) { + if (!kernel) return; + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); +} + +/** The transport's unmatched answer, captured from a boot with no seam armed. */ +const BARE_NOT_FOUND = { error: 'Not found' }; + +describe('metadata slot carries no matchEndpoint — a fully working passthrough', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + queries.length = 0; + ({ kernel, baseUrl } = await boot([fakeMetadataPlugin({ withMatcher: false })])); + }, 30_000); + afterAll(() => shutdown(kernel), 30_000); + + it('answers an endpoint-shaped path with the transport\'s own 404, unchanged', async () => { + const res = await fetch(`${baseUrl}/api/v1/apps/showcase/tasks`); + expect(res.status).toBe(404); + expect(await res.json()).toEqual(BARE_NOT_FOUND); + }); + + it('serves the dispatcher\'s own routes normally', async () => { + const res = await fetch(`${baseUrl}/.well-known/objectstack`); + expect(res.status).toBe(200); + }); +}); + +describe('no metadata service at all', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { ({ kernel, baseUrl } = await boot([])); }, 30_000); + afterAll(() => shutdown(kernel), 30_000); + + it('answers 404 rather than failing the request', async () => { + const res = await fetch(`${baseUrl}/api/v1/apps/showcase/tasks`); + expect(res.status).toBe(404); + expect(await res.json()).toEqual(BARE_NOT_FOUND); + }); +}); + +describe('matcher present — the endpoint dispatch step (#5090)', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + queries.length = 0; + ({ kernel, baseUrl } = await boot([fakeMetadataPlugin({ withMatcher: true }), routePlugin()])); + }, 30_000); + afterAll(() => shutdown(kernel), 30_000); + + it('answers a MATCH with 501 NOT_IMPLEMENTED in the declared envelope', async () => { + const res = await fetch(`${baseUrl}/api/v1/apps/showcase/tasks`); + expect(res.status).toBe(501); + const body = await res.json() as { success: boolean; error: Record }; + expect(body.success).toBe(false); + expect(body.error.code).toBe('NOT_IMPLEMENTED'); + expect(body.error.httpStatus).toBe(501); + expect(body.error.message).toContain('showcase_tasks'); + }); + + it('reaches the step for a POST carrying a JSON body', async () => { + const res = await fetch(`${baseUrl}/api/v1/apps/showcase/inquiries/purge`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ olderThanDays: 30 }), + }); + expect(res.status).toBe(501); + const body = await res.json() as { error: Record }; + expect(body.error.message).toContain('showcase_purge_inquiries'); + expect(queries).toContainEqual({ path: '/api/v1/apps/showcase/inquiries/purge', method: 'POST' }); + }); + + it('answers a MISS under the mount with the transport\'s 404, unchanged', async () => { + const res = await fetch(`${baseUrl}/api/v1/apps/showcase/nope`); + expect(res.status).toBe(404); + expect(await res.json()).toEqual(BARE_NOT_FOUND); + expect(queries).toContainEqual({ path: '/api/v1/apps/showcase/nope', method: 'GET' }); + }); + + it('never consults the matcher for a path outside the mount', async () => { + queries.length = 0; + const res = await fetch(`${baseUrl}/api/v1/nope`); + expect(res.status).toBe(404); + expect(await res.json()).toEqual(BARE_NOT_FOUND); + const notFoundOnRoot = await fetch(`${baseUrl}/totally/unrouted`); + expect(notFoundOnRoot.status).toBe(404); + expect(queries, 'the endpoint step ran for a path outside `/api/v1/apps/`').toEqual([]); + }); + + it('never shadows a registered route, even one under the mount prefix', async () => { + // The structural guarantee of using `notFound` rather than a + // `${prefix}/apps/*` wildcard: this PUT is registered by a plugin that + // starts BEFORE the dispatcher, and it still wins — while the SAME path + // under GET (which no route owns) reaches the endpoint step. + queries.length = 0; + const res = await fetch(`${baseUrl}/api/v1/apps/showcase/tasks`, { method: 'PUT' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ from: 'route' }); + expect(queries, 'the fallback ran for a request a registered route matched').toEqual([]); + }); + + it('keeps the 405 + `Allow` answer for a method mismatch', async () => { + // Hono routes a method mismatch to the same not-found sink, so the + // fallback sees these too. Declining must leave 405 intact — the + // baseline `notfound-405.test.ts` pins without a fallback installed. + const res = await fetch(`${baseUrl}/api/v1/apps/showcase/tasks`, { method: 'DELETE' }); + expect(res.status).toBe(405); + expect(res.headers.get('Allow')).toBe('PUT'); + const body = await res.json() as { code: string; allowed: string[] }; + expect(body.code).toBe('METHOD_NOT_ALLOWED'); + expect(body.allowed).toEqual(['PUT']); + }); + + it('answers 5xx — not 404 — when the matcher itself fails', async () => { + // `matchEndpoint`'s contract: an implementation that cannot read its + // store MUST throw, because a miss becomes a 404 and an outage must not + // masquerade as one. + const brokenMetadata: Plugin = { + name: 'com.objectstack.test.broken-metadata', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('metadata', { + matchEndpoint: async () => { throw new Error('metadata store unreachable'); }, + }); + }, + }; + const { kernel: k2, baseUrl: url2 } = await boot([brokenMetadata]); + try { + const res = await fetch(`${url2}/api/v1/apps/showcase/tasks`); + expect(res.status).toBeGreaterThanOrEqual(500); + const body = await res.json() as { success: boolean }; + expect(body.success).toBe(false); + } finally { + await shutdown(k2); + } + }, 30_000); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 1948247a64..1df03d6fbe 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -3,12 +3,13 @@ import { Plugin, PluginContext, IHttpServer } from '@objectstack/core'; import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { DispatcherErrorCode } from '@objectstack/spec/api'; -import type { IAuthService } from '@objectstack/spec/contracts'; +import type { IAuthService, IMetadataService } from '@objectstack/spec/contracts'; import type { CounterStore } from '@objectstack/plugin-auth'; import { HttpDispatcher, HttpDispatcherResult } from './http-dispatcher.js'; import { isServiceServeable } from './service-serveable.js'; import { validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js'; import { buildApiError } from './error-envelope.js'; +import { appEndpointMountPrefix, runAppEndpointStep } from './api-endpoint-step.js'; import { buildSecurityHeaders, createInboundRateLimitMiddleware, @@ -1240,6 +1241,88 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu ctx.logger.info('Dispatcher bridge routes registered', { prefix, enableProjectScoping, projectResolution }); + // ── Declarative endpoint mount seam (#5040 E3) ─────────────── + // The ONE path by which a metadata-declared `apis:` endpoint can + // ever reach a handler. It is installed as the server's LAST-RESORT + // handler, not as a `${prefix}/apps/*` wildcard route, and the + // difference is structural rather than stylistic: a wildcard route + // competes with every route registered after it and Hono resolves + // that by first-registration-wins across plugin `start()` order — + // the exact ADR-0076 D11 hazard. A fallback runs only once every + // explicitly registered route has missed, so it CANNOT shadow one, + // whatever order the plugins started in. + // + // Feature-detected: the member is optional on `IHttpServer`, and an + // adapter that cannot express a not-found hook simply omits it (see + // the contract in `@objectstack/spec/contracts`). Installed on the + // RAW server rather than the observability Proxy above, which wraps + // route registration only. + // + // A miss writes NOTHING. That is load-bearing: the transport's + // existing unmatched answer (404, or 405 + `Allow` for a method + // mismatch) then stands unchanged, so this seam costs today's + // callers nothing. Folding those bare 404s into the dispatcher's + // semantic `ROUTE_NOT_FOUND` envelope is a separate decision and + // deliberately NOT taken here (#5090). + if (typeof rawServer.setFallbackHandler === 'function') { + rawServer.setFallbackHandler(async (req: any, res: any) => { + try { + const answer = await runAppEndpointStep({ + method: req.method, + path: req.path, + prefix, + // Resolved PER REQUEST and never cached: during + // `start()` the `metadata` slot may still be filling, + // and recording "absent" as a verdict that outlives + // the moment is the #4771 defect class. A read-only + // probe per request is the sanctioned shape. + // + // It resolves this kernel's metadata service. A + // multi-tenant host serves each request from a + // per-environment kernel, and reaching THAT one means + // running the resolver + kernel swap `dispatch()` + // performs — which the executor needs anyway for its + // `executionContext`, and which therefore lands with + // it (#5040 E5). Nothing here reads data through the + // service: the step only probes and reports 501, so + // there is no wrong-environment answer to give. + metadataService: safeGetService(ctx, 'metadata'), + }); + // `undefined` = not an app-endpoint path, no matcher, or + // no declaration owns it. Writing nothing is how this + // handler says "not mine" (contract on setFallbackHandler). + if (!answer) return; + res.status(answer.status); + if (securityHeaders) { + for (const [k, v] of Object.entries(securityHeaders)) res.header(k, v); + } + res.json(answer.body); + } catch (err: any) { + // `matchEndpoint` throws when it cannot read its store — + // its contract says so explicitly, so that an outage + // cannot masquerade as a 404. Answer 5xx like any other + // dispatcher exit rather than degrading to not-found. + errorResponse(err, res); + } + }); + ctx.logger.info('Declarative endpoint dispatch step armed', { + mount: appEndpointMountPrefix(prefix), + // Said plainly so this line is never read as "endpoints work + // now": the seam is mounted, execution is not built yet. + executes: false, + }); + } else { + // `debug`, not `warn`: no stack can declare an endpoint yet (a + // non-empty `apis:` is rejected at publish until #5040 E7), so + // nothing is missing from any deployment today. When that flip + // lands, THIS is where absence must become loud — an adapter + // without the seam can never serve a declared endpoint. + ctx.logger.debug( + '[dispatcher] http.server exposes no `setFallbackHandler`; declarative endpoints ' + + 'would be unreachable on this transport.', + ); + } + // Resolve the authenticated user from a request's headers by // delegating to the AuthService's `getSession` API (better-auth // compatible). Returns a slim user shape that route handlers diff --git a/packages/runtime/src/error-envelope.conformance.test.ts b/packages/runtime/src/error-envelope.conformance.test.ts index d3ec5706c7..32bbd42419 100644 --- a/packages/runtime/src/error-envelope.conformance.test.ts +++ b/packages/runtime/src/error-envelope.conformance.test.ts @@ -236,6 +236,11 @@ describe('#3842 — no dispatcher module may reintroduce the drift', () => { // covering only the branches that existed when it was authored is how // four sites drifted into three parking spots. './security/inbound-rate-limit.ts', + // [#5090] The declarative-endpoint step writes a body from the + // `setFallbackHandler` seam — a sixth way onto this wire surface, and + // the first that answers a request no route matched. Listed the day it + // was written, for the same reason the line above was. + './api-endpoint-step.ts', ]; for (const file of MODULES) { diff --git a/packages/runtime/src/route-ledger.conformance.test.ts b/packages/runtime/src/route-ledger.conformance.test.ts index 2147bbfb97..ef5eef3c31 100644 --- a/packages/runtime/src/route-ledger.conformance.test.ts +++ b/packages/runtime/src/route-ledger.conformance.test.ts @@ -22,7 +22,7 @@ import { describe, it, expect } from 'vitest'; import { HttpDispatcher } from './http-dispatcher.js'; -import { ROUTE_LEDGER, LEGACY_CHAIN_PREFIXES } from './route-ledger.js'; +import { ROUTE_LEDGER, LEGACY_CHAIN_PREFIXES, NON_DISPATCH_MOUNT_PREFIXES } from './route-ledger.js'; /** Minimal kernel — enough for the constructor to register builtin domains. */ function fakeKernel(): any { @@ -51,11 +51,17 @@ describe('route ledger ↔ dispatcher domain registry', () => { ).toEqual([]); }); - it('every ledger domain is a live registry prefix or a pinned legacy branch', () => { + it('every ledger domain is a live registry prefix, a pinned legacy branch, or a pinned non-dispatch mount', () => { const live = registryPrefixes(); const legacy = new Set(LEGACY_CHAIN_PREFIXES); + // [#5090] The third source: prefixes `dispatcher-plugin` serves without + // going through `dispatch()` (today: the declarative-endpoint fallback + // seam). Neither the registry nor the if-chain can enumerate them, and + // pinning them in the list whose name says "legacy if-chain" would have + // made that list lie — the ledger's job is the opposite. + const nonDispatch = new Set(NON_DISPATCH_MOUNT_PREFIXES); const stale = [...new Set(ROUTE_LEDGER.map((e) => e.domain))].filter( - (d) => !live.has(d) && !legacy.has(d), + (d) => !live.has(d) && !legacy.has(d) && !nonDispatch.has(d), ); expect( stale, @@ -65,6 +71,32 @@ describe('route ledger ↔ dispatcher domain registry', () => { }); }); +describe('non-dispatch mounts (#5090)', () => { + it('every pinned non-dispatch mount has a ledger row', () => { + const ledgerDomains = new Set(ROUTE_LEDGER.map((e) => e.domain)); + const missing = NON_DISPATCH_MOUNT_PREFIXES.filter((p) => !ledgerDomains.has(p)); + expect( + missing, + `Non-dispatch mounts with no route-ledger entry: ${missing.join(', ')}.`, + ).toEqual([]); + }); + + it('the `/apps` carve-out is owned by nothing else (ADR-0121 D1)', () => { + // D1 rests on a factual claim about THIS repo — that `apps` is not a + // built-in prefix — and reserves it for app-declared endpoints on that + // basis. A future built-in domain mounted at `/apps` would silently shadow + // every declared endpoint, so the claim is pinned rather than trusted: the + // ADR's own conflict analysis ("端点撞内建域:不可能") is only true while + // this passes. + const live = registryPrefixes(); + const legacy = new Set(LEGACY_CHAIN_PREFIXES); + for (const prefix of NON_DISPATCH_MOUNT_PREFIXES) { + expect(live.has(prefix), `${prefix} is now a dispatcher domain prefix — ADR-0121 D1 needs revisiting`).toBe(false); + expect(legacy.has(prefix), `${prefix} is now a legacy-chain prefix — ADR-0121 D1 needs revisiting`).toBe(false); + } + }); +}); + // The client-instance direction — "every named client method actually exists" // — lives in packages/client/src/route-ledger-coverage.test.ts, next to the // SDK it introspects. It cannot live here: a runtime→client edge (package OR diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 98d65510c4..b891513fd6 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -13,9 +13,15 @@ * exist. A new route therefore lands with an explicit, reviewed disposition or * not at all. * - * SCOPE. Dispatcher (`http-dispatcher.ts`) routes only, expressed as + * SCOPE. Dispatcher (`http-dispatcher.ts`) routes, expressed as * dispatcher-internal `cleanPath` patterns (prepend `/api/v1` for the wire - * path). The REST server (`@objectstack/rest`) mounts a second, larger surface + * path) — plus, since #5090, the surfaces `dispatcher-plugin.ts` mounts on the + * host `IHttpServer` WITHOUT going through `dispatch()`, pinned in + * {@link NON_DISPATCH_MOUNT_PREFIXES}. There is exactly one of those (the + * declarative-endpoint fallback seam) and it is listed for the same reason + * everything else here is: a route surface this package serves and nobody + * reviewed the SDK disposition of is precisely what #3528 was. + * The REST server (`@objectstack/rest`) mounts a second, larger surface * (search, forms, reports, sharing rules, …) that the client also reaches; * that surface has its own ledger + guard since #3587 — * `packages/rest/src/rest-route-ledger.ts`. A third surface — services that @@ -107,6 +113,23 @@ export const LEGACY_CHAIN_PREFIXES = [ // catch-all placeholder. ] as const; +/** + * Prefixes this package serves from OUTSIDE `dispatch()` — mounted by + * `dispatcher-plugin.ts` straight onto the host `IHttpServer`, so neither the + * domain registry nor {@link LEGACY_CHAIN_PREFIXES} can enumerate them. + * + * One member, and it is a seam rather than a route table: `/apps` is the + * platform's reserved carve-out for metadata-declared endpoints (ADR-0121 D1), + * reached through the `setFallbackHandler` seam — see the ledger row. + * Deliberately NOT folded into `LEGACY_CHAIN_PREFIXES`: that list means + * "branches of the `dispatch()` if-chain not yet lifted into the registry", and + * this is not one. A pinned list whose name stops describing its contents is + * how a ledger note goes quietly false (#5078). + */ +export const NON_DISPATCH_MOUNT_PREFIXES = [ + '/apps', +] as const; + export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // ── ops probes ──────────────────────────────────────────────────────────── { route: 'GET /health', domain: '/health', disposition: 'server-only', note: 'liveness probe for orchestrators, not app traffic' }, @@ -248,6 +271,23 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: 'client sends recordId in the body — both server shapes honor it' }, { route: 'POST /actions/global/:action', domain: '/actions', disposition: 'sdk', client: 'actions.invokeGlobal' }, + // ── apps (declarative endpoints — the mount seam, NOT a dispatch() route) ── + // Read this row literally; it describes what is WIRED, not what is planned. + { route: '* /apps/**', domain: '/apps', disposition: 'dynamic', + note: 'ADR-0121 D1 reserved carve-out for metadata-declared `apis:` endpoints, whose paths are ' + + '`/apps//` and therefore not enumerable here. Served by neither ' + + 'the domain registry nor the dispatch() if-chain: dispatcher-plugin installs an ' + + '`IHttpServer.setFallbackHandler` (Hono `app.notFound`) that runs only after every registered ' + + 'route has missed, probes `metadata.matchEndpoint` for paths under this prefix, and — as of ' + + '#5090 — answers 501 NOT_IMPLEMENTED on a match. EXECUTION IS NOT WIRED: target dispatch and ' + + 'the authRequired / rateLimit / cacheTtl / mapping keys land with #5040 E4–E5. A miss (or an ' + + 'occupant of the metadata slot with no matchEndpoint) writes nothing, leaving the transport\'s ' + + '404/405 answer untouched. Structurally unreachable today: a non-empty `apis:` is rejected at ' + + 'publish until the #5040 E7 flip, so nothing can be declared for this seam to match. No SDK ' + + 'surface — app-declared endpoints are an external-integration channel (ADR-0121 D3), called by ' + + 'the integrator\'s own client, not by `@objectstack/client`', + }, + // ── misc legacy ─────────────────────────────────────────────────────────── { route: 'GET /openapi.json', domain: '/openapi.json', disposition: 'server-only', note: 'docs tooling; falls through when metadata service lacks a generator' }, // `* (unmatched)` / `/__api-endpoint` removed in #4936 — see LEGACY_CHAIN_PREFIXES