From b7f2ed8f5defed31bc8dbc0f2abe2603ce54ca25 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 05:48:44 +0000 Subject: [PATCH 1/2] fix(rest,runtime): mount five ledgered-but-dead routes and gate the class that hid them (#7526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three routes were in the ledgers, implemented in the dispatcher, and mounted by nobody — two of them answering a plausible 200 instead of a 404: * GET /meta/types fell into the /meta/:type catch-all and answered {"type":"types","items":[]}, shape-identical to /meta/zzz_not_a_type; * GET /meta/:type/:name/published fell into the compound-name route and answered a stub identical before publish AND for a bogus name — a route that structurally could not 404; * GET /meta/objects/:name/state/:field needs four path segments and REST's /meta registrations topped out at three, so it answered Hono's notFound. All three mount now, ahead of the catch-alls that were swallowing them, with the compound-name /published arity the SDK documents. The routes were the symptom. The ledgers are a DECLARATION and every guard built on them (#3563/#3587/#3636/#3642) reads their union as an OBSERVATION of what is mounted, so the audit chain was green on this class by construction. This adds the missing observation: a route-ledger <-> live-mount parity gate that boots a real server, reads the mount table off it, and asserts both directions. It consults no second hand-written list of what is mounted, and it PROBES reachability through the live router rather than checking presence in a table — a literal registered after a catch-all sibling is mounted and unreachable. IHttpServer grows two optional feature-detected members for it — getMountedRoutes() and resolveMountedRoute() — implemented by the Hono adapter. On its first run the gate found three more instances of the same class: /automation/actions, /automation/connectors and /automation/_status were ordered ahead of the /:name catch-all inside dispatch(), with a comment calling the order load-bearing, while the bridge that mounts /automation registered /:name and never those three. It also found the unledgered live mounts — the four /api/settings routes get a ledger of their own, and GET /.well-known/objectstack plus the object-less POST /actions//:action get rows in the dispatcher ledger. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DnUXMJxLDLoQ9MTRthtAGd --- .changeset/route-ledger-live-mount-parity.md | 47 +++ .../client/src/client-url-conformance.test.ts | 7 +- .../plugins/plugin-hono-server/src/adapter.ts | 62 ++++ .../src/mounted-route-introspection.test.ts | 158 +++++++++ .../meta-types-create-seed.dogfood.test.ts | 41 ++- ...e-ledger-live-mount-parity.dogfood.test.ts | 319 ++++++++++++++++++ .../src/meta-route-registration-order.test.ts | 134 ++++++++ packages/rest/src/rest-route-ledger.ts | 33 ++ packages/rest/src/rest-server.ts | 233 ++++++++++++- packages/runtime/src/dispatcher-plugin.ts | 41 +++ packages/runtime/src/route-ledger.ts | 60 +++- .../src/settings-route-ledger.ts | 75 ++++ packages/spec/src/contracts/http-server.ts | 74 ++++ 13 files changed, 1269 insertions(+), 15 deletions(-) create mode 100644 .changeset/route-ledger-live-mount-parity.md create mode 100644 packages/plugins/plugin-hono-server/src/mounted-route-introspection.test.ts create mode 100644 packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts create mode 100644 packages/rest/src/meta-route-registration-order.test.ts create mode 100644 packages/services/service-settings/src/settings-route-ledger.ts diff --git a/.changeset/route-ledger-live-mount-parity.md b/.changeset/route-ledger-live-mount-parity.md new file mode 100644 index 0000000000..895ecc9510 --- /dev/null +++ b/.changeset/route-ledger-live-mount-parity.md @@ -0,0 +1,47 @@ +--- +"@objectstack/rest": patch +"@objectstack/runtime": patch +"@objectstack/plugin-hono-server": patch +"@objectstack/spec": patch +"@objectstack/service-settings": patch +--- + +Mount five ledgered-but-dead routes, and gate the class that hid them (#7526) + +Three routes shipped in the ledgers, implemented in the dispatcher, and mounted +by nobody. Two of them answered a plausible `200` rather than a 404, which is +worse: `GET /meta/types` fell into the `/meta/:type` catch-all and returned +`{"type":"types","items":[]}`, shape-identical to `/meta/zzz_not_a_type`, and +`GET /meta/:type/:name/published` fell into the compound-name route and +returned a stub identical before publish **and for a name that does not exist** +— a route that structurally could not 404. `GET /meta/objects/:name/state/:field` +was the honest one: REST's `/meta` registrations topped out at three path +segments and it needs four, so it answered Hono's `notFound`. All three now +mount, `published` 404s for a bogus name, and the compound-name arity the SDK +documents (`getPublished('lead', 'views/all_leads')`) mounts with it. + +The routes were the symptom. The route ledgers are a DECLARATION and every +guard built on them (#3563 / #3587 / #3636 / #3642) reads that union as an +OBSERVATION of what is mounted, so the whole audit chain was green on this +class by construction — `/meta/objects/:name/state/:field` counted as mounted +because it was ledgered. This adds the missing observation: a route-ledger ↔ +live-mount parity gate that boots a real server, reads the mount table off it, +and asserts both directions — every ledgered route reachably mounted, every +mounted route ledgered. It never consults a second hand-written list of what is +mounted, and it PROBES reachability through the live router rather than +checking presence in a table, because a literal route registered after a +catch-all sibling is mounted and unreachable. + +`IHttpServer` grows two optional, feature-detected members for it — +`getMountedRoutes()` (the live mount table, in registration order) and +`resolveMountedRoute(method, path)` (which registration answers a concrete +request, per the router itself) — implemented by the Hono adapter. + +The gate found three more instances of the same class on its first run: +`GET /automation/actions`, `/automation/connectors` and `/automation/_status` +were ordered ahead of the `/:name` catch-all inside `dispatch()`, with a +comment saying the order was load-bearing, while the bridge that actually +mounts `/automation` registered `/:name` and never those three. They now mount. +It also found the unledgered live mounts: the four `/api/settings` routes get a +ledger of their own, and `GET /.well-known/objectstack` and the object-less +`POST /actions//:action` get rows in the dispatcher ledger. diff --git a/packages/client/src/client-url-conformance.test.ts b/packages/client/src/client-url-conformance.test.ts index ac618fb5f6..07e5965c30 100644 --- a/packages/client/src/client-url-conformance.test.ts +++ b/packages/client/src/client-url-conformance.test.ts @@ -109,7 +109,12 @@ function compile(route: string, prefix: string, source: string): Pattern[] { } const PATTERNS: Pattern[] = [ - ...ROUTE_LEDGER.map((r) => r.route).filter((r) => !UNUSABLE_ROWS.has(r)).flatMap((r) => compile(r, '/api/v1', 'dispatcher')), + // `absolute` rows carry their own wire path and must NOT be prefixed — + // `/.well-known/*` lives at the site root by definition, and prefixing it + // here would compile a pattern nothing serves, i.e. certify a URL that does + // not exist (#7526). + ...ROUTE_LEDGER.filter((r) => !UNUSABLE_ROWS.has(r.route)) + .flatMap((r) => compile(r.route, r.absolute ? '' : '/api/v1', 'dispatcher')), ...REST_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'rest')), ...STORAGE_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'storage')), ...I18N_ROUTE_LEDGER.map((r) => r.route).flatMap((r) => compile(r, '', 'i18n')), diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 7e948b4519..57edba4859 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -560,6 +560,68 @@ export class HonoHttpServer implements IHttpServer { this.app.patch(path, this.wrap(handler)); } + /** + * The LIVE mount table — every `(method, pattern)` this server registered, + * in registration order. See `IHttpServer.getMountedRoutes` for the + * contract; the ordering guarantee is load-bearing and honoured here + * because {@link registeredRoutes} is appended to inside the verb methods, + * on the same call that reaches `this.app`. + * + * A COPY, not the live array: a consumer of an OBSERVATION must not be able + * to edit the thing observed. + */ + getMountedRoutes(): ReadonlyArray<{ method: string; pattern: string }> { + return this.registeredRoutes.map((r) => ({ ...r })); + } + + /** + * Ask the LIVE Hono router which registered route actually answers a + * concrete request — see `IHttpServer.resolveMountedRoute` for why + * "is it in the table" is not the same question. + * + * Implemented against `app.router.match()`, i.e. the very router object + * that serves production traffic, so the answer is Hono's own and cannot + * drift from it. `match()` returns the matched handlers in the order Hono + * would run them — middleware included — so this walks that list and takes + * the first entry whose `RouterRoute` corresponds to a route THIS adapter + * registered. Middleware and the raw-app catch-alls (static / SPA) are + * absent from {@link registeredRoutes} by construction, so they are skipped + * rather than mistaken for the answer. + * + * `undefined` means the router matched no registered route at all — the + * request would reach the `notFound` sink (404, or 405 via + * {@link allowedMethodsForPath}). + */ + resolveMountedRoute(method: string, path: string): { method: string; pattern: string } | undefined { + const router: any = (this.app as any)?.router; + if (!router || typeof router.match !== 'function') return undefined; + const verb = method.toUpperCase(); + let matched: any; + try { + matched = router.match(verb, path); + } catch { + // A router that cannot answer is not evidence that nothing is + // mounted — say "unknown" rather than invent a verdict. + return undefined; + } + const handlers = Array.isArray(matched) ? matched[0] : undefined; + if (!Array.isArray(handlers)) return undefined; + for (const entry of handlers) { + // Each entry is `[[handler, RouterRoute], paramIndexMap]`; the + // RouterRoute carries the very `path` / `method` strings Hono was + // registered with. + const route = Array.isArray(entry) && Array.isArray(entry[0]) ? entry[0][1] : undefined; + const pattern = typeof route?.path === 'string' ? route.path : undefined; + const routeMethod = typeof route?.method === 'string' ? route.method.toUpperCase() : undefined; + if (!pattern || !routeMethod) continue; + const own = this.registeredRoutes.find( + (r) => r.pattern === pattern && r.method === routeMethod, + ); + if (own) return { ...own }; + } + return undefined; + } + /** * The HTTP methods registered for a concrete request `path`, ignoring the * request's own method. Empty when no registered route matches the path at diff --git a/packages/plugins/plugin-hono-server/src/mounted-route-introspection.test.ts b/packages/plugins/plugin-hono-server/src/mounted-route-introspection.test.ts new file mode 100644 index 0000000000..fb8ef4479d --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/mounted-route-introspection.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `getMountedRoutes()` / `resolveMountedRoute()` — the adapter's answer to + * "what did this server REALLY mount, and which of it can actually be + * reached?" (#7526). + * + * WHY THIS EXISTS. Four route ledgers declare the platform's HTTP surface and + * every guard built on them reads that union as an observation of what is + * mounted. Three routes shipped ledgered-but-unmounted in one build because a + * declaration cannot audit itself. The parity gate that closes it + * (`packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts`) + * rests entirely on the two members exercised here, so they get their own pins + * rather than being trusted implicitly by the gate that uses them. + * + * The first `describe` below is the MEASUREMENT the `/meta` fix is argued + * from. `rest-server.ts` registers `/meta/types` before `/meta/:type` and says + * in a comment that registering it after would silently break the route. That + * claim is about Hono's matching, not about ObjectStack, and a comment + * asserting third-party behaviour with no test under it is how the original + * defect stayed invisible. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HonoHttpServer } from './adapter'; + +const noop = () => {}; + +describe('Hono is first-match-wins (the premise the /meta registration order rests on)', () => { + it('a literal route registered AFTER a :param sibling never runs', async () => { + const server = new HonoHttpServer(0); + server.get('/api/v1/meta/:type', (_req, res: any) => res.json({ answered: ':type' })); + server.get('/api/v1/meta/types', (_req, res: any) => res.json({ answered: 'types' })); + + const app = server.getRawApp(); + const body = await (await app.request('/api/v1/meta/types')).json(); + + // The disguise this whole card is about: a 200 whose body is the + // catch-all's, indistinguishable from "that type is empty". + expect(body).toEqual({ answered: ':type' }); + // …and the live router agrees about WHY, which is the fact the gate reads. + expect(server.resolveMountedRoute('GET', '/api/v1/meta/types')) + .toEqual({ method: 'GET', pattern: '/api/v1/meta/:type' }); + }); + + it('the same literal registered BEFORE the :param sibling wins', async () => { + const server = new HonoHttpServer(0); + server.get('/api/v1/meta/types', (_req, res: any) => res.json({ answered: 'types' })); + server.get('/api/v1/meta/:type', (_req, res: any) => res.json({ answered: ':type' })); + + const app = server.getRawApp(); + expect(await (await app.request('/api/v1/meta/types')).json()).toEqual({ answered: 'types' }); + expect(server.resolveMountedRoute('GET', '/api/v1/meta/types')) + .toEqual({ method: 'GET', pattern: '/api/v1/meta/types' }); + // The catch-all still serves everything it used to. + expect(await (await app.request('/api/v1/meta/zzz')).json()).toEqual({ answered: ':type' }); + }); +}); + +describe('HonoHttpServer.getMountedRoutes', () => { + it('reports every registered (method, pattern) in registration order, spelled as passed', () => { + const server = new HonoHttpServer(0); + server.get('/api/v1/meta/types', noop); + server.get('/api/v1/meta/:type', noop); + server.put('/api/v1/meta/:type/:name', noop); + server.delete('/api/v1/meta/:type/:name', noop); + + expect(server.getMountedRoutes()).toEqual([ + { method: 'GET', pattern: '/api/v1/meta/types' }, + { method: 'GET', pattern: '/api/v1/meta/:type' }, + { method: 'PUT', pattern: '/api/v1/meta/:type/:name' }, + { method: 'DELETE', pattern: '/api/v1/meta/:type/:name' }, + ]); + }); + + it('hands back a copy — a consumer cannot edit the thing it is observing', () => { + const server = new HonoHttpServer(0); + server.get('/api/v1/health', noop); + + const first = server.getMountedRoutes() as Array<{ method: string; pattern: string }>; + first.push({ method: 'GET', pattern: '/api/v1/invented' }); + first[0]!.pattern = '/api/v1/rewritten'; + + expect(server.getMountedRoutes()).toEqual([{ method: 'GET', pattern: '/api/v1/health' }]); + }); + + it('excludes middleware and the fallback seam — this answers "what did I register", not "what might respond"', () => { + const server = new HonoHttpServer(0); + server.use('/api/v1/*', (async (_req: any, _res: any, next: any) => next()) as any); + server.setFallbackHandler(noop); + server.get('/api/v1/health', noop); + + expect(server.getMountedRoutes()).toEqual([{ method: 'GET', pattern: '/api/v1/health' }]); + }); +}); + +describe('HonoHttpServer.resolveMountedRoute', () => { + it('resolves a concrete path to the pattern that would answer it', () => { + const server = new HonoHttpServer(0); + server.get('/api/v1/meta/:type/:name/published', noop); + server.get('/api/v1/meta/:type/:section/:name', noop); + + expect(server.resolveMountedRoute('GET', '/api/v1/meta/object/lead/published')) + .toEqual({ method: 'GET', pattern: '/api/v1/meta/:type/:name/published' }); + expect(server.resolveMountedRoute('GET', '/api/v1/meta/object/views/all_leads')) + .toEqual({ method: 'GET', pattern: '/api/v1/meta/:type/:section/:name' }); + }); + + it('returns a pattern string identical to the getMountedRoutes entry (the gate compares them with ===)', () => { + const server = new HonoHttpServer(0); + server.get('/api/v1/meta/objects/:name/state/:field', noop); + + const resolved = server.resolveMountedRoute('GET', '/api/v1/meta/objects/lead/state/status'); + expect(server.getMountedRoutes().some( + (r) => r.method === resolved?.method && r.pattern === resolved?.pattern, + )).toBe(true); + }); + + it('is undefined when no registered route matches — the request would reach the notFound sink', () => { + const server = new HonoHttpServer(0); + server.get('/api/v1/meta/:type', noop); + + expect(server.resolveMountedRoute('GET', '/api/v1/nothing/here')).toBeUndefined(); + // A method mismatch on an existing PATH is also "no registered route + // answers", which is exactly what the 405 machinery is for. + expect(server.resolveMountedRoute('POST', '/api/v1/meta/object')).toBeUndefined(); + }); + + it('skips middleware and the fallback: neither is a route this adapter registered', () => { + const server = new HonoHttpServer(0); + server.use('/api/v1/*', (async (_req: any, _res: any, next: any) => next()) as any); + server.get('/api/v1/meta/:type', noop); + server.setFallbackHandler(noop); + + expect(server.resolveMountedRoute('GET', '/api/v1/meta/object')) + .toEqual({ method: 'GET', pattern: '/api/v1/meta/:type' }); + // A path only the fallback could answer resolves to nothing — the + // fallback is not a mount, and a gate must not read it as one. + expect(server.resolveMountedRoute('GET', '/api/v1/apps/acme/webhook')).toBeUndefined(); + }); + + it('does not run the handler it resolves', () => { + const handler = vi.fn(); + const server = new HonoHttpServer(0); + server.get('/api/v1/meta/:type', handler); + + server.resolveMountedRoute('GET', '/api/v1/meta/object'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('is case-insensitive on the verb, so a ledger row\'s spelling cannot change the verdict', () => { + const server = new HonoHttpServer(0); + server.post('/api/v1/meta/_migrate-stored', noop); + + expect(server.resolveMountedRoute('post', '/api/v1/meta/_migrate-stored')) + .toEqual({ method: 'POST', pattern: '/api/v1/meta/_migrate-stored' }); + }); +}); diff --git a/packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts b/packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts index a281a9d078..01c6af7444 100644 --- a/packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts +++ b/packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts @@ -6,6 +6,15 @@ // registry response, so consumers derive their create defaults from the spec // instead of re-inventing them (the drift that produced the dashboard-`layout` // and action-`body` create-save 422s). Exercised end-to-end over real HTTP. +// +// [#7526] This header said `/meta/types` from the day it was written while the +// call below asked `/meta` — so the file documented coverage of a path it never +// touched, and `/meta/types` was in fact dead (swallowed by the `/meta/:type` +// catch-all, answering `{"type":"types","items":[]}` for anyone who called it). +// A test whose comment describes a route it does not call is worse than no +// test: it is what someone reads when they ask "is this covered?". It reads +// `/meta/types` now, and pins the two paths against each other so the alias +// cannot rot back apart. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -20,7 +29,7 @@ describe('dogfood: /meta/types exposes authoritative create seeds (spec-derived beforeAll(async () => { stack = await bootStack(showcaseStack); token = await stack.signIn(); - const res = await stack.apiAs(token, 'GET', '/meta'); // GET {prefix} lists all metadata types (entries[]) + const res = await stack.apiAs(token, 'GET', '/meta/types'); expect(res.status).toBe(200); const body = (await res.json()) as { entries?: Array> }; entries = body.entries ?? []; @@ -61,4 +70,34 @@ describe('dogfood: /meta/types exposes authoritative create seeds (spec-derived expect(entry.createSeed, `${type} entry is missing its create seed`).toEqual(getMetadataCreateSeed(type)); } }); + + // [#7526] `/meta/types` and `/meta` are ONE handler at two paths, and this is + // the assertion that keeps that true. It is also the regression pin for the + // defect: before the fix `/meta/types` answered `{type:'types', items:[]}` — + // the `/meta/:type` catch-all's shape, with no `entries` at all — so this + // comparison could not have passed no matter how the bodies were compared. + it('answers the same body as GET /meta — the two paths are one handler', async () => { + const [viaTypes, viaBase] = await Promise.all([ + stack.apiAs(token, 'GET', '/meta/types'), + stack.apiAs(token, 'GET', '/meta'), + ]); + expect(viaTypes.status).toBe(200); + expect(viaBase.status).toBe(200); + expect(await viaTypes.json()).toEqual(await viaBase.json()); + }, 30_000); + + // The disguise, pinned: an unknown type answers the catch-all's shape, and + // `/meta/types` must NOT look like that. Without this, a future registration + // that puts `/meta/types` back under `/meta/:type` would still return 200 and + // every assertion above would fail with a confusing "entries is empty". + it('is not the /meta/:type catch-all wearing a 200', async () => { + const bogus = await stack.apiAs(token, 'GET', '/meta/zzz_not_a_type'); + expect(bogus.status).toBe(200); + expect(await bogus.json()).toEqual({ type: 'zzz_not_a_type', items: [] }); + + const real = await stack.apiAs(token, 'GET', '/meta/types'); + const body = (await real.json()) as Record; + expect(body.items).toBeUndefined(); + expect(Array.isArray(body.entries)).toBe(true); + }, 30_000); }); diff --git a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts new file mode 100644 index 0000000000..505d64a14f --- /dev/null +++ b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts @@ -0,0 +1,319 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ROUTE-LEDGER ↔ LIVE-MOUNT PARITY GATE (#7526) +// +// ## What this exists to catch, and why nothing else could +// +// Five route ledgers DECLARE the platform's HTTP surface, and every guard +// built on them (#3563 / #3587 / #3636 / #3642) reads the union of those +// declarations as its source of truth for "what is mounted". That is the +// assumption three defects in one build falsified: +// +// * `GET /meta/objects/:name/state/:field` was IN `route-ledger.ts`, so the +// SDK URL guard passed the method that calls it — and the route answered +// Hono's `notFound`, byte-identical to an unmounted-path control. +// * `GET /meta/:type/:name/published` fell into the compound-name route and +// answered a stub identical before publish AND for a bogus name. +// * `GET /meta/types` fell into the `/meta/:type` catch-all and answered +// `{"type":"types","items":[]}`, shape-identical to `/meta/zzz_not_a_type`. +// +// The ledger is a DECLARATION; those guards read it as an OBSERVATION. So the +// whole audit chain was green on this class by construction and would have +// stayed green for every future instance. This gate supplies the missing +// observation: it boots a real server and reads the mount table off it. +// +// ## The two rules that make it honest +// +// 1. THE MOUNTED SIDE IS NEVER HAND-MAINTAINED. Patterns come from +// `IHttpServer.getMountedRoutes()` — the adapter's record of what it was +// actually asked to register, populated on the same call that reaches the +// router. A second hand-written list of "what we mount" would drift +// exactly the way the ledgers did, which is the defect, not the fix. +// +// 2. REGISTRATION IS NOT REACHABILITY. On a first-match router a literal +// route registered after a catch-all sibling is mounted and unreachable — +// that is defect #3 above, and being in the table would have "passed" it. +// So every row is PROBED: a concrete path is built from the pattern and +// `IHttpServer.resolveMountedRoute()` asks the live router which +// registration would answer it. The row passes only when the router names +// the row's own pattern back. +// +// ## What a boot can and cannot observe (read before adding a pin) +// +// The mount table records what went through the `IHttpServer` port. Two +// surfaces deliberately do not: +// +// * `/api/v1/auth/*` — `plugin-auth` mounts one `rawApp.all()` catch-all on +// Hono directly. It is not unaudited: `auth-route-ledger.conformance.test.ts` +// checks all ~129 endpoints against better-auth's LIVE `auth.api` table, +// which is already an observation of the real thing. AUTH_ROUTE_LEDGER is +// therefore not one of this gate's inputs. +// * `* /apps/**` — the ADR-0121 declarative-endpoint carve-out is a +// `setFallbackHandler` seam, structurally not a route (that is the point of +// it), so it can never appear in a mount table. +// +// Everything else absent from this boot is absent because a plugin was not +// registered, and that is pinned below with a reason — see +// {@link UNEXERCISED_BY_THIS_BOOT}. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { StorageServicePlugin } from '@objectstack/service-storage'; + +import { ROUTE_LEDGER } from '../../../runtime/src/route-ledger'; +import { REST_ROUTE_LEDGER } from '../../../rest/src/rest-route-ledger'; +import { STORAGE_ROUTE_LEDGER } from '../../../services/service-storage/src/storage-route-ledger'; +import { I18N_ROUTE_LEDGER } from '../../../services/service-i18n/src/i18n-route-ledger'; +import { SETTINGS_ROUTE_LEDGER } from '../../../services/service-settings/src/settings-route-ledger'; + +/** One ledger row, normalized to a wire pattern this gate can probe. */ +interface LedgerRow { + /** Which ledger it came from — for the diff message. */ + ledger: string; + /** `GET`, `POST`, … or `*` for a wildcard family row. */ + method: string; + /** Full wire pattern, e.g. `/api/v1/meta/:type/:name/published`. */ + pattern: string; + /** The row as written, for error messages. */ + raw: string; + /** A broader mounted pattern this row is a declared specialization of. */ + servedBy?: string; +} + +const DISPATCHER_PREFIX = '/api/v1'; + +function collectLedgerRows(): LedgerRow[] { + const rows: LedgerRow[] = []; + const add = (ledger: string, route: string, prefix: string, servedBy?: string) => { + const sp = route.indexOf(' '); + const method = route.slice(0, sp); + // A trailing `?` marks an OPTIONAL param in the dispatcher's own dialect + // (`/ui/view/:object/:type?`); the serving mount spells the required form. + const pattern = (prefix + route.slice(sp + 1)).replace(/\?$/, ''); + rows.push({ ledger, method, pattern, raw: route, ...(servedBy ? { servedBy } : {}) }); + }; + + for (const r of ROUTE_LEDGER) { + add('runtime/route-ledger.ts', r.route, r.absolute ? '' : DISPATCHER_PREFIX, r.servedBy); + } + for (const r of REST_ROUTE_LEDGER) add('rest/rest-route-ledger.ts', r.route, ''); + for (const r of STORAGE_ROUTE_LEDGER) add('service-storage/storage-route-ledger.ts', r.route, ''); + for (const r of I18N_ROUTE_LEDGER) add('service-i18n/i18n-route-ledger.ts', r.route, ''); + for (const r of SETTINGS_ROUTE_LEDGER) add('service-settings/settings-route-ledger.ts', r.route, ''); + return rows; +} + +/** + * Rows this boot structurally cannot observe, each with the reason. + * + * ⚠️ READ THIS BEFORE ADDING A LINE. A pin is NOT "this route is allowed to be + * missing" — it is "this boot cannot see it", and the gate asserts BOTH + * directions of that claim: a pinned row that turns out to be reachable fails + * too, so the set can only shrink by accident and never grow by accident. The + * moment a pin starts meaning "we know it is broken", it has become the + * declaration-instead-of-observation this whole file exists to end. A route + * that is broken gets fixed or gets an issue, not a line here. + */ +const UNEXERCISED_BY_THIS_BOOT: Record = { + '* /api/v1/auth/**': + 'plugin-auth mounts one rawApp.all() catch-all on Hono directly, so no auth route ever passes through the IHttpServer port. Audited against better-auth\'s live auth.api table by auth-route-ledger.conformance.test.ts instead', + '* /api/v1/apps/**': + 'the ADR-0121 declarative-endpoint carve-out is a setFallbackHandler seam, not a route — being invisible to a route table is the property that makes it incapable of shadowing one (#5040 §1-C)', + 'POST /api/v1/packages/publish': + 'the marketplace publish registrar mounts only when a `package` service occupies the slot (direct-mount-composition.ts); the showcase registers none. Its presence half is already guarded by rest-route-ledger.conformance.test.ts against a capably-mocked RestServer', +}; + +/** Segments a probe path uses for `:params` — must match no literal segment. */ +function probePath(pattern: string): string { + let i = 0; + return pattern + .split('/') + .map((seg) => (seg.startsWith(':') ? `__parity_probe_${i++}` : seg)) + .join('/'); +} + +/** `* /api/v1/ai/**` → `/api/v1/ai`. */ +function wildcardPrefix(pattern: string): string { + return pattern.replace(/\/\*+$/, ''); +} + +const isWildcardRow = (row: LedgerRow) => row.pattern.includes('*'); + +interface Mounted { method: string; pattern: string } + +describe('route ledger ↔ live mount parity (#7526)', () => { + let stack: VerifyStack; + let server: { + getMountedRoutes?(): ReadonlyArray; + resolveMountedRoute?(method: string, path: string): Mounted | undefined; + }; + let mounted: ReadonlyArray; + let ledgerRows: LedgerRow[]; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { + // Boot as WIDE as this gate can, so as few rows as possible have to be + // pinned: every plugin added here converts a pin into a measurement. + // `StorageServicePlugin` alone converts ten — the whole storage ledger, + // which the lean default boot leaves entirely unobserved. + // + // NOT `automation: true`, and the reason is worth writing down: the + // dispatcher bridge mounts `/automation/*` UNCONDITIONALLY (mounting is + // this gate's whole subject; whether a service answers is not), while + // `automation: true` makes the showcase's declared `rest` connector a + // hard boot failure without the connector plugins. Turning it on would + // buy zero routes and cost the gate its ability to boot. + extraPlugins: [new StorageServicePlugin()], + }); + server = await (stack.kernel as unknown as { + getServiceAsync(n: string): Promise; + }).getServiceAsync('http-server'); + ledgerRows = collectLedgerRows(); + + // FAIL, never skip. A parity gate that quietly passes because it could not + // look is the exact failure mode it was built to end. + expect( + typeof server.getMountedRoutes, + 'the booted http-server exposes no getMountedRoutes() — this gate cannot observe anything and must not pass', + ).toBe('function'); + expect( + typeof server.resolveMountedRoute, + 'the booted http-server exposes no resolveMountedRoute() — reachability is unprobeable and mounted-but-shadowed routes would pass', + ).toBe('function'); + + mounted = server.getMountedRoutes!(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('observes a non-trivial mount table (a boot that mounted nothing would pass every other assertion)', () => { + expect(mounted.length).toBeGreaterThan(100); + }); + + // ── Direction 1: every ledgered route is REACHABLY mounted ──────────────── + it('every ledgered route is reachably mounted', () => { + const failures: string[] = []; + + for (const row of ledgerRows) { + const pinKey = `${row.method} ${row.pattern}`; + if (pinKey in UNEXERCISED_BY_THIS_BOOT) continue; + + if (isWildcardRow(row)) { + // A `**` row claims a PREFIX FAMILY, not a resolvable route, so the + // strongest honest check is that the prefix has at least one live + // mount under it. + const base = wildcardPrefix(row.pattern); + const under = mounted.filter((m) => m.pattern === base || m.pattern.startsWith(`${base}/`)); + if (under.length === 0) { + failures.push(`${row.ledger}: ${row.raw} — nothing is mounted under ${base}`); + } + continue; + } + + const expected = row.servedBy ?? row.pattern; + const resolved = server.resolveMountedRoute!(row.method, probePath(row.pattern)); + if (!resolved) { + failures.push( + `${row.ledger}: ${row.raw} — LEDGERED BUT NOT MOUNTED. ` + + `The live router answers nothing for ${row.method} ${probePath(row.pattern)}; ` + + 'this URL 404s at runtime while every ledger-reading guard passes it.', + ); + continue; + } + if (resolved.pattern !== expected) { + failures.push( + `${row.ledger}: ${row.raw} — MOUNTED BUT UNREACHABLE. ` + + `${row.method} ${probePath(row.pattern)} is answered by \`${resolved.pattern}\`, not \`${expected}\`. ` + + 'A literal route registered AFTER a catch-all sibling is shadowed by it — ' + + 'register it before, or say which pattern serves it with `servedBy`.', + ); + } + } + + expect(failures, `\n${failures.join('\n')}\n`).toEqual([]); + }); + + // ── The pin is a claim about the BOOT, and it is checked too ────────────── + it('every pinned row is genuinely unobservable — a stale pin fails', () => { + const stale: string[] = []; + + for (const [key, reason] of Object.entries(UNEXERCISED_BY_THIS_BOOT)) { + const sp = key.indexOf(' '); + const method = key.slice(0, sp); + const pattern = key.slice(sp + 1); + const row = ledgerRows.find((r) => r.method === method && r.pattern === pattern); + if (!row) { + stale.push(`${key} — pinned, but no ledger row says this any more. Delete the pin.`); + continue; + } + const observed = pattern.includes('*') + ? mounted.some((m) => { + const base = wildcardPrefix(pattern); + return m.pattern === base || m.pattern.startsWith(`${base}/`); + }) + : server.resolveMountedRoute!(method, probePath(pattern))?.pattern === pattern; + if (observed) { + stale.push( + `${key} — pinned as unobservable ("${reason}"), but THIS BOOT mounts it reachably. ` + + 'Delete the pin: the gate can guard it for real now.', + ); + } + } + + expect(stale, `\n${stale.join('\n')}\n`).toEqual([]); + }); + + // ── Direction 2: every live mount is ledgered ───────────────────────────── + it('every mounted route is ledgered', () => { + const exact = new Set(ledgerRows.filter((r) => !isWildcardRow(r)).map((r) => `${r.method} ${r.pattern}`)); + const wildcardBases = ledgerRows.filter(isWildcardRow).map((r) => ({ + method: r.method, + base: wildcardPrefix(r.pattern), + })); + + const unledgered = mounted.filter((m) => { + if (exact.has(`${m.method} ${m.pattern}`)) return false; + return !wildcardBases.some( + (w) => (w.method === '*' || w.method === m.method) + && (m.pattern === w.base || m.pattern.startsWith(`${w.base}/`)), + ); + }); + + expect( + unledgered.map((m) => `${m.method} ${m.pattern}`), + '\nLive mounts no ledger claims — a route surface shipped with no reviewed SDK disposition, ' + + 'which is exactly the pre-#3563 posture. Give each one a ledger row (or delete the mount):\n' + + `${unledgered.map((m) => ` ${m.method} ${m.pattern}`).join('\n')}\n`, + ).toEqual([]); + }); + + // ── The reachability check is REAL, not decorative ──────────────────────── + // + // Everything above passes if `resolveMountedRoute` merely re-implements "is + // the pattern in the table". This pins the difference on the very family the + // defect lived in: `/meta/types` and `/meta/:type` are BOTH mounted, and the + // probe must be able to tell which one answers. + it('distinguishes reachable from merely-registered on the /meta catch-all family', () => { + const table = mounted.map((m) => `${m.method} ${m.pattern}`); + expect(table).toContain('GET /api/v1/meta/types'); + expect(table).toContain('GET /api/v1/meta/:type'); + + // Registration order decides, and the literal must be first. + expect(server.resolveMountedRoute!('GET', '/api/v1/meta/types')) + .toEqual({ method: 'GET', pattern: '/api/v1/meta/types' }); + // …while a genuinely unknown type still reaches the catch-all. + expect(server.resolveMountedRoute!('GET', '/api/v1/meta/zzz_not_a_type')) + .toEqual({ method: 'GET', pattern: '/api/v1/meta/:type' }); + }); + + // The other two defects, pinned as live-router facts rather than as prose. + it('the three #7526 routes resolve to themselves and not to a catch-all sibling', () => { + expect(server.resolveMountedRoute!('GET', '/api/v1/meta/object/lead/published')) + .toEqual({ method: 'GET', pattern: '/api/v1/meta/:type/:name/published' }); + expect(server.resolveMountedRoute!('GET', '/api/v1/meta/objects/showcase_task/state/status')) + .toEqual({ method: 'GET', pattern: '/api/v1/meta/objects/:name/state/:field' }); + }); +}); diff --git a/packages/rest/src/meta-route-registration-order.test.ts b/packages/rest/src/meta-route-registration-order.test.ts new file mode 100644 index 0000000000..0b268bbf31 --- /dev/null +++ b/packages/rest/src/meta-route-registration-order.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/meta` registration ORDER (#7526). + * + * The `/meta` family is the one place on this server where a route's position + * in the registration sequence decides whether it can ever run. Hono is + * first-match-wins (measured in `plugin-hono-server`'s + * `mounted-route-introspection.test.ts`), and this family carries two + * catch-alls that shadow their literal siblings: + * + * * `GET /meta/:type` swallows every one-segment path — `diagnostics`, + * `_drafts`, `types` — that is not registered ahead of it; + * * `GET /meta/:type/:section/:name` swallows every three-segment path — + * `/history`, `/audit`, `/diff`, `/published` — likewise. + * + * `GET /meta/types` and `GET /meta/:type/:name/published` were both DEAD in + * shipped builds for the second reason apiece: one was never registered at + * all, the other never registered at all. Registering them is only half the + * fix; leaving the order unpinned means the next person who tidies this + * function can silently undo it, and the failure is a plausible 200 rather + * than an error. + * + * WHY THIS AND NOT ONLY THE PARITY GATE. The dogfood parity gate + * (`route-ledger-live-mount-parity.dogfood.test.ts`) catches the same breakage + * against a real booted server, and it is the stronger check. This one is + * cheap, runs in this package's own unit suite, and names the constraint at + * the file it constrains — so the feedback arrives while the edit is being + * made rather than at the end of a 20-second boot in another package. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server.js'; + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function createCapableProtocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({}), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + }; +} + +/** `GET /api/v1/meta/...` keys in the order the server registered them. */ +function metaRoutesInOrder(): string[] { + const rest = new RestServer(createMockServer() as any, createCapableProtocol() as any, {} as any); + rest.registerRoutes(); + return rest + .getRoutes() + .map((r) => `${r.method.toUpperCase()} ${r.path}`) + .filter((k) => k.includes('/api/v1/meta')); +} + +/** Index of a route key, or `-1`. Fails loudly rather than returning a lie. */ +function indexOf(order: string[], key: string): number { + const i = order.indexOf(key); + expect(i, `${key} is not registered at all — the route cannot serve, in any order`).toBeGreaterThanOrEqual(0); + return i; +} + +describe('/meta registration order', () => { + it('registers every one-segment literal BEFORE the GET /meta/:type catch-all', () => { + const order = metaRoutesInOrder(); + const catchAll = indexOf(order, 'GET /api/v1/meta/:type'); + + for (const literal of [ + 'GET /api/v1/meta/types', + 'GET /api/v1/meta/diagnostics', + 'GET /api/v1/meta/_drafts', + ]) { + expect( + indexOf(order, literal), + `${literal} is registered AFTER GET /api/v1/meta/:type, so it is mounted and unreachable — ` + + 'the catch-all answers it with a plausible 200 that no client can tell from an empty result', + ).toBeLessThan(catchAll); + } + }); + + it('registers every three-segment literal BEFORE the compound-name catch-all', () => { + const order = metaRoutesInOrder(); + const catchAll = indexOf(order, 'GET /api/v1/meta/:type/:section/:name'); + + for (const literal of [ + 'GET /api/v1/meta/:type/:name/published', + 'GET /api/v1/meta/:type/:name/history', + 'GET /api/v1/meta/:type/:name/audit', + 'GET /api/v1/meta/:type/:name/diff', + 'GET /api/v1/meta/:type/:name/references', + 'GET /api/v1/meta/:type/:name/layers', + ]) { + expect( + indexOf(order, literal), + `${literal} is registered AFTER GET /api/v1/meta/:type/:section/:name and is therefore shadowed — ` + + 'it answers the compound-name read instead, which for `published` was a stub identical ' + + 'before publish AND for a bogus name', + ).toBeLessThan(catchAll); + } + }); + + it('registers the FSM state read before the compound `/published` twin they collide on', () => { + const order = metaRoutesInOrder(); + // The single colliding path is `/meta/objects/x/state/published`. Two + // literal segments beat one, so the state-machine reading must win it. + expect(indexOf(order, 'GET /api/v1/meta/objects/:name/state/:field')) + .toBeLessThan(indexOf(order, 'GET /api/v1/meta/:type/:section/:name/published')); + expect(indexOf(order, 'GET /api/v1/meta/object/:name/state/:field')) + .toBeLessThan(indexOf(order, 'GET /api/v1/meta/:type/:section/:name/published')); + }); + + it('mounts the three routes #7526 found dead', () => { + const order = metaRoutesInOrder(); + for (const key of [ + 'GET /api/v1/meta/types', + 'GET /api/v1/meta/:type/:name/published', + 'GET /api/v1/meta/objects/:name/state/:field', + ]) { + expect(order, `${key} is not registered — this is the #7526 defect returning`).toContain(key); + } + }); +}); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index e1bbb1d1c6..08e9c1cc66 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -129,6 +129,20 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ // ── metadata ────────────────────────────────────────────────────────────── { route: 'GET /api/v1/meta', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getTypes' }, + // [#7526] The same `listMetaTypes` closure as the row above, at the spelling + // the dispatcher branch and `route-ledger.ts` have always used. It is + // `server-only` for the reason that ledger's row gives — Studio tooling + // calls this path directly and the SDK goes to `GET /meta` — and NOT `gap`: + // the gap ratchet is pinned at zero and a new `gap` row needs its own + // reviewed decision, which mounting a route the SDK already reaches by + // another path does not carry. + // + // MUST stay registered before `GET /api/v1/meta/:type`. It was absent + // entirely until #7526, so `/meta/types` answered from the `:type` catch-all + // with `{"type":"types","items":[]}` — a 200 indistinguishable from + // `/meta/zzz_not_a_type`. + { route: 'GET /api/v1/meta/types', family: 'metadata', source: 'route-manager', disposition: 'server-only', + note: 'richer types listing consumed by Studio tooling directly; the SDK reads the same body from GET /meta (meta.getTypes). Mirrors the `GET /meta/types` row in runtime/src/route-ledger.ts' }, { route: 'GET /api/v1/meta/diagnostics', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getDiagnostics' }, { route: 'GET /api/v1/meta/_drafts', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.listDrafts' }, { route: 'POST /api/v1/meta/_migrate-stored', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.migrateStored', @@ -168,6 +182,25 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'per-item ADR-0033 publish; packages.publishDrafts remains the package-scoped flow' }, { route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.rollbackItem' }, { route: 'GET /api/v1/meta/:type/:name/diff', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.diffItem' }, + // [#7526] The two routes that were ledgered in `runtime/src/route-ledger.ts` + // and implemented in the dispatcher, but which no registrar ever mounted — + // so the SDK guard (#3642) certified them off a DECLARATION while they died + // at runtime. Both are `route-manager` mounts here now. + // + // Order is load-bearing and pinned by `meta-route-registration-order.test.ts`: + // the `/state/:field` pair precedes the compound `/published` twin (they + // collide only on a field literally named `published`), and BOTH `/published` + // rows precede `GET /api/v1/meta/:type/:section/:name` — a three-segment + // literal registered after that catch-all is mounted and unreachable. + { route: 'GET /api/v1/meta/objects/:name/state/:field', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getLegalNextStates', + note: 'ADR-0020 D3.3 legal-next-state introspection. `next: null` = no state_machine governs the field, `next: []` = a declared dead end; the SDK spells the segment `objects`' }, + { route: 'GET /api/v1/meta/object/:name/state/:field', family: 'metadata', source: 'route-manager', disposition: 'server-only', + note: 'singular-spelling alias of the row above — metadata-protocol folds object/objects (#4432) and the dispatcher branch this mount replaces accepted both, so the replacement is not pickier than what it replaced. The SDK calls the plural only' }, + { route: 'GET /api/v1/meta/:type/:name/published', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getPublished', + note: 'ADR-0033 published snapshot; 404s for a name that does not exist, which the pre-#7526 fall-through into the compound-name route structurally could not do (it answered a protection-envelope stub identical before publish and for a bogus name)' }, + { route: 'GET /api/v1/meta/:type/:section/:name/published', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getPublished', + note: 'compound-name arity of the row above — the SDK documents getPublished(\'lead\', \'views/all_leads\'), the same unencoded pass-through getItem/saveItem carry' }, + { route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem', note: 'compound names pass through getItem unencoded (URL-pinned in client.test.ts); only deleteItem encodes' }, { route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem', diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 973b446155..9abe0d93bf 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2036,6 +2036,43 @@ export class RestServer { return undefined; } + /** + * Resolve the `metadata` service for this request — the whole occupant of + * the slot, unfiltered. + * + * The same two-step chain {@link resolveEndpointMatchAuthority} walks + * (per-request kernel, then the single-kernel provider `rest-api-plugin` + * wires), separated out because that method narrows its answer to the ONE + * capability it needs and returns `undefined` for a service that lacks it. + * A caller after a different optional member (`getPublished`, #7526) must + * not have "the service is absent" and "the service does not do that" + * collapsed into one answer before it sees them. + * + * `undefined` means nothing in the chain answered. Deciding what an absent + * service means for a given surface is the call site's business. + */ + private async resolveMetadataService(environmentId?: string, req?: any): Promise { + let envId: string | undefined; + try { + envId = await this.resolveRequestEnvironmentId(environmentId, req); + } catch { /* fall through to the single-kernel provider */ } + + if (envId && envId !== 'platform' && this.kernelManager) { + try { + const kernel = await this.kernelManager.getOrCreate(envId); + const svc = await kernel.getServiceAsync('metadata'); + if (svc) return svc; + } catch { /* fall through */ } + } + if (this.metadataServiceProvider) { + try { + const svc = await this.metadataServiceProvider(envId); + if (svc) return svc; + } catch { /* an unreachable provider is an absent service */ } + } + return undefined; + } + /** * Say — once per server — that no endpoint matcher is reachable, so the * endpoint faces cannot promise they describe only served routes. @@ -4192,27 +4229,60 @@ export class RestServer { const isScoped = basePath.includes('/environments/:environmentId'); // GET /meta - List all metadata types + // + // Also mounted at `/meta/types`, the spelling the dispatcher's `/meta` + // branch has always implemented (`parts[0] === 'types'`) and the + // spelling `route-ledger.ts` has always declared. ONE handler, two + // paths, deliberately: the dispatcher's two branches return the same + // `protocol.getMetaTypes()` body, so a second REST handler would be a + // second thing to keep true. if (metadata.endpoints.types !== false) { + const listMetaTypes = async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const types = await p.getMetaTypes(); + const translated = await this.translateMetaTypesResponse(req, environmentId, types); + res.header('Vary', 'Accept-Language'); + res.json(translated); + } catch (error: any) { + handleRouteError(res, error); + } + }; this.routeManager.register({ method: 'GET', path: metaPath, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - const types = await p.getMetaTypes(); - const translated = await this.translateMetaTypesResponse(req, environmentId, types); - res.header('Vary', 'Accept-Language'); - res.json(translated); - } catch (error: any) { - handleRouteError(res, error); - } - }, + handler: listMetaTypes, metadata: { summary: 'List all metadata types', tags: ['metadata'], }, }); + + // GET /meta/types — REGISTERED BEFORE `/meta/:type`, and that is + // the entire fix (#7526). + // + // The branch existed in the dispatcher and the row existed in the + // ledger; the REST mount is a THIRD place and nobody wrote it here. + // So `/meta/types` fell into the `:type` catch-all below and + // answered `{"type":"types","items":[]}` — byte-shaped like + // `/meta/zzz_not_a_type`, a 200 no client can tell from "that type + // is empty". Hono is first-match-wins (MEASURED, not assumed — + // `plugin-hono-server`'s `mounted-route-introspection.test.ts` + // registers a literal and a `:param` sibling in both orders and + // pins that the later one never runs), so moving this below + // `/meta/:type` silently re-breaks it — the same shape that already + // put `diagnostics` / `_drafts` / `_migrate-stored` above it. The + // order is pinned by `meta-route-registration-order.test.ts`. + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/types`, + handler: listMetaTypes, + metadata: { + summary: 'List all metadata types (explicit `/types` spelling)', + tags: ['metadata'], + }, + }); } // GET /meta/diagnostics - Cross-type spec-validation sweep @@ -5879,6 +5949,145 @@ export class RestServer { }, }); + // GET /meta/objects/:name/state/:field?from=:state — ADR-0020 D3.3 + // legal-next-state introspection. [#7526] + // + // Ledgered since #3563 (`route-ledger.ts`, `meta.getLegalNextStates`) + // and implemented in the dispatcher's `/meta` branch — but REST's ~17 + // `/meta` routes topped out at THREE path segments and this one needs + // four, so no registration here could ever deliver it and it answered + // Hono's `notFound`, byte-identical to an unmounted path. + // + // Registered BEFORE the compound `/:type/:section/:name/published` + // twin below, which it collides with on exactly one shape: + // `/meta/objects/x/state/published`. Two literal segments (`objects`, + // `state`) beat one, so the FSM reading wins that path — a field + // literally named `published` is the ambiguity, and answering it as + // "the published version of the compound name objects/x/state" would + // be the less likely of the two by a wide margin. + // + // `/object` as well as `/objects`: `metadata-protocol` folds the two + // spellings (#4432) and the dispatcher branch accepts both, so the + // REST mount that replaces it must not be pickier than what it + // replaces. + for (const objectsSegment of ['objects', 'object']) { + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/${objectsSegment}/:name/state/:field`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const name = String(req.params?.name ?? ''); + const field = String(req.params?.field ?? ''); + // [#6877 shape] `?from=` narrows to ONE current state; + // an array would reach `legalNextStates` as a + // stringified pair and match no transition key. + if (refuseRepeatedQueryParams(req, res, ['from'])) return; + const from = req.query?.from !== undefined ? String(req.query.from) : undefined; + const ql = this.objectQLProvider + ? await this.objectQLProvider(environmentId).catch(() => undefined) + : undefined; + const schema = (ql as any)?.registry?.getObject?.(name); + if (!schema) { + res.status(404).json({ error: 'Object not found' }); + return; + } + // Dynamic import, matching the dispatcher branch this + // mirrors: `@objectstack/objectql` is a devDependency + // here, so a deployment serving REST without the data + // engine must degrade rather than fail to load. + let legalNextStates: + | ((s: { validations?: unknown[] } | null | undefined, f: string, c: string) => string[] | null) + | undefined; + try { + ({ legalNextStates } = await import('@objectstack/objectql')); + } catch { + legalNextStates = undefined; + } + if (typeof legalNextStates !== 'function') { + res.status(501).json({ + error: 'State-machine introspection is not available in this runtime', + code: 'NOT_IMPLEMENTED', + }); + return; + } + // `next: null` = no FSM governs the field; `next: []` = + // a declared dead end. Same three-valued answer the + // dispatcher gives, because a UI asking "where can this + // record go" must be able to tell those apart. + const next = from === undefined ? null : legalNextStates(schema, field, from); + res.json({ object: name, field, from: from ?? null, next }); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'List the legal next states declared by an object field\'s state machine', + tags: ['metadata'], + }, + }); + } + + // GET /meta/:type/:name/published — ADR-0033 published snapshot. [#7526] + // + // Ledgered since #3563 (`meta.getPublished`) and implemented in the + // dispatcher, but never mounted here — so the request fell into the + // compound-name route below with `section=:name, name='published'`, + // which answered a protection-envelope stub. Identical before and + // after publish, identical for a name that does not exist: a route + // that structurally could not 404. + // + // Both arities, mirroring the `getItem` / `saveItem` twins: the SDK + // documents `getPublished('lead', 'views/all_leads')`, and a compound + // name is how every other read on this surface addresses a + // sub-resource. REGISTERED BEFORE `/:type/:section/:name` — the + // three-segment form collides with it exactly the way `/history` and + // `/audit` do, and Hono is first-match-wins. + for (const publishedPath of [ + `${metaPath}/:type/:name/published`, + `${metaPath}/:type/:section/:name/published`, + ]) { + this.routeManager.register({ + method: 'GET', + path: publishedPath, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const type = String(req.params?.type ?? ''); + const section = req.params?.section; + const name = section + ? `${section}/${req.params?.name ?? ''}` + : String(req.params?.name ?? ''); + const svc = await this.resolveMetadataService(environmentId, req); + if (typeof (svc as any)?.getPublished !== 'function') { + res.status(501).json({ + error: 'metadata.getPublished() is not available in this kernel', + code: 'NOT_IMPLEMENTED', + }); + return; + } + const data = await (svc as any).getPublished(type, name); + // The 404 this route could never produce before. An + // item that exists but was never published still + // answers 200 with its current definition — that is + // `getPublished`'s documented fallback, and it is a + // different fact from "no such item". + if (data === undefined) { + res.status(404).json({ error: 'Not found' }); + return; + } + res.json(data); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Get the published version of a metadata item', + tags: ['metadata'], + }, + }); + } + // GET /meta/:type/:section/:name - Get specific item with compound name // Compound names express sub-resources of a type (e.g. a view of an // object, a flow under an automation). The protocol layer treats diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index ed829796a7..c035d8cc2f 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1112,6 +1112,47 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } }); + // [#7526] `/actions`, `/connectors`, `/_status` — REGISTERED + // BEFORE `/automation/:name`, which is the whole point. + // + // `domains/automation.ts` has always ordered these three ahead + // of its `/:name → getFlow` catch-all, and its module doc says + // in as many words that the order is load-bearing. That care + // was spent inside `dispatch()`, on a path no request took: + // this bridge is the only thing that mounts `/automation`, and + // it mounted `/:name` and never these — so `GET + // /api/v1/automation/actions` resolved to `getFlow('actions')` + // and answered a flow-not-found where the ledger promises the + // action-descriptor palette. Found by the live-mount parity + // gate this issue added, as three more instances of the class + // it was built for. + server!.get(`${base}/automation/actions`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch('GET', '/automation/actions', undefined, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.get(`${base}/automation/connectors`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch('GET', '/automation/connectors', undefined, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.get(`${base}/automation/_status`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch('GET', '/automation/_status', undefined, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + server!.get(`${base}/automation/:name`, async (req: any, res: any) => { try { const result = await dispatcher.dispatch('GET', `/automation/${req.params.name}`, undefined, req.query, { request: req }); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 06ff02f327..742b019f19 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -79,6 +79,40 @@ export type RouteDisposition = export interface RouteLedgerEntry { /** `VERB /cleanPath/:param` (or `* /prefix/**` for wildcard families). */ route: string; + /** + * `true` when {@link route} is already an ABSOLUTE wire path and must NOT + * have the `/api/v1` prefix prepended to it. + * + * Every other row here is a dispatcher-internal `cleanPath`, and both + * consumers (`client-url-conformance.test.ts` and the live-mount parity + * gate) reconstruct the wire path by prepending the prefix. `/.well-known/*` + * is mounted at the site root by definition — a well-known URI under a + * versioned API prefix is not a well-known URI — so prefixing it would + * compile a pattern nothing serves. Rather than let a guard carry a quietly + * wrong pattern (the #5078 failure), the row says so and the guards honour + * it. [#7526] + */ + absolute?: boolean; + /** + * The mounted pattern that ANSWERS this row, when the row deliberately + * describes a SPECIALIZATION of a broader mount rather than a pattern of its + * own. + * + * One row uses it: `POST /actions/global/:action` names the global-action + * calling convention the SDK's `actions.invokeGlobal` builds, and it is + * served by `POST /actions/:object/:action` with `:object` bound to the + * literal `global`. There is no `/actions/global/:action` registration and + * there should not be one. + * + * This is NOT an escape hatch for "the route is missing". The live-mount + * parity gate does not take the field's word for anything — it probes a + * concrete path and asserts the LIVE ROUTER answers with exactly this + * pattern, so a wrong `servedBy` fails as loudly as a missing route. What the + * field buys is that the shadowing becomes a written, reviewable claim + * instead of an unexplained mismatch a future reader would "fix" by deleting + * the row. [#7526] + */ + servedBy?: string; /** Owning domain — a registry prefix (e.g. `/automation`) or legacy-chain prefix. */ domain: string; disposition: RouteDisposition; @@ -169,9 +203,23 @@ export const LEGACY_CHAIN_PREFIXES = [ */ export const NON_DISPATCH_MOUNT_PREFIXES = [ '/apps', + // [#7526] `GET /.well-known/objectstack` — the platform's own discovery + // document, mounted by `dispatcher-plugin.ts` straight on the host + // `IHttpServer` and UNCONDITIONALLY owned by this plugin (no other + // registrar may claim it; the `${prefix}/discovery` twin next to it IS + // ceded to `@objectstack/rest`, this one never is). It was reachable, + // documented and in NO ledger until the live-mount parity gate read it off + // a booted server — the plain unledgered-mount case, and it sat directly + // beside the routes this list already exists to name. + '/.well-known/objectstack', ] as const; export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ + // ── well-known ──────────────────────────────────────────────────────────── + { route: 'GET /.well-known/objectstack', domain: '/.well-known/objectstack', absolute: true, + disposition: 'server-only', + note: 'RFC 8615 discovery document at the SITE ROOT (hence `absolute`), answering the same getDiscoveryInfo() body as GET /api/v1/discovery. Dispatcher-owned unconditionally — unlike the /discovery twin, which is ceded when @objectstack/rest is mounted. Consumed by tooling probing an unknown host, not by the SDK, which connects through /discovery' }, + // ── ops probes ──────────────────────────────────────────────────────────── { route: 'GET /health', domain: '/health', disposition: 'server-only', note: 'liveness probe for orchestrators, not app traffic' }, { route: 'GET /ready', domain: '/ready', disposition: 'server-only', note: 'readiness probe for orchestrators, not app traffic' }, @@ -317,7 +365,17 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'POST /actions/:object/:action', domain: '/actions', disposition: 'sdk', client: 'actions.invoke' }, { route: 'POST /actions/:object/:action/:recordId', domain: '/actions', disposition: 'sdk', client: 'actions.invoke', note: 'client sends recordId in the body — both server shapes honor it' }, - { route: 'POST /actions/global/:action', domain: '/actions', disposition: 'sdk', client: 'actions.invokeGlobal' }, + { route: 'POST /actions/global/:action', domain: '/actions', disposition: 'sdk', client: 'actions.invokeGlobal', + servedBy: '/api/v1/actions/:object/:action', + note: '[#7526] a CALLING CONVENTION, not a registration: `global` is bound to `:object` on the row above, and handleActionsRequest routes that literal to the global-action table. Written as `servedBy` because the live-mount gate found no `/actions/global/:action` pattern and the honest answer is which pattern answers it — the gate re-derives that from the live router rather than believing this string' }, + // [#7526] The OBJECT-LESS shape, mounted since #3913 and ledgered by + // nothing until the parity gate read it off a booted server. The empty + // segment is deliberate and load-bearing: it is the URL an SDK with no + // object to name emits, `:object` cannot match an empty segment, and + // before #3913 it fell to Hono's `notFound`. Ugly on the wire and correct; + // what was wrong was that no ledger said it existed. + { route: 'POST /actions//:action', domain: '/actions', disposition: 'sdk', client: 'actions.invokeGlobal', + note: 'the object-less spelling of the global-action call (#3913) — same handler and same `global` key as the row above, reached without naming an object' }, // ── apps (declarative endpoints — the mount seam, NOT a dispatch() route) ── // Read this row literally; it describes what is WIRED, not what is planned. diff --git a/packages/services/service-settings/src/settings-route-ledger.ts b/packages/services/service-settings/src/settings-route-ledger.ts new file mode 100644 index 0000000000..5246f915ef --- /dev/null +++ b/packages/services/service-settings/src/settings-route-ledger.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Settings route ledger — the audited disposition of every HTTP route + * `SettingsServicePlugin` mounts (#7526, closing the largest unledgered live + * mount the route-ledger ↔ live-mount parity gate measured). + * + * WHY THIS EXISTS. Same shape as tranche 3 (`service-storage`, + * `service-i18n`): this plugin reaches for the `http-server` service and + * registers straight on `IHttpServer`, so neither the dispatcher ledger nor + * the REST ledger has ever seen these four routes and nothing anywhere + * recorded a disposition for them. They were found the only way an unledgered + * mount CAN be found — by reading the live mount table off a booted server and + * asking which rows nobody claims (PENDING-GAPS §E; the gate is + * `packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts`). + * + * WHAT GUARDS IT. That parity gate, in both directions: a row here whose + * route the plugin stops mounting fails it, and a fifth route mounted without + * a row here fails it too. Deliberately NOT a fifth per-package conformance + * test — the sibling ledgers each grew one because nothing else could see + * their registrar, and the gate now can. A second guard over the same fact + * would be the "two places to remember" shape this issue is about. + * + * SCOPE & SHAPE. Rows carry full wire paths at the DEFAULT base + * (`/api/settings` — note NOT under `/api/v1`; this surface predates the + * versioned prefix and `SettingsRoutesOptions.basePath` can move the family). + * The gate enumerates a boot that leaves the default in place. + * + * NOT SDK SURFACE, all four. `@objectstack/client` expresses no settings + * method: the consumer is the Setup/admin UI over plain HTTP, and the values + * here are deployment configuration (mail credentials, storage drivers, SMS + * providers) rather than application data. That is a `server-only` disposition + * and not a `gap` — a gap row is an acknowledged debt with an owner, and + * nothing has ever asked the SDK for this. + * + * This module is package-internal (not exported from the index): it is the + * guard's data, not public API. It must stay import-free — the gate imports it + * as a relative SOURCE file. + */ + +/** Disposition of a single settings route. Same vocabulary as the REST ledger. */ +export type SettingsRouteDisposition = + /** Expressed by the SDK — `client` names the method (dotted path). */ + | 'sdk' + /** Should be in the SDK and is not — an open, acknowledged gap. */ + | 'gap' + /** Deliberately not SDK surface. */ + | 'server-only' + /** Public, unauthenticated browser-facing route. */ + | 'public' + /** Server and client disagree on the shape — needs reconciliation. */ + | 'mismatch'; + +export interface SettingsRouteLedgerEntry { + /** `VERB /api/settings/...` — full wire path at the default base. */ + route: string; + /** Registrar family, for grouping and diff messages. */ + family: string; + disposition: SettingsRouteDisposition; + /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + client?: string; + /** One-line rationale. Required for every non-`sdk` disposition. */ + note?: string; +} + +export const SETTINGS_ROUTE_LEDGER: readonly SettingsRouteLedgerEntry[] = [ + { route: 'GET /api/settings', family: 'settings', disposition: 'server-only', + note: 'manifests the caller may see, filtered by their capabilities (ADR-0007 §REST) — the Setup UI\'s namespace index' }, + { route: 'GET /api/settings/:namespace', family: 'settings', disposition: 'server-only', + note: 'one namespace as { manifest, values }; secret-typed keys come back masked' }, + { route: 'PUT /api/settings/:namespace', family: 'settings', disposition: 'server-only', + note: 'batch upsert of one namespace, validated against its manifest; refuses locked keys' }, + { route: 'POST /api/settings/:namespace/:actionId', family: 'settings', disposition: 'server-only', + note: 'invoke an action the namespace manifest declares (test-connection probes and the like), not a data write' }, +]; diff --git a/packages/spec/src/contracts/http-server.ts b/packages/spec/src/contracts/http-server.ts index 754f15dc6a..a7dcd3383c 100644 --- a/packages/spec/src/contracts/http-server.ts +++ b/packages/spec/src/contracts/http-server.ts @@ -221,6 +221,80 @@ export interface IHttpServer { */ getPort?(): number; + /** + * The LIVE mount table: every `(method, pattern)` pair registered on THIS + * server, in registration order. + * + * ## Why this is on the contract (#7526) + * + * Four route ledgers in this repo DECLARE what each surface serves, and + * every guard built on them (#3563 / #3587 / #3636 / #3642) reads the union + * of those declarations as if it were an OBSERVATION of what is mounted. + * It is not. `GET /meta/objects/:name/state/:field` sat in + * `route-ledger.ts` while no registrar mounted it, so the SDK guard passed + * it and the route 404'd at runtime — and the same build shipped two more + * of the same defect. A declaration cannot audit itself; something has to + * report what the server really did. That is this member. + * + * ## Contract + * + * - Every pattern a consumer registered through {@link get} / {@link post} + * / {@link put} / {@link delete} / {@link patch} appears, spelled exactly + * as it was passed (adapters must not normalize it into a private + * dialect) — a caller compares these strings against its own route table. + * - The order is REGISTRATION order, which for first-match routers is also + * priority order. Do not sort it. + * - Routes an adapter mounts on its framework-native handle behind + * {@link getRawApp} are outside this table by construction, and so are + * {@link use} middleware and the {@link setFallbackHandler} seam: this + * answers "what routes did I register", not "what paths might respond". + * - The returned array is the caller's; mutating it must not affect the + * server. + * + * Optional and feature-detected, like {@link getRawApp} — an adapter that + * keeps no record simply omits it. A consumer that needs the answer for + * correctness must FAIL when it is absent, never skip: a parity gate that + * quietly passes because it could not look is the failure it exists to + * catch. + */ + getMountedRoutes?(): ReadonlyArray<{ method: string; pattern: string }>; + + /** + * Which registered route actually ANSWERS a concrete request — the + * router's own verdict, not a re-implementation of its matching. + * + * ## Why registration is not reachability (#7526) + * + * On a first-match router, a literal route registered AFTER a catch-all + * sibling that also matches its path is mounted and unreachable. Measured + * against Hono: with `GET /api/v1/meta/:type` registered first, a later + * `GET /api/v1/meta/types` never runs — `/meta/types` answers from + * `:type`, with a plausible 200 that no client can tell from an empty + * result. So {@link getMountedRoutes} containing a pattern is NECESSARY + * and not SUFFICIENT evidence that the pattern serves; a consumer probes a + * concrete path through this member and checks it gets back the pattern it + * expected. + * + * ## Contract + * + * - `path` is a concrete request path (`/api/v1/meta/types`), not a + * pattern. The return value IS a pattern — one of the entries + * {@link getMountedRoutes} reports, `===`-comparable to it. + * - The verdict must come from the same routing machinery that serves + * traffic. An adapter that answers from a private copy of the rules can + * drift from itself, which is this member's whole subject. + * - `undefined` means no registered route matches, i.e. the request would + * reach the adapter's unmatched-request answer (404/405). It does NOT + * mean "unknown": an adapter that cannot ask its router omits the member + * rather than returning `undefined`. + * - Read-only. Resolving a route must not run its handler or any + * middleware. + * + * Optional and feature-detected, with the same fail-don't-skip rule as + * {@link getMountedRoutes}. + */ + resolveMountedRoute?(method: string, path: string): { method: string; pattern: string } | undefined; + /** * The underlying framework's own app object (Hono's `Hono`, Express's * `Express`, …) — THE deliberate framework-specific escape hatch on this From 00d870d9ed6e6903484f1bdad848608a92ea4b3a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 06:10:55 +0000 Subject: [PATCH 2/2] test(qa): pin the documented answers of the two revived /meta routes (#7526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parity gate proves they are MOUNTED. This proves they ANSWER — a different question, and the one `/published` failed most visibly: it was reachable in the sense that requests to it got a 200, from the compound-name route, with a body identical before publish AND for a name that does not exist. Booted with a MetadataPlugin because `getPublished` is a metadata- SERVICE capability and `objectstack serve` composes one for every deployment; without it the route answers a typed 501, which is the honest degradation but not the behaviour under test. Also conforms the new error bodies to the envelope `BaseResponseSchema` declares (`pnpm check:route-envelope` ratchets the two non-conforming dialects DOWN only), and retires a stale assertion in rest-openapi-route.test.ts: `/api/v1/meta/types` was listed as a path that 'exists nowhere in the repo', which was true of the ROUTE and is no longer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DnUXMJxLDLoQ9MTRthtAGd --- ...published-and-state-routes.dogfood.test.ts | 145 ++++++++++++++++++ ...e-ledger-live-mount-parity.dogfood.test.ts | 34 ++-- packages/qa/dogfood/tsconfig.json | 9 +- packages/rest/src/rest-openapi-route.test.ts | 18 ++- packages/rest/src/rest-server.ts | 26 +++- 5 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts diff --git a/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts b/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts new file mode 100644 index 0000000000..8192622e8f --- /dev/null +++ b/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The two `/meta` routes #7526 found DEAD, exercised end-to-end over real HTTP +// for the answers they are documented to give. +// +// The parity gate next door proves they are MOUNTED and reachable. That is a +// different question from whether they answer anything, and both halves have to +// hold: `GET /meta/:type/:name/published` was reachable in the sense that a +// request to it got a 200 — from the compound-name route, with a body identical +// before publish AND for a name that does not exist. A route that cannot 404 is +// the failure this file pins against. +// +// * GET /meta/:type/:name/published — ADR-0033 published snapshot. +// 404 for a name nothing declares. 200 with the current definition for one +// that exists but was never published (getPublished's documented fallback). +// * GET /meta/objects/:name/state/:field — ADR-0020 D3.3 legal next states. +// `next: null` when no state_machine governs the field or no `?from=` was +// given, `next: [...]` for a declared transition, `[]` for a dead end. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { writeBuildShapedArtifact } from './build-shaped-artifact.js'; + +describe('dogfood: /meta/:type/:name/published and /meta/objects/:name/state/:field (#7526)', () => { + let stack: VerifyStack; + let token: string; + let tempDir: string; + + beforeAll(async () => { + // `getPublished` is a `metadata`-SERVICE capability, and the lean harness + // boot registers no such service — the route then answers a typed 501, + // which is the honest degradation but not the behaviour under test. + // `objectstack serve` composes a MetadataPlugin for every deployment + // (serve.ts), so booting one here is what a real server looks like, not a + // fixture convenience. + tempDir = mkdtempSync(join(tmpdir(), 'os-7526-published-')); + const artifactPath = join(tempDir, 'objectstack.json'); + // The real `objectstack build` lowering, not `JSON.stringify(stack)` — + // that drops callables silently and the artifact parses green carrying + // none of what it advertises (#6293). + writeBuildShapedArtifact(showcaseStack as unknown as Record, artifactPath); + + stack = await bootStack(showcaseStack, { + extraPlugins: [ + new MetadataPlugin({ + rootDir: tempDir, + watch: false, + artifactWatch: false, + registerSystemObjects: false, + artifactSource: { mode: 'local-file', path: artifactPath }, + }), + ], + }); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('GET /meta/:type/:name/published', () => { + it('404s for a name nothing declares — the answer the old fall-through could never give', async () => { + const res = await stack.apiAs(token, 'GET', '/meta/object/zzz_not_a_real_object/published'); + expect(res.status).toBe(404); + }); + + it('answers a declared object with its definition rather than the compound-name stub', async () => { + const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_task/published'); + expect(res.status).toBe(200); + const body = await res.json() as Record; + // The pre-fix answer came from `GET /meta/:type/:section/:name` with + // `section='showcase_task', name='published'` — a protection envelope for + // an item called `showcase_task/published`, which is why it was identical + // for a bogus name. A real published read carries the object itself. + expect(JSON.stringify(body)).toContain('showcase_task'); + }); + + it('does not answer a bogus and a real name identically (the defect, stated directly)', async () => { + const [bogus, real] = await Promise.all([ + stack.apiAs(token, 'GET', '/meta/object/zzz_not_a_real_object/published'), + stack.apiAs(token, 'GET', '/meta/object/showcase_task/published'), + ]); + expect(bogus.status).not.toBe(real.status); + }); + }); + + describe('GET /meta/objects/:name/state/:field', () => { + it('returns the legal next states declared by the field\'s state_machine', async () => { + // showcase_task declares `todo → [in_progress, backlog]`. + const res = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/status?from=todo'); + expect(res.status).toBe(200); + const body = await res.json() as { object: string; field: string; from: string | null; next: string[] | null }; + expect(body.object).toBe('showcase_task'); + expect(body.field).toBe('status'); + expect(body.from).toBe('todo'); + expect(body.next?.sort()).toEqual(['backlog', 'in_progress']); + }); + + it('distinguishes "no FSM / no from" (null) from "a dead end" ([])', async () => { + // No `?from=` — the caller asked nothing answerable, so `next` is null. + const noFrom = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/status'); + expect(((await noFrom.json()) as { next: unknown }).next).toBeNull(); + + // A field with no state_machine at all is also `null`, not `[]`: "nothing + // governs this" and "this state goes nowhere" are different facts and a + // UI has to be able to tell them apart (ADR-0020 D3.3). + const noRule = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/title?from=anything'); + expect(noRule.status).toBe(200); + expect(((await noRule.json()) as { next: unknown }).next).toBeNull(); + + // An unknown state under a field that DOES have a machine is a dead end. + const deadEnd = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/status?from=not_a_state'); + expect(((await deadEnd.json()) as { next: unknown }).next).toEqual([]); + }); + + it('404s for an object the registry does not know', async () => { + const res = await stack.apiAs(token, 'GET', '/meta/objects/zzz_not_a_real_object/state/status?from=x'); + expect(res.status).toBe(404); + }); + + it('accepts the singular `/meta/object/...` spelling the dispatcher branch accepted', async () => { + const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_task/state/status?from=todo'); + expect(res.status).toBe(200); + expect(((await res.json()) as { next: string[] }).next.sort()).toEqual(['backlog', 'in_progress']); + }); + + it('is not the transport 404 — an unmounted control answers differently', async () => { + // The pre-fix state of this route: Hono's `notFound`, byte-identical to a + // path nothing mounts. Both 404s below are 404s; only one of them is a + // HANDLER's answer, and that difference is the whole point. + const control = await stack.apiAs(token, 'GET', '/meta/objects/showcase_task/state/status/definitely/not/mounted'); + const handled = await stack.apiAs(token, 'GET', '/meta/objects/zzz_not_a_real_object/state/status?from=x'); + expect(control.status).toBe(404); + expect(handled.status).toBe(404); + expect(await handled.text()).not.toBe(await control.text()); + }); + }); +}); diff --git a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts index 505d64a14f..6740bb0714 100644 --- a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts @@ -61,11 +61,16 @@ import showcaseStack from '@objectstack/example-showcase'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { StorageServicePlugin } from '@objectstack/service-storage'; -import { ROUTE_LEDGER } from '../../../runtime/src/route-ledger'; -import { REST_ROUTE_LEDGER } from '../../../rest/src/rest-route-ledger'; -import { STORAGE_ROUTE_LEDGER } from '../../../services/service-storage/src/storage-route-ledger'; -import { I18N_ROUTE_LEDGER } from '../../../services/service-i18n/src/i18n-route-ledger'; -import { SETTINGS_ROUTE_LEDGER } from '../../../services/service-settings/src/settings-route-ledger'; +// `.js` on the relative source imports: without it `moduleResolution: nodenext` +// does not resolve them. These are compiled as relative SOURCE files (never as +// package entry points) because each ledger is package-internal — the guard's +// data, not public API — which is the same access the sibling ledger guards +// take. +import { ROUTE_LEDGER } from '../../../runtime/src/route-ledger.js'; +import { REST_ROUTE_LEDGER } from '../../../rest/src/rest-route-ledger.js'; +import { STORAGE_ROUTE_LEDGER } from '../../../services/service-storage/src/storage-route-ledger.js'; +import { I18N_ROUTE_LEDGER } from '../../../services/service-i18n/src/i18n-route-ledger.js'; +import { SETTINGS_ROUTE_LEDGER } from '../../../services/service-settings/src/settings-route-ledger.js'; /** One ledger row, normalized to a wire pattern this gate can probe. */ interface LedgerRow { @@ -224,11 +229,22 @@ describe('route ledger ↔ live mount parity (#7526)', () => { continue; } if (resolved.pattern !== expected) { + // Two different diseases with the same symptom, and naming which one + // it is saves the reader the trip: either nobody registered the + // pattern and a broader sibling is answering in its place, or someone + // registered it too late and the sibling wins on order. The fix + // differs (write the registration / move it up), so the message must. + const registered = mounted.some((m) => m.method === row.method && m.pattern === expected); failures.push( - `${row.ledger}: ${row.raw} — MOUNTED BUT UNREACHABLE. ` - + `${row.method} ${probePath(row.pattern)} is answered by \`${resolved.pattern}\`, not \`${expected}\`. ` - + 'A literal route registered AFTER a catch-all sibling is shadowed by it — ' - + 'register it before, or say which pattern serves it with `servedBy`.', + registered + ? `${row.ledger}: ${row.raw} — MOUNTED BUT UNREACHABLE. ` + + `\`${expected}\` IS registered, but ${row.method} ${probePath(row.pattern)} is answered by ` + + `\`${resolved.pattern}\` — a first-match router gives the path to whichever registration came ` + + 'first. Move it ahead of that sibling.' + : `${row.ledger}: ${row.raw} — LEDGERED BUT NOT MOUNTED, and DISGUISED. ` + + `Nothing registers \`${expected}\`; ${row.method} ${probePath(row.pattern)} is swallowed by ` + + `\`${resolved.pattern}\`, so the caller gets that route's answer — a plausible response rather ` + + 'than a 404. Register it ahead of that sibling, or say which pattern serves it with `servedBy`.', ); } } diff --git a/packages/qa/dogfood/tsconfig.json b/packages/qa/dogfood/tsconfig.json index 15de196d35..828c81be0f 100644 --- a/packages/qa/dogfood/tsconfig.json +++ b/packages/qa/dogfood/tsconfig.json @@ -8,7 +8,14 @@ "skipLibCheck": true, "noEmit": true, "types": ["node"], - "rootDir": "." + // No `rootDir`. This project emits nothing (`noEmit`), so `rootDir` never + // shaped an output layout here — its only effect was to forbid importing a + // sibling package's SOURCE file, and the route-ledger ↔ live-mount parity + // gate (#7526) must read the five ledgers exactly that way: each one is + // package-internal on purpose (the guard's data, not public API), so there + // is no entry point to import them from. Every other ledger guard in the + // repo compiles them as relative sources for the same reason. + "rootDir": "../../.." }, "include": ["src/**/*", "test/**/*"], "exclude": ["node_modules"] diff --git a/packages/rest/src/rest-openapi-route.test.ts b/packages/rest/src/rest-openapi-route.test.ts index 234f5c205c..59cabf3026 100644 --- a/packages/rest/src/rest-openapi-route.test.ts +++ b/packages/rest/src/rest-openapi-route.test.ts @@ -232,12 +232,22 @@ describe('#5588 — built-in routes come from rest, not from the static artifact for (const phantom of PHANTOM_PATHS) { expect(body.paths[phantom], `'${phantom}' is served by nothing`).toBeUndefined(); } - // `/api/meta/types` and `/api/.well-known/objectstack` had no route on ANY - // prefix — the first exists nowhere in the repo, the second is the runtime - // dispatcher's, mounted on the root. Neither may come back under `/api/v1`. - expect(body.paths['/api/v1/meta/types']).toBeUndefined(); + // `/api/.well-known/objectstack` had no route on ANY prefix under this + // server: the real one is the runtime dispatcher's, mounted at the SITE + // ROOT, so it must not appear here on either spelling. expect(body.paths['/api/v1/.well-known/objectstack']).toBeUndefined(); expect(body.paths['/.well-known/objectstack']).toBeUndefined(); + + // `/api/v1/meta/types` used to be asserted absent here, on the grounds + // that it "exists nowhere in the repo". That was true of the ROUTE and not + // of the path: it was ledgered and implemented in the dispatcher all + // along, and only the REST registration was missing — so `/meta/types` + // answered from the `/meta/:type` catch-all instead of 404ing, which is + // why nobody noticed (#7526). It is a real mount now, and this document is + // built from mounted routes, so it belongs in the document. The + // unversioned `/api/meta/types` spelling in PHANTOM_PATHS is still a + // phantom and is still asserted absent by the loop above. + expect(body.paths['/api/v1/meta/types']).toBeDefined(); }); it('documents the real CRUD surface, with the real verbs', async () => { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 9abe0d93bf..b712c758dd 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -5989,7 +5989,15 @@ export class RestServer { : undefined; const schema = (ql as any)?.registry?.getObject?.(name); if (!schema) { - res.status(404).json({ error: 'Object not found' }); + // `{ error: { code, message } }`, the envelope + // `BaseResponseSchema` declares — not the bare + // `{ error: 'string' }` the dispatcher branch this + // mirrors emits. `pnpm check:route-envelope` + // ratchets both non-conforming shapes DOWN only, so + // a new route arrives conforming or not at all. + res.status(404).json({ + error: { code: 'NOT_FOUND', message: 'Object not found' }, + }); return; } // Dynamic import, matching the dispatcher branch this @@ -6006,8 +6014,10 @@ export class RestServer { } if (typeof legalNextStates !== 'function') { res.status(501).json({ - error: 'State-machine introspection is not available in this runtime', - code: 'NOT_IMPLEMENTED', + error: { + code: 'NOT_IMPLEMENTED', + message: 'State-machine introspection is not available in this runtime', + }, }); return; } @@ -6061,8 +6071,10 @@ export class RestServer { const svc = await this.resolveMetadataService(environmentId, req); if (typeof (svc as any)?.getPublished !== 'function') { res.status(501).json({ - error: 'metadata.getPublished() is not available in this kernel', - code: 'NOT_IMPLEMENTED', + error: { + code: 'NOT_IMPLEMENTED', + message: 'metadata.getPublished() is not available in this kernel', + }, }); return; } @@ -6073,7 +6085,9 @@ export class RestServer { // `getPublished`'s documented fallback, and it is a // different fact from "no such item". if (data === undefined) { - res.status(404).json({ error: 'Not found' }); + res.status(404).json({ + error: { code: 'NOT_FOUND', message: 'Not found' }, + }); return; } res.json(data);