From f78a83e0cc05d1425822117f33aca27c81920c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 08:55:25 +0000 Subject: [PATCH] feat(runtime): envelope the dispatcher's two discovery bodies (#9813) Inherits the #9436 maintainer ruling (2026-08-18, option A) with its reason intact: machine-read discovery bodies are the envelope's core constituency and the migration is one additive key. Adds success: true to both GET /.well-known/objectstack and the REST-less GET {prefix}/discovery fallback, pins the shape in dispatcher-plugin.routes.test.ts, updates the two documenting pages, and grows check-route-envelope.mjs an enumerated fourth scan population for express-style res.json writes on an IHttpServer (dispatcher-plugin.ts: unenveloped 1, ratcheted to #9936; the discovery walk for the dialect is #9937). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa --- .../runtime-dispatcher-discovery-envelope.md | 28 ++++ content/docs/api/index.mdx | 2 +- .../docs/protocol/kernel/http-protocol.mdx | 11 +- .../src/dispatcher-plugin.routes.test.ts | 29 +++++ packages/runtime/src/dispatcher-plugin.ts | 14 +- scripts/check-route-envelope.mjs | 121 +++++++++++++++++- 6 files changed, 194 insertions(+), 11 deletions(-) create mode 100644 .changeset/runtime-dispatcher-discovery-envelope.md diff --git a/.changeset/runtime-dispatcher-discovery-envelope.md b/.changeset/runtime-dispatcher-discovery-envelope.md new file mode 100644 index 0000000000..574b2493d8 --- /dev/null +++ b/.changeset/runtime-dispatcher-discovery-envelope.md @@ -0,0 +1,28 @@ +--- +"@objectstack/runtime": minor +--- + +feat(runtime): the dispatcher's two discovery bodies join the response envelope (#9813) + + + +`GET /.well-known/objectstack` and the REST-less fallback `GET {prefix}/discovery` +answered `{ data: {discovery} }` with no `success` flag — one key short of the +declared `BaseResponseSchema` envelope. They now answer +`{ success: true, data: {discovery} }`. + +This inherits the #9436 maintainer ruling (2026-08-18, option A) on the hono +adapter's identical discovery bodies, with its reason intact: machine-read +discovery surfaces — SDK `connect()` fallback probes, codegen, AI clients — are +the envelope's core constituency, and the migration is one additive key. It is +deliberately not #9389's pre-auth exemption, which is a closed list of SPA-read +shell-bootstrap surfaces these bodies are not on. Readers that unwrapped +`body.data` keep working unchanged; envelope-aware readers that discriminate on +`success` now unwrap these routes correctly. diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index 13a54b5098..8bfba0d722 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -151,7 +151,7 @@ send an atomic batch or fall back to client-side sequencing, instead of probing ### `GET /.well-known/objectstack` -Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`, `features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does not. The client SDK's `connect()` tries `/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or the bare `body`. +Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its body is wrapped in the response envelope as `{ "success": true, "data": { ... } }` and includes fields (`name`, `environment`, `features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does not. The client SDK's `connect()` tries `/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or the bare `body`. **Service Status Values**: `available` (fully operational), `registered` (route declared but handler unverified — may return 501), `degraded` (partial functionality), `unavailable` (not installed), `stub` (placeholder that throws errors) diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index bf75732cdb..cd299fc01a 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -107,9 +107,9 @@ descriptor, each derived from what is actually registered — never hardcoded. S ### `GET /.well-known/objectstack` Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its -body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`, -`features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does -not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it +body is wrapped in the response envelope as `{ "success": true, "data": { ... } }` and +includes fields (`name`, `environment`, `features`, `locale`) that the +`@objectstack/rest`-served `/api/v1` response above does not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it answers with this shape whether or not REST is mounted. The client SDK's `connect()` tries `/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or the bare `body`. @@ -123,6 +123,7 @@ Host: api.acme.com **Response:** ```json { + "success": true, "data": { "name": "ObjectOS", "version": "1.0.0", @@ -225,8 +226,8 @@ dispatcher owns `/api/v1/discovery` as the fallback registrant, so that path and `/api/v1` is registered by `@objectstack/rest` alone and is not served at all). As soon as `@objectstack/rest` is mounted it takes `/api/v1/discovery` under the single-owner rule (ADR-0076 D11) and the two paths answer different *documents* — same schema, different -producers, so the envelope (`{ "data": … }` here, bare there) and the values differ even -though the key set no longer does. +producers, so the envelope (`{ "success": true, "data": … }` here, bare there) and the +values differ even though the key set no longer does. **Why discovery matters:** diff --git a/packages/runtime/src/dispatcher-plugin.routes.test.ts b/packages/runtime/src/dispatcher-plugin.routes.test.ts index 9bbe136b32..80a22a46e2 100644 --- a/packages/runtime/src/dispatcher-plugin.routes.test.ts +++ b/packages/runtime/src/dispatcher-plugin.routes.test.ts @@ -245,6 +245,35 @@ describe('createDispatcherPlugin — HTTP route registration', () => { } }); + // #9813 (inheriting the #9436 maintainer ruling, 2026-08-18, option A): + // machine-read discovery bodies answer the declared envelope. Both bodies + // used to be a bare `{ data }` — one key short of BaseResponseSchema — which + // objectui's envelope-discriminating readers (`typeof body.success === + // 'boolean' && 'data' in body`) mis-parsed. The pin is the exact top-level + // key set, not just `success`: an extra sibling key would be the strayKeys + // dialect arriving back. + it('envelopes both discovery bodies as { success: true, data }', async () => { + const { server, handlers } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server)); + + for (const route of ['GET /.well-known/objectstack', 'GET /api/v1/discovery']) { + const handler = handlers[route]; + expect(handler, `${route} should be registered`).toBeTypeOf('function'); + let body: any; + const res: any = { + header: () => {}, + json: (b: unknown) => { body = b; }, + }; + await handler({}, res); + expect(body?.success, `${route} success flag`).toBe(true); + expect(body?.data, `${route} data payload`).toBeDefined(); + // The payload stays under `data` — the flip was additive, nothing moved. + expect(body.data.routes, `${route} discovery document under data`).toBeDefined(); + expect(Object.keys(body).sort(), `${route} top-level keys`).toEqual(['data', 'success']); + } + }); + // ADR-0076 D11 / OQ#9 — single owner for ${prefix}/discovery. When the REST // plugin is registered on the same kernel it serves /api/v1/discovery itself; // which payload a client saw used to depend on plugin start order diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index d0b507ae62..52932bfd4a 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -772,7 +772,14 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // enabled" against a live server (cloud#152). The body is computed // fresh per request; the only staleness is the HTTP cache layer. res.header('Cache-Control', 'no-store'); - res.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); + // Enveloped (`{ success: true, data }`) under the #9436 maintainer + // ruling (2026-08-18, option A), inherited by #9813 — machine-read + // discovery bodies are the envelope's core constituency and the + // migration is one additive key. Deliberately NOT #9389's pre-auth + // exemption: that is a closed list of SPA-read surfaces, and this + // body is read by SDKs (`connect()`'s fallback probe), codegen and + // AI clients. Every measured reader tolerates the added key. + res.json({ success: true, data: await dispatcher.getDiscoveryInfo(prefix) }); }); // ── Discovery (versioned API path) ────────────────────────── @@ -796,9 +803,10 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } } // See the .well-known handler above: discovery must not be cached - // (mutable runtime config; cloud#152 stale `routes.mcp`). + // (mutable runtime config; cloud#152 stale `routes.mcp`), and the + // body is enveloped under the same #9436 ruling (via #9813). res.header('Cache-Control', 'no-store'); - res.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); + res.json({ success: true, data: await dispatcher.getDiscoveryInfo(prefix) }); }); } else { ctx.logger.info(`[Dispatcher] ${prefix}/discovery ceded to com.objectstack.rest.api (single owner)`); diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 8b60232907..6eeec6e736 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -681,6 +681,45 @@ const PLUGIN_ROUTE_MODULES = { }, }; +const EXPRESS_RESPONSE_RECEIVERS = new Set(['res']); + +/** + * Surface 4 (#9813): modules that write express-style `res.json(…)` responses + * on an `IHttpServer` — the dialect none of the other three surfaces can see. + * `dispatcher-plugin.ts` served the exact `{ data }` discovery shape #9436 was + * ruled on for a day with no counter anywhere, because it writes through `res` + * rather than a Hono context and returns nothing to a central sender. + * + * ⚠ ENUMERATED, not discovered. The other populations refuse an undeclared + * response-writing module; this one audits only the files named here, because + * a discovery walk for this dialect first needs a read/write discriminator — + * fetch's `Response.json()` is a zero-argument READ on the same receiver name + * (`const res = await fetch(…); await res.json()`), and 20 non-test files + * under packages/ carry the `res.json(` spelling today, most of them fetch + * readers. Growing the walk is #9937; adding a NEW express-style route module + * to this table is part of adding the module. + * + * Entries carry the same counters, `ratchet`/`exempt`/`note` grammar and + * audit (`auditPluginRouteModule`) as PLUGIN_ROUTE_MODULES — one grammar, two + * receiver dialects. + */ +const IHTTP_ROUTE_MODULES = { + // Ten bodies. The two discovery bodies (`/.well-known/objectstack`, + // unconditional, and the REST-less `${prefix}/discovery` fallback) were + // enveloped by #9813 under the #9436 maintainer ruling (2026-08-18, option + // A, inherited with its reason intact: machine-read discovery bodies are + // the envelope's core constituency and the migration is one additive key — + // deliberately NOT #9389's pre-auth exemption, whose closed SPA-read list + // these sites are not on). The two `{ success: false, error: buildApiError(…) }` + // exits are conformant, and five relayed bodies (`result.body`, + // `ANONYMOUS_DENY_BODY`, …) are deliberately invisible, as everywhere. + 'packages/runtime/src/dispatcher-plugin.ts': { + unenveloped: 1, + ratchet: '#9936 (envelope or rule on the SSE-fallback `{ events }` body)', + note: 'the streaming branch\'s JSON fallback — a transport whose `res` cannot stream gets the collected events as a bare `{ events }`, no `success` flag and the payload beside the envelope rather than under `data`. A different consumer population from the discovery bodies (callers that asked for an SSE stream), so #9813\'s inherited ruling does not reach it; #9936 carries the fork', + }, +}; + /** * Count the ways one plugin-route module's hand-built Hono bodies depart from * the declared envelope. @@ -688,10 +727,17 @@ const PLUGIN_ROUTE_MODULES = { * Only `.json(, …)` is judged — see the header on why * relayed bodies are deliberately invisible here. * + * `receivers` names the identifiers that count as a response receiver. The + * default is the Hono pair; passing `EXPRESS_RESPONSE_RECEIVERS` reads the + * express-style `res.json(…)` dialect instead (#9813) — same body grammar, + * different receiver, so the counters and their meanings are shared. + * * @param {string} source TypeScript source text. + * @param {string} fileName reported in sites. + * @param {Set} receivers identifiers judged as response receivers. * @returns {{bodies: number, unenveloped: number, errorWithoutMessage: number, errorCodeNotString: number, strayKeys: number, stringError: number, siblingCode: number, sites: Record}} */ -export function scanHonoRouteSource(source, fileName = 'plugin.ts') { +export function scanHonoRouteSource(source, fileName = 'plugin.ts', receivers = HONO_CONTEXT_RECEIVERS) { const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); const found = { bodies: 0, @@ -711,7 +757,7 @@ export function scanHonoRouteSource(source, fileName = 'plugin.ts') { ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'json' && ts.isIdentifier(node.expression.expression) && - HONO_CONTEXT_RECEIVERS.has(node.expression.expression.text) + receivers.has(node.expression.expression.text) ) { found.bodies += 1; const arg = node.arguments[0]; @@ -1397,6 +1443,31 @@ function audit() { } } + // ── The IHttpServer express-style modules (#9813) — enumerated, see table ── + const ihttpScans = {}; + for (const [file, declared] of Object.entries(IHTTP_ROUTE_MODULES)) { + let source; + try { + source = readFileSync(join(ROOT, file), 'utf8'); + } catch { + problems.push( + `${file}\n declared in IHTTP_ROUTE_MODULES but not found — moved or deleted?\n` + + ` Update the table.`, + ); + continue; + } + const got = scanHonoRouteSource(source, file, EXPRESS_RESPONSE_RECEIVERS); + if (got.bodies === 0) { + problems.push( + `${file}\n declared in IHTTP_ROUTE_MODULES but no longer writes an express-style\n` + + ` response (\`res.json(…)\`) — moved, deleted, or converted? Update the table.`, + ); + continue; + } + ihttpScans[file] = got; + problems.push(...auditPluginRouteModule(file, declared, got)); + } + if (problems.length) { console.error('✗ Route-envelope conformance (#3843)\n'); for (const p of problems) console.error(' ' + p + '\n'); @@ -1468,6 +1539,24 @@ function audit() { for (const [file, m] of pExempt) { console.log(` – exempt, closed at ${pCounts(m)}: ${file} — ${m.exempt}`); } + + const iEntries = Object.entries(IHTTP_ROUTE_MODULES); + const iRatcheted = iEntries.filter(([, m]) => m.ratchet); + const iExempt = iEntries.filter(([, m]) => m.exempt); + const iBodies = Object.values(ihttpScans).reduce((n, s) => n + s.bodies, 0); + console.log( + `✓ IHttpServer express-style modules — ${iEntries.length} module(s) audited ` + + `(ENUMERATED, not discovered — the walk is #9937), ` + + `${iBodies} hand-built body/bodies (count reported, NOT pinned): ` + + `${iEntries.length - iRatcheted.length - iExempt.length} conformant, ` + + `${iRatcheted.length} ratcheted, ${iExempt.length} exempt`, + ); + for (const [file, m] of iRatcheted) { + console.log(` ⚠ ratchet ${m.ratchet}: ${file} (${pCounts(m)}; ticks down only) — ${m.note}`); + } + for (const [file, m] of iExempt) { + console.log(` – exempt, closed at ${pCounts(m)}: ${file} — ${m.exempt}`); + } } // ── Self-test ──────────────────────────────────────────────────────────────── @@ -1712,6 +1801,34 @@ function selfTest() { p = scanHonoRouteSource(`res.status(404).json({ error: 'nope' });`); assert(p.bodies === 0, `a res.json write must not enter surface 3 → ${JSON.stringify(p)}`); + // ── Surface 4 (#9813): the same grammar under express receivers ─────────── + // + // The receiver set is the load-bearing difference: identical source reads as + // zero bodies under the default (Hono) receivers and as a bare body under + // EXPRESS_RESPONSE_RECEIVERS — which is exactly how dispatcher-plugin.ts's + // discovery bodies sat invisible beside a green surface 3. + const expressBare = `res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });`; + p = scanHonoRouteSource(expressBare, 'x.ts', EXPRESS_RESPONSE_RECEIVERS); + assert( + p.bodies === 1 && p.unenveloped === 1, + `a bare express body must count under express receivers → ${JSON.stringify(p)}`, + ); + assert( + scanHonoRouteSource(expressBare, 'x.ts').bodies === 0, + 'the same express body must stay invisible to the default (Hono) receivers', + ); + p = scanHonoRouteSource(`res.json({ success: true, data: await x() });`, 'x.ts', EXPRESS_RESPONSE_RECEIVERS); + assert( + p.bodies === 1 && p.unenveloped === 0, + `the enveloped flip must read conformant under express receivers → ${JSON.stringify(p)}`, + ); + // Relayed bodies are counted but never judged, in this dialect like the others. + p = scanHonoRouteSource(`res.json(result.body); res.json(ANONYMOUS_DENY_BODY);`, 'x.ts', EXPRESS_RESPONSE_RECEIVERS); + assert( + p.bodies === 2 && p.unenveloped === 0, + `relayed express bodies must count as bodies, never as counters → ${JSON.stringify(p)}`, + ); + // ── The #9389 ruling: an exemption is a CLOSED list ─────────────────────── // // The ruling's own load-bearing clause is that the boundary stays enumerated: