From fee81b753e25f81038669bf0d5de3dd34a6244d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 09:57:25 +0000 Subject: [PATCH 1/2] feat(cloud-connection,metadata,cli): ledger the six raw-app route mounters and guard them Six registrars across three packages mount HTTP routes on the host Hono app's framework-native handle (`http-server` -> `getRawApp()`), so their routes sit outside the dispatcher ledger, outside `RestServer.getRoutes()`, and outside `IHttpServer.getMountedRoutes()` -- the last one by the contract's own words, "routes an adapter mounts on its framework-native handle behind `getRawApp` are outside this table by construction". None carried a reviewed disposition anywhere. Adds three per-package ledgers in the #3636 / #11863 pattern plus a guard for each. All six files are new; no existing file is touched and no route behaviour changes. The guards read package SOURCE rather than driving plugin lifecycles: every one of these registrars mounts from inside a `kernel:ready` hook behind multi-service resolutions that return quietly when a service is absent, so a lifecycle drive would fail OPEN -- observing zero mounts while every accounting assertion passed vacuously. That is the completed-census defect these ledgers exist to remove. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa --- .../console-route-ledger.conformance.test.ts | 413 ++++++++++++++ .../cli/src/utils/console-route-ledger.ts | 161 ++++++ ...onnection-route-ledger.conformance.test.ts | 522 ++++++++++++++++++ .../src/cloud-connection-route-ledger.ts | 289 ++++++++++ .../metadata-route-ledger.conformance.test.ts | 468 ++++++++++++++++ .../metadata/src/metadata-route-ledger.ts | 121 ++++ 6 files changed, 1974 insertions(+) create mode 100644 packages/cli/src/utils/console-route-ledger.conformance.test.ts create mode 100644 packages/cli/src/utils/console-route-ledger.ts create mode 100644 packages/cloud-connection/src/cloud-connection-route-ledger.conformance.test.ts create mode 100644 packages/cloud-connection/src/cloud-connection-route-ledger.ts create mode 100644 packages/metadata/src/metadata-route-ledger.conformance.test.ts create mode 100644 packages/metadata/src/metadata-route-ledger.ts diff --git a/packages/cli/src/utils/console-route-ledger.conformance.test.ts b/packages/cli/src/utils/console-route-ledger.conformance.test.ts new file mode 100644 index 0000000000..3eb3b24848 --- /dev/null +++ b/packages/cli/src/utils/console-route-ledger.conformance.test.ts @@ -0,0 +1,413 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * cli console route-ledger conformance (#11882) — the guard that keeps this + * package's `getRawApp()`-mounted HTTP surface and its reviewed dispositions + * from drifting apart, in the #3636 / #11863 pattern. + * + * WHY A SOURCE SCAN AND NOT A LIFECYCLE DRIVE. Both factories in + * `utils/console.ts` return early — before mounting anything — unless a + * resolvable HTTP server AND a built `dist/` exist on disk. A lifecycle drive + * would therefore fail OPEN in CI, observing zero mounts while every accounting + * assertion passed vacuously. That is the "completed census" defect these + * ledgers exist to remove, so this guard reads SOURCE TEXT, the shape + * `check-auth-mount-ledger.mjs` (#10534) established for `rawApp` mounts. + * + * FIVE LIMBS: the census is real; accounting is exact in both directions; the + * POPULATION is an identity across all 109 of this package's sources; hygiene is + * backed by the anti-vacuity measurement; and — specific to this package — the + * `static-asset` extension is CONTAINED, so the sixth word cannot become a + * parking space for a route somebody did not want to think about. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { CONSOLE_ROUTE_LEDGER } from './console-route-ledger.js'; + +/** + * Seeded from `import.meta.url`, the spelling `check:cross-package-test-inputs` + * resolves STATICALLY (this package is `"type": "module"`). `SRC_DIR` is this + * package's own `src/`, one level up from `src/utils/` — the read does not + * escape the package. + */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC_DIR = join(HERE, '..'); + +/** The registrar module whose mount calls this census reads. */ +const MOUNT_SOURCES = ['utils/console.ts'] as const; + +/** + * The other file in this package that reaches for the host app. It installs a + * middleware LANE (`rawApp.use('*', …)`, the unknown-hostname guard) and mounts + * no route. Declared rather than ignored, and asserted below to still mount + * nothing — so the day it grows a route, this guard says so. + */ +const LANE_ONLY_REACH = 'commands/serve.ts'; + +/** The ledger module is excluded from the scan: it is the DECLARATION. */ +const SCAN_EXCLUDED = new Set(['console-route-ledger.ts']); + +/** Members that MOUNT a route. */ +const ROUTING_MEMBERS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'all']); + +/** Members that are lanes, not routes — recorded, then ignored. */ +const NON_ROUTE_MEMBERS = new Set(['use', 'notFound', 'onError', 'fire', 'fetch', 'request', 'route']); + +// --------------------------------------------------------------------------- +// Scanning machinery +// --------------------------------------------------------------------------- + +/** + * Strip comments before scanning, PRESERVING newlines inside block comments so + * every finding's `file:line` points at the real line — `console.ts` opens with + * a 35-line header, and reporting a mount 35 lines short makes an accurate + * finding read as a wrong one. + */ +export function stripComments(source: string): string { + let out = ''; + let i = 0; + while (i < source.length) { + const c = source[i]; + const next = source[i + 1]; + if (c === '/' && next === '/') { + while (i < source.length && source[i] !== '\n') i++; + continue; + } + if (c === '/' && next === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { + if (source[i] === '\n') out += '\n'; + i++; + } + i += 2; + continue; + } + if (c === '\'' || c === '"' || c === '`') { + const quote = c; + out += c; + i++; + while (i < source.length) { + if (source[i] === '\\') { out += source.slice(i, i + 2); i += 2; continue; } + out += source[i]; + if (source[i] === quote) { i++; break; } + i++; + } + continue; + } + out += c; + i++; + } + return out; +} + +/** Module-scope `const NAME = '';` bindings, exported or not. */ +export function constantBindings(code: string): Map { + const out = new Map(); + for (const m of code.matchAll(/\b(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(['"])([^'"]*)\2\s*;/g)) { + out.set(m[1], m[3]); + } + return out; +} + +/** Resolve a mount call's first argument to a wire path, or null (a FINDING). */ +export function resolveFirstArg(rest: string, bindings: Map): string | null { + let i = 0; + while (i < rest.length && /\s/.test(rest[i])) i++; + const quote = rest[i]; + if (quote !== '`' && quote !== '\'' && quote !== '"') { + const ident = /^([A-Za-z_$][\w$]*)\s*[,)]/.exec(rest.slice(i)); + if (ident && bindings.has(ident[1])) return bindings.get(ident[1])!; + return null; + } + let raw = ''; + for (let j = i + 1; j < rest.length; j++) { + const ch = rest[j]; + if (ch === '\\') { raw += ch + (rest[j + 1] ?? ''); j += 1; continue; } + if (ch === quote) { + if (quote !== '`') return raw.includes('${') ? null : raw; + const resolved = raw.replace(/\$\{([A-Za-z_$][\w$]*)\}/g, (whole, name: string) => + bindings.has(name) ? bindings.get(name)! : whole, + ); + return resolved.includes('${') ? null : resolved; + } + if (ch === '\n' && quote !== '`') return null; + raw += ch; + } + return null; +} + +interface Census { + routes: { route: string; file: string; line: number }[]; + lanes: { member: string; file: string; line: number }[]; + unreadable: string[]; +} + +/** Every mount call on a host-app handle named `app` or `rawApp`, classified. */ +export function censusOf(files: readonly string[], read: (f: string) => string): Census { + const routes: Census['routes'] = []; + const lanes: Census['lanes'] = []; + const unreadable: string[] = []; + + for (const file of files) { + const code = stripComments(read(file)); + const bindings = constantBindings(code); + const lineOf = (index: number) => code.slice(0, index).split('\n').length; + + for (const m of code.matchAll(/\b(?:raw)?[Aa]pp\s*\[/g)) { + unreadable.push( + `${file}:${lineOf(m.index)} mounts through a COMPUTED member, whose verb this scan cannot ` + + 'resolve. Express it with a verb method, or teach this scan to read it.', + ); + } + + for (const m of code.matchAll(/\b(?:raw)?[Aa]pp\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/g)) { + const member = m[1]; + const line = lineOf(m.index); + if (NON_ROUTE_MEMBERS.has(member)) { lanes.push({ member, file, line }); continue; } + if (!ROUTING_MEMBERS.has(member)) { + unreadable.push( + `${file}:${line} calls \`app.${member}(…)\`, which is not a member this scan can read ` + + 'per-route. An unrecognised mount spelling is a FINDING, never a silent skip.', + ); + continue; + } + const path = resolveFirstArg(code.slice(m.index + m[0].length), bindings); + if (path === null) { + unreadable.push( + `${file}:${line} calls \`app.${member}(…)\` with a first argument this scan cannot resolve ` + + 'to a wire path, so the route it mounts cannot be enumerated.', + ); + continue; + } + routes.push({ route: `${member.toUpperCase()} ${path}`, file, line }); + } + } + + routes.sort((a, b) => a.route.localeCompare(b.route)); + return { routes, lanes, unreadable }; +} + +const readSource = (file: string): string => readFileSync(join(SRC_DIR, file), 'utf8'); + +/** Every non-test `.ts` under this package's `src/`, POSIX-spelled, recursively. */ +function packageSourceFiles(dir = SRC_DIR): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const abs = join(dir, entry); + if (statSync(abs).isDirectory()) { + if (entry === '__tests__' || entry === 'node_modules') continue; + out.push(...packageSourceFiles(abs)); + continue; + } + if (!entry.endsWith('.ts') || entry.endsWith('.test.ts')) continue; + if (SCAN_EXCLUDED.has(entry)) continue; + out.push(relative(SRC_DIR, abs).split(sep).join('/')); + } + return out.sort(); +} + +/** A mount-shaped call on a handle named `app` or `rawApp`. */ +const MOUNT_SHAPED = /\b(?:raw)?[Aa]pp\s*\.\s*(?:get|post|put|patch|delete|options|head|all)\s*\(/; + +const ledgerRoutes = (): Set => new Set(CONSOLE_ROUTE_LEDGER.map((e) => e.route)); +const liveCensus = (): Census => censusOf(MOUNT_SOURCES, readSource); + +// --------------------------------------------------------------------------- + +describe('cli console route ledger ↔ source census', () => { + it('the census is real — the scan observed mounts in this package', () => { + // ZERO IS NOT A CLEAN PACKAGE, IT IS A BROKEN SCAN. + const { routes } = liveCensus(); + expect(routes.length, 'the source scan observed NO mount at all — the scan is broken').toBeGreaterThan(0); + }); + + it('every mount lands through a member this scan can read', () => { + const { unreadable } = liveCensus(); + expect(unreadable, `mounts this guard cannot account for:\n${unreadable.join('\n')}`).toEqual([]); + }); + + it('every route mounted in source has a ledger entry', () => { + const ledger = ledgerRoutes(); + // EXACT equality on `METHOD /wire/path`: `GET /_console` is a strict + // PREFIX of `GET /_console/*`, so this package contains the very + // relation #10534's census got wrong. Matching without a right boundary + // would score the bare path accounted-for on the strength of its + // wildcard sibling's row. + const missing = liveCensus().routes.filter((r) => !ledger.has(r.route)); + expect( + missing.map((r) => `${r.route} (${r.file}:${r.line})`), + 'routes with no CONSOLE_ROUTE_LEDGER row. A new route needs a reviewed disposition in ' + + 'console-route-ledger.ts (#11882).', + ).toEqual([]); + }); + + it('every ledger entry is really mounted in source', () => { + const live = new Set(liveCensus().routes.map((r) => r.route)); + const stale = [...ledgerRoutes()].filter((r) => !live.has(r)); + expect(stale, 'CONSOLE_ROUTE_LEDGER rows this package no longer mounts').toEqual([]); + }); + + it('the prefix pair is ledgered as two distinct rows', () => { + // Said out loud rather than left implicit in the comparison operator: + // the bare path and the wildcard are DIFFERENT routes with different + // handlers, and a census that folded one into the other would report a + // complete surface while missing a real mount (#10534). + const routes = ledgerRoutes(); + expect(routes.has('GET /_console')).toBe(true); + expect(routes.has('GET /_console/*')).toBe(true); + }); + + it('no route is ledgered twice', () => { + const seen = new Set(); + const dupes = CONSOLE_ROUTE_LEDGER.map((e) => e.route).filter((r) => !seen.add(r)); + expect(dupes, `duplicate CONSOLE_ROUTE_LEDGER rows: ${dupes.join(', ')}`).toEqual([]); + }); + + it('every row names the file it is mounted in', () => { + const byRoute = new Map(liveCensus().routes.map((r) => [r.route, r.file])); + const wrong = CONSOLE_ROUTE_LEDGER + .filter((e) => byRoute.has(e.route) && byRoute.get(e.route) !== e.mountedIn) + .map((e) => `${e.route}: ledgered as '${e.mountedIn}' but mounted in '${byRoute.get(e.route)}'`); + expect(wrong, 'mountedIn values that do not match the census').toEqual([]); + }); +}); + +describe('cli mount population', () => { + it('utils/console.ts is the only file in this package that mounts a route', () => { + // An IDENTITY across all of this package's sources, not a count. The + // census above reads ONE file; without this sweep a route mounted from + // any of the other hundred-odd modules would be invisible to it, and a + // four-row ledger that misses a fifth mount is worse than no ledger, + // because it reads as a completed census. + const mounting = packageSourceFiles().filter((f) => MOUNT_SHAPED.test(stripComments(readSource(f)))); + expect( + mounting, + 'files mounting a route on a host app handle. A new one must be added to MOUNT_SOURCES and its ' + + 'routes ledgered before it lands.', + ).toEqual([...MOUNT_SOURCES]); + }); + + it('the declared lane-only reach still mounts no route', () => { + // `commands/serve.ts` takes the raw app to install the unknown-hostname + // guard as `rawApp.use('*', …)`. A middleware lane is not a route (the + // exclusion `check-auth-mount-ledger.mjs` makes), but the file is + // pinned here so that the day it mounts one, this fails rather than the + // route going unledgered. + const census = censusOf([LANE_ONLY_REACH], readSource); + expect( + census.routes, + `${LANE_ONLY_REACH} now mounts a route. Add it to MOUNT_SOURCES and ledger what it mounts.`, + ).toEqual([]); + expect(census.lanes.length, `${LANE_ONLY_REACH} no longer installs its middleware lane`).toBeGreaterThan(0); + }); +}); + +describe('cli console route ledger hygiene', () => { + it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => { + const sdkWithout = CONSOLE_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route); + expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]); + + // A FLOOR, not a proof — so that pasting three words is not the + // cheapest path (`check-auth-mount-ledger.mjs`'s rationale half). + const thin = CONSOLE_ROUTE_LEDGER + .filter((e) => e.disposition !== 'sdk' && (e.note ?? '').length < 60) + .map((e) => e.route); + expect(thin, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]); + }); + + it('the whole surface is audited as reaching NO client method', () => { + // Said as a MEASUREMENT rather than left implicit, because the + // assertion above holds vacuously while no row is `sdk` (the + // `service-datasource` rule). For this family the measurement is also + // the point of the disposition: static assets are not SDK surface by + // category, not by omission. + const claimed = CONSOLE_ROUTE_LEDGER.filter((e) => e.client != null).map((e) => e.route); + expect(claimed, `rows claiming a client method: ${claimed.join(', ')}`).toEqual([]); + }); + + it('`static-asset` is CONTAINED — it cannot become a parking space', () => { + // The sixth word earns its place only while it means what the header + // says. Two directions: + // + // (a) every `static-asset` row must actually serve bytes off disk or + // redirect to something that does — asserted through the note, + // which must name the mechanism rather than merely claim the word; + // (b) the whole ledger must still be static-asset-only, so the day an + // API route lands in this package it CANNOT inherit the word by + // sitting in the same file. It will fail here and force a real + // disposition. + const staticRows = CONSOLE_ROUTE_LEDGER.filter((e) => e.disposition === 'static-asset'); + const unjustified = staticRows + .filter((e) => !/redirect|serves|file|asset|dist|disk|bundle/i.test(e.note ?? '')) + .map((e) => e.route); + expect( + unjustified, + 'static-asset rows whose note does not name the byte-serving or redirect mechanism. The word is ' + + 'a reviewed NON-question, and the note is where that review lives.', + ).toEqual([]); + + const nonStatic = CONSOLE_ROUTE_LEDGER.filter((e) => e.disposition !== 'static-asset').map((e) => e.route); + expect( + nonStatic, + 'this package mounted a route that is NOT static-asset serving. That is a real disposition ' + + 'question (#11882 deliberately left the vocabulary extension contained to this family) — give ' + + 'it one of the five standard words with its evidence, and re-review this assertion.', + ).toEqual([]); + }); + + it('gap and mismatch counts only shrink', () => { + // Ratchet, not aspiration. This surface audited at ZERO of each + // (#11882) — and `gap` in particular is structurally unreachable here: + // it would assert the SDK ought to grow a method for fetching + // `index.html`. + expect(CONSOLE_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length).toBeLessThanOrEqual(0); + expect(CONSOLE_ROUTE_LEDGER.filter((e) => e.disposition === 'mismatch').length).toBeLessThanOrEqual(0); + }); + + it('the conditional mount is recorded as conditional', () => { + // The census reads the mount CALL and cannot see the branch around it. + // `GET /` is mounted only when `options.rootRedirect !== false`, so a + // row that implied "always mounted" would overstate the surface. + const root = CONSOLE_ROUTE_LEDGER.find((e) => e.route === 'GET /'); + expect(root?.conditional, '`GET /` is a guarded mount and the row must say so').toBeTruthy(); + expect( + stripComments(readSource('utils/console.ts')).includes('options?.rootRedirect !== false'), + 'the `GET /` mount guard changed shape — re-read it and update the row\'s `conditional`.', + ).toBe(true); + }); +}); + +describe('scan machinery, pinned in both directions', () => { + it('the comment stripper drops prose paths, keeps code paths, and preserves line numbers', () => { + const stripped = stripComments("// app.get('/ghost', h)\n/* a\nb */\napp.get(`/real`, h);\n"); + expect(stripped).not.toContain('ghost'); + expect(stripped).toContain('/real'); + expect(censusOf(['f.ts'], () => "/* a\nb\nc */\napp.get('/x', h);\n").routes[0].line).toBe(4); + }); + + it('resolves the spellings this package uses, and refuses the rest', () => { + const b = constantBindings("export const CONSOLE_PATH = '/_console';\n"); + expect(b.get('CONSOLE_PATH')).toBe('/_console'); + expect(resolveFirstArg('CONSOLE_PATH, h)', b)).toBe('/_console'); + expect(resolveFirstArg('`${CONSOLE_PATH}/*`, h)', b)).toBe('/_console/*'); + expect(resolveFirstArg("'/', h)", b)).toBe('/'); + expect(resolveFirstArg('`${unknown}/x`, h)', b)).toBeNull(); + }); + + it('an unledgered mount is REDDENED, and a lane is not a route', () => { + // LOAD-BEARING POSITIVE: without it the accounting assertions could + // pass because the recogniser matches nothing at all. + const added = censusOf(['f.ts'], () => "app.get('/_console/zzz-new', h);\n"); + expect(added.routes.map((r) => r.route)).toEqual(['GET /_console/zzz-new']); + expect(ledgerRoutes().has('GET /_console/zzz-new')).toBe(false); + + const lane = censusOf(['f.ts'], () => "rawApp.use('*', mw);\n"); + expect(lane.routes).toEqual([]); + expect(lane.lanes.map((l) => l.member)).toEqual(['use']); + + const odd = censusOf(['f.ts'], () => "app.on('GET', '/x', h);\n"); + expect(odd.unreadable.length).toBe(1); + }); +}); diff --git a/packages/cli/src/utils/console-route-ledger.ts b/packages/cli/src/utils/console-route-ledger.ts new file mode 100644 index 0000000000..9dcd401d64 --- /dev/null +++ b/packages/cli/src/utils/console-route-ledger.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * cli console route ledger — the audited disposition of every HTTP route this + * package mounts on the host app's framework-native handle (#11882, in the + * #3636 / #11863 pattern). + * + * WHY THIS EXISTS. Both plugin factories in `utils/console.ts` resolve the HTTP + * server, take the Hono handle through `getRawApp()`, and register straight on + * it. Those mounts are outside every ledger the platform had, and outside the + * reach of the #7526 live-mount parity gate as well: that gate reads + * `IHttpServer.getMountedRoutes()`, and "routes an adapter mounts on its + * framework-native handle behind `getRawApp` are outside this table by + * construction" — the contract's own words + * (`packages/spec/src/contracts/http-server.ts`). + * + * ## WHY THIS LEDGER HAS A SIXTH DISPOSITION, AND WHY THAT IS NOT A DISTORTION + * + * This is the family #11882 singled out. The routing comment on that card + * (2026-08-24) and the card body both flag it: these rows are **static-asset + * serving, not API surface**. None of the five words the REST-ledger vocabulary + * carries describes them truthfully, and the nearest one is actively + * misleading: + * + * - `sdk` — false. No client method builds these URLs, and none should. + * - `gap` — false, and it is the WRONG KIND of false: `gap` means + * "should be in the SDK and is not", and it is ratcheted to + * <= 0 across this programme. Filing a static file server as + * a gap would assert that `@objectstack/client` ought to + * grow a method for fetching `index.html`, and would reverse + * a ratchet to say it. + * - `server-only` — false. That word means an inbound integration door or a + * loopback (webhooks, HMAC-token reads). These are the + * opposite: outbound bytes to a browser. + * - `public` — TRUE but insufficient, and this is the trap. These routes + * ARE anonymous and browser-facing, so `public` is the + * nearest allowed word — which is exactly why it is the + * wrong one to reach for. It would file a static file + * server alongside genuine anonymous API endpoints like + * `GET /api/v1/runtime/config`, and a reader auditing the + * platform's unauthenticated API surface would find four + * rows here that are not API at all. The peer group is the + * discriminator (`check-auth-mount-ledger.mjs`'s rule), and + * these routes' peer group is a CDN, not an endpoint. + * + * `check-auth-mount-ledger.mjs` states the governing rule for precisely this + * situation: *"IF YOU CANNOT DECIDE, DO NOT PICK THE NEAREST ALLOWED WORD."* + * Here the disposition is not undecided — the card, the routing comment and the + * source all agree on what these are — so this ledger says it in a word that is + * true: `static-asset`. The precedent that a per-package ledger may extend the + * vocabulary when the shared words are false is `plugin-auth`, whose + * `AUTH_ROUTE_LEDGER` carries a sixth disposition of its own (`disabled`). + * + * The extension is deliberately CONTAINED: this type is package-local, so it + * cannot widen the vocabulary any other ledger is read against, and the guard + * asserts that `static-asset` is used ONLY for routes that serve bytes off + * disk — a word that could be reached for by an API route would just be a new + * parking space. + * + * SCOPE, re-derived on `origin/main` @ 2ba4329e rather than inherited from the + * filing: four routes across two plugin factories, both in `utils/console.ts`, + * which the guard confirms is the ONLY file in this package's 109 sources that + * mounts a route at all. + * + * This module is package-internal: it is the guard's data, not public API, and + * `@objectstack/cli` is a binary rather than a consumed library surface. It + * must stay import-free. + */ + +/** + * Disposition of a single cli-mounted route. The five REST-ledger words, plus + * `static-asset` — see the header for why the five cannot express this family. + */ +export type ConsoleRouteDisposition = + /** 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 (inbound integration doors, loopbacks). */ + | 'server-only' + /** Public, unauthenticated browser-facing API route. */ + | 'public' + /** Server and client disagree on the shape — needs reconciliation. */ + | 'mismatch' + /** + * Not API surface at all: serves bytes off disk (or redirects to something + * that does). An SDK method here would be a category error, so this word + * records a reviewed NON-question rather than a deferred one. + */ + | 'static-asset'; + +export interface ConsoleRouteLedgerEntry { + /** `VERB /path` — the full wire path, verbatim as mounted. */ + route: string; + /** Registrar family (the plugin factory that mounts it). */ + family: string; + /** The `src/`-relative file whose mount call produced this row. */ + mountedIn: string; + disposition: ConsoleRouteDisposition; + /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + client?: string; + /** + * True when the mount is guarded by a condition rather than unconditional. + * Recorded because the census reads SOURCE: it sees the mount call, not the + * branch around it, and a row that silently implied "always mounted" would + * overstate the surface. + */ + conditional?: string; + /** One-line rationale. Required for every non-`sdk` disposition. */ + note?: string; +} + +export const CONSOLE_ROUTE_LEDGER: readonly ConsoleRouteLedgerEntry[] = [ + // ── console SPA static serving (createConsoleStaticPlugin) ───────── + { + route: 'GET /', + family: 'console-static', + mountedIn: 'utils/console.ts', + disposition: 'static-asset', + conditional: 'options.rootRedirect !== false (default: mounted)', + note: + 'redirects the site root to `/_console/`. The Console is the default end-user surface, so claiming `/` is the ' + + 'intended behaviour in both dev and production once the Console is mounted at all; `os serve` gates whether ' + + 'it mounts via `--no-console` / `OS_DISABLE_CONSOLE=1`. Not API surface — a redirect to a static bundle. ' + + 'CONDITIONAL, and the ledger says so because the census reads the mount call and cannot see the branch.', + }, + { + route: 'GET /_console', + family: 'console-static', + mountedIn: 'utils/console.ts', + disposition: 'static-asset', + note: + 'redirects the bare mount path to its trailing-slash form, the ordinary SPA convention — the Console is built ' + + 'with `base: \'/_console/\'`, so relative asset URLs only resolve from the slashed path. Pure navigation ' + + 'plumbing for a static bundle; there is nothing here for an SDK to express.', + }, + { + route: 'GET /_console/*', + family: 'console-static', + mountedIn: 'utils/console.ts', + disposition: 'static-asset', + note: + 'serves the pre-built Console SPA verbatim from `dist/`, with HTML entry points routed through base-tag ' + + 'injection and an SPA fallback for client-side routes. Reads files off disk behind a path-traversal guard ' + + '(any resolved path escaping `dist/` is refused 403). A file server, not an endpoint: its peer group is a ' + + 'CDN origin, so no client method builds these URLs and none should.', + }, + + // ── runtime asset serving (second factory in the same module) ────── + { + route: 'GET /runtime/assets/:filename', + family: 'runtime-assets', + mountedIn: 'utils/console.ts', + disposition: 'static-asset', + note: + 'serves individual build assets off disk by filename, behind two guards: separators are stripped from the ' + + 'parameter and any resolved path escaping the assets directory is refused 403. Sent with a one-hour ' + + '`cache-control`, which is the tell that this is CDN-shaped rather than API-shaped. A distinct family from ' + + '`console-static` because it is a separate plugin factory with its own dist root and its own mount guard.', + }, +]; diff --git a/packages/cloud-connection/src/cloud-connection-route-ledger.conformance.test.ts b/packages/cloud-connection/src/cloud-connection-route-ledger.conformance.test.ts new file mode 100644 index 0000000000..564468f8e3 --- /dev/null +++ b/packages/cloud-connection/src/cloud-connection-route-ledger.conformance.test.ts @@ -0,0 +1,522 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * cloud-connection route-ledger conformance (#11882) — the guard that keeps + * this package's `getRawApp()`-mounted HTTP surface and its reviewed + * dispositions from drifting apart, in the #3636 / #11863 pattern. + * + * WHY A SOURCE SCAN AND NOT A LIFECYCLE DRIVE. #11863's trigger-api guard + * drives `ApiTriggerPlugin` through its real lifecycle, which works because + * that plugin resolves three services and mounts one route. All four registrars + * here mount from inside a `kernel:ready` hook behind resolutions of + * `http.server`/`http-server`, `env-registry`, `kernel-manager`, `manifest`, + * `metadata` and `objectql`, each guarded by a `try`/`catch` that RETURNS + * QUIETLY when the service is absent. A lifecycle drive over that would fail + * OPEN — it would observe zero mounts and every accounting assertion would pass + * vacuously — precisely when one of those resolutions changed. That is the + * "completed census" defect these ledgers exist to remove, so this guard reads + * SOURCE TEXT instead, the shape `check-auth-mount-ledger.mjs` (#10534) + * established for `rawApp` mounts. No import, no module resolution, no `dist/` + * between the edit and the reading. + * + * FOUR LIMBS. + * + * LIMB 1 — THE CENSUS IS REAL. The scan must find mounts at all. Zero is a + * broken recogniser, never a clean package. + * + * LIMB 2 — ACCOUNTING, EXACT. Every mount found in source has a ledger row and + * every row is really mounted, matched by EXACT `METHOD /wire/path` equality so + * a prefix route can never be credited to a longer sibling (#10534's own census + * read 5 when the truth was 6 for exactly that reason). + * + * LIMB 3 — THE POPULATION. Limb 2 only reads the four files it is told to read. + * A FIFTH registrar added to this package later would be invisible to it, and a + * sixteen-row ledger that misses a seventeenth mount is worse than no ledger, + * because it reads as a completed census. So the set of files reaching for the + * host app is asserted as an IDENTITY, not a count. + * + * LIMB 4 — HYGIENE AND ANTI-VACUITY. Every `sdk` row names its client method + * and every non-`sdk` row carries a substantive rationale. Because today's + * ledger contains no `sdk` row at all, the client half is asserted as the + * #11882 audit's actual FINDING (no row reaches a client method) rather than + * left to hold vacuously — the `service-datasource` precedent's rule: a guard + * that can only ever pass is the "declared but unverified" shape being removed. + * + * A PARTIAL READ MUST NOT REPORT AS A COMPLETE ONE (#10534 constraint 4). Every + * mount spelling this scan cannot read per-route is a FINDING, never a silent + * skip — except the one computed-member mount declared in + * `DECLARED_COMPUTED_MOUNTS` below, which is reconciled in both directions so + * the exemption cannot rot into a blind spot. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { CLOUD_CONNECTION_ROUTE_LEDGER } from './cloud-connection-route-ledger.js'; + +/** + * Seeded from `import.meta.url`, the spelling `check:cross-package-test-inputs` + * resolves STATICALLY (this package is `"type": "module"`). The read does not + * escape the package — it is this package's own `src/` — and the seed keeps + * that fact checkable rather than merely true. + */ +const SRC_DIR = dirname(fileURLToPath(import.meta.url)); + +/** The four registrars whose mount calls this census reads. */ +const MOUNT_SOURCES = [ + 'cloud-connection-plugin.ts', + 'marketplace-install-local-plugin.ts', + 'marketplace-proxy-plugin.ts', + 'runtime-config-plugin.ts', +] as const; + +/** + * The ledger module is excluded from the population scan for the obvious + * reason: it is the DECLARATION, and its prose quotes `getRawApp` and + * `http-server`. Scanning it would let the ledger satisfy itself. + */ +const SCAN_EXCLUDED = new Set(['cloud-connection-route-ledger.ts']); + +/** Hono members that MOUNT a route, keyed by the verb the ledger spells. */ +const ROUTING_MEMBERS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'all']); + +/** + * Members that are lanes, not routes — recorded, then ignored. `use` is a + * middleware lane (the same exclusion `check-auth-mount-ledger.mjs` makes); + * `all` is NOT here, because an `.all()` that answers requests is a route + * (#11863). The marketplace proxy's `.all()` answers; it is ledgered. + */ +const NON_ROUTE_MEMBERS = new Set(['use', 'notFound', 'onError', 'fire', 'fetch', 'request', 'route']); + +/** + * The ONE mount this scan cannot read per-route, declared rather than skipped. + * + * `marketplace-proxy-plugin.ts` mounts its handler through a computed member in + * the `rawApp.all` fallback arm: + * + * for (const m of ['get', 'head'] as const) { + * try { rawApp[m]?.(`${MARKETPLACE_PREFIX}/*`, handler); } catch {} + * } + * + * A textual scan cannot resolve `rawApp[m]`. It is exempt ONLY because the + * pattern it mounts is the SAME wire path the `ALL` row already covers, so no + * route escapes the ledger through it. Both halves of that claim are asserted + * below — the source still contains the computed spelling (so a rewrite cannot + * silently strip the declaration's subject) and the covering row still exists. + */ +const DECLARED_COMPUTED_MOUNTS = [ + { + file: 'marketplace-proxy-plugin.ts', + marker: 'rawApp[m]?.(`${MARKETPLACE_PREFIX}/*`, handler)', + coveredBy: 'ALL /api/v1/marketplace/*', + why: + 'the `rawApp.all` fallback arm mounts get/head on the SAME pattern the ALL row ledgers, ' + + 'so the computed spelling adds no unledgered wire path.', + }, +] as const; + +// --------------------------------------------------------------------------- +// Scanning machinery +// --------------------------------------------------------------------------- + +/** + * Strip comments before scanning. Prose cannot mount a route, and this + * package's headers quote every wire path they serve — so a raw-text scan would + * report a documented path as an unledgered mount, a false red on an accurate + * package. The three string forms are tracked so a literal CONTAINING comment + * punctuation (`'/api/v1/x/*'`, `'https://host'`) is never mistaken for a + * comment opener; `comment-stripper` below pins both directions, because a + * stripper that swallowed real code would make this scan silently blind, which + * is the failure that actually matters here. + */ +export function stripComments(source: string): string { + let out = ''; + let i = 0; + while (i < source.length) { + const c = source[i]; + const next = source[i + 1]; + if (c === '/' && next === '/') { + while (i < source.length && source[i] !== '\n') i++; + continue; + } + if (c === '/' && next === '*') { + i += 2; + // Newlines inside the block are PRESERVED. Line numbers are what + // every finding below points a reader at, and this package's + // headers run to eighty lines — swallowing them would send someone + // to a line eighty short of the mount, which reads as a wrong + // report rather than as the accurate one it is. + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { + if (source[i] === '\n') out += '\n'; + i++; + } + i += 2; + continue; + } + if (c === '\'' || c === '"' || c === '`') { + const quote = c; + out += c; + i++; + while (i < source.length) { + if (source[i] === '\\') { + out += source.slice(i, i + 2); + i += 2; + continue; + } + out += source[i]; + if (source[i] === quote) { + i++; + break; + } + i++; + } + continue; + } + out += c; + i++; + } + return out; +} + +/** Module-scope `const NAME = '';` bindings, for resolving mount paths. */ +export function constantBindings(code: string): Map { + const out = new Map(); + for (const m of code.matchAll(/\bconst\s+([A-Z][A-Z0-9_]*)\s*=\s*(['"])([^'"]*)\2\s*;/g)) { + out.set(m[1], m[3]); + } + return out; +} + +/** + * Read the first argument of a mount call and resolve it to a wire path. + * Returns `null` when the argument is not something this scan can resolve — + * which is a FINDING at the call site, never a skip. + */ +export function resolveFirstArg(rest: string, constants: Map): string | null { + let i = 0; + while (i < rest.length && /\s/.test(rest[i])) i++; + const quote = rest[i]; + + // A bare identifier: `rawApp.post(ROUTE_BASE, handler)`. + if (quote !== '`' && quote !== '\'' && quote !== '"') { + const ident = /^([A-Z][A-Z0-9_]*)\s*[,)]/.exec(rest.slice(i)); + if (ident && constants.has(ident[1])) return constants.get(ident[1])!; + return null; + } + + let raw = ''; + for (let j = i + 1; j < rest.length; j++) { + const ch = rest[j]; + if (ch === '\\') { raw += ch + (rest[j + 1] ?? ''); j += 1; continue; } + if (ch === quote) { + // A plain string literal is already the wire path. + if (quote !== '`') return raw.includes('${') ? null : raw; + // A template: substitute `${CONST}` from module scope; anything + // else left interpolated is unresolvable and must be reported. + const resolved = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name: string) => + constants.has(name) ? constants.get(name)! : whole, + ); + return resolved.includes('${') ? null : resolved; + } + if (ch === '\n' && quote !== '`') return null; + raw += ch; + } + return null; +} + +interface Census { + routes: { route: string; file: string; line: number }[]; + lanes: { member: string; file: string; line: number }[]; + unreadable: string[]; +} + +/** Every `rawApp.(…)` call across the declared mount sources, classified. */ +export function censusOf(files: readonly string[], read: (f: string) => string): Census { + const routes: Census['routes'] = []; + const lanes: Census['lanes'] = []; + const unreadable: string[] = []; + + for (const file of files) { + const code = stripComments(read(file)); + const constants = constantBindings(code); + const lineOf = (index: number) => code.slice(0, index).split('\n').length; + + // Computed-member access: `rawApp[m]?.(...)`. Reported unless declared. + for (const m of code.matchAll(/\brawApp\s*\[/g)) { + const declared = DECLARED_COMPUTED_MOUNTS.some((d) => d.file === file); + if (declared) continue; + unreadable.push( + `${file}:${lineOf(m.index)} mounts through a COMPUTED member (\`rawApp[…]\`), whose verb this ` + + 'scan cannot resolve. Declare it in DECLARED_COMPUTED_MOUNTS with the row that covers it, or ' + + 'express it with a verb method.', + ); + } + + for (const m of code.matchAll(/\brawApp\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/g)) { + const member = m[1]; + const line = lineOf(m.index); + if (NON_ROUTE_MEMBERS.has(member)) { lanes.push({ member, file, line }); continue; } + if (!ROUTING_MEMBERS.has(member)) { + unreadable.push( + `${file}:${line} calls \`rawApp.${member}(…)\`, which is not a member this scan can read ` + + 'per-route. An unrecognised mount spelling is a FINDING, never a silent skip — teach ' + + 'ROUTING_MEMBERS about it and ledger what it mounts.', + ); + continue; + } + const path = resolveFirstArg(code.slice(m.index + m[0].length), constants); + if (path === null) { + unreadable.push( + `${file}:${line} calls \`rawApp.${member}(…)\` with a first argument this scan cannot ` + + 'resolve to a wire path, so the route it mounts cannot be enumerated.', + ); + continue; + } + routes.push({ route: `${member.toUpperCase()} ${path}`, file, line }); + } + } + + routes.sort((a, b) => a.route.localeCompare(b.route)); + return { routes, lanes, unreadable }; +} + +const readSource = (file: string): string => readFileSync(join(SRC_DIR, file), 'utf8'); + +/** `.ts` files in this package's `src/`, minus tests and the ledger itself. */ +function packageSourceFiles(): string[] { + return readdirSync(SRC_DIR) + .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && !SCAN_EXCLUDED.has(f)) + .sort(); +} + +/** The spellings by which a module in this package reaches the HOST app. */ +const HOST_APP_REACH = /getRawApp|['"`]http-server['"`]|['"`]http\.server['"`]/; + +const ledgerRoutes = (): Set => new Set(CLOUD_CONNECTION_ROUTE_LEDGER.map((e) => e.route)); +const liveCensus = (): Census => censusOf(MOUNT_SOURCES, readSource); + +// --------------------------------------------------------------------------- + +describe('cloud-connection route ledger ↔ source census', () => { + it('the census is real — the scan observed mounts in this package', () => { + // ZERO IS NOT A CLEAN PACKAGE, IT IS A BROKEN SCAN. Every assertion + // below passes vacuously if the recogniser stops matching, and a ledger + // backed by a guard that sees nothing is the completed-census defect. + const { routes } = liveCensus(); + expect( + routes.length, + 'the source scan observed NO mount at all — the scan is broken, not the package', + ).toBeGreaterThan(0); + }); + + it('every mount lands through a member this scan can read', () => { + const { unreadable } = liveCensus(); + expect( + unreadable, + `mounts this guard cannot account for per-route:\n${unreadable.join('\n')}`, + ).toEqual([]); + }); + + it('every route mounted in source has a ledger entry', () => { + const ledger = ledgerRoutes(); + const { routes } = liveCensus(); + // EXACT equality on `METHOD /wire/path`: a prefix is never credited to + // a longer sibling (#10534). + const missing = routes.filter((r) => !ledger.has(r.route)); + expect( + missing.map((r) => `${r.route} (${r.file}:${r.line})`), + 'routes with no CLOUD_CONNECTION_ROUTE_LEDGER row. A new route needs a reviewed ' + + 'disposition in cloud-connection-route-ledger.ts (#11882) — a row is not enough, it must ' + + 'carry the evidence its disposition claims.', + ).toEqual([]); + }); + + it('every ledger entry is really mounted in source', () => { + const live = new Set(liveCensus().routes.map((r) => r.route)); + const stale = [...ledgerRoutes()].filter((r) => !live.has(r)); + expect( + stale, + 'CLOUD_CONNECTION_ROUTE_LEDGER rows this package no longer mounts. Remove or reclassify ' + + 'them so the ledger stays truthful.', + ).toEqual([]); + }); + + it('every row names the file it is mounted in, and that file is a declared mount source', () => { + const declared = new Set(MOUNT_SOURCES); + const census = liveCensus(); + const byRoute = new Map(census.routes.map((r) => [r.route, r.file])); + const wrong: string[] = []; + for (const e of CLOUD_CONNECTION_ROUTE_LEDGER) { + if (!declared.has(e.mountedIn)) { wrong.push(`${e.route}: '${e.mountedIn}' is not a declared mount source`); continue; } + const actual = byRoute.get(e.route); + if (actual && actual !== e.mountedIn) { + wrong.push(`${e.route}: ledgered as '${e.mountedIn}' but mounted in '${actual}'`); + } + } + expect(wrong, 'mountedIn values that do not match the census').toEqual([]); + }); + + it('no route is ledgered twice', () => { + const seen = new Set(); + const dupes = CLOUD_CONNECTION_ROUTE_LEDGER.map((e) => e.route).filter((r) => !seen.add(r)); + expect(dupes, `duplicate CLOUD_CONNECTION_ROUTE_LEDGER rows: ${dupes.join(', ')}`).toEqual([]); + }); +}); + +describe('cloud-connection mount population', () => { + it('the four declared mount sources are exactly the files that reach the host app', () => { + // An IDENTITY, not a count: the day a FIFTH module in this package + // resolves `http-server` or calls `getRawApp()`, this names it — and + // the census above, which only reads MOUNT_SOURCES, would not have. + const reaching = packageSourceFiles().filter((f) => HOST_APP_REACH.test(readSource(f))); + expect( + reaching, + 'files reaching for the host HTTP app. A registrar not listed in MOUNT_SOURCES is invisible ' + + 'to the census above — add it there and ledger its routes before adding it here.', + ).toEqual([...MOUNT_SOURCES].sort()); + }); + + it('every declared computed mount is still present, and still covered by a ledger row', () => { + // Reconciled in BOTH directions so the exemption cannot rot into a + // blind spot: the spelling it excuses must still exist (otherwise the + // declaration is stale and should be deleted), and the row it leans on + // must still be in the ledger (otherwise the mount is unaccounted). + const ledger = ledgerRoutes(); + const stale: string[] = []; + const uncovered: string[] = []; + for (const d of DECLARED_COMPUTED_MOUNTS) { + if (!readSource(d.file).includes(d.marker)) { + stale.push(`${d.file}: the declared computed mount \`${d.marker}\` is no longer in source — delete the declaration`); + } + if (!ledger.has(d.coveredBy)) { + uncovered.push(`${d.file}: declared as covered by '${d.coveredBy}', which is not a ledger row`); + } + } + expect(stale, 'stale DECLARED_COMPUTED_MOUNTS entries').toEqual([]); + expect(uncovered, 'DECLARED_COMPUTED_MOUNTS entries whose covering row is gone').toEqual([]); + }); +}); + +describe('cloud-connection route ledger hygiene', () => { + it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => { + const sdkWithout = CLOUD_CONNECTION_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route); + expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]); + + // A FLOOR, not a proof — it exists so that pasting three words is not + // the cheapest path (`check-auth-mount-ledger.mjs`'s rationale half). + const thin = CLOUD_CONNECTION_ROUTE_LEDGER + .filter((e) => e.disposition !== 'sdk' && (e.note ?? '').length < 60) + .map((e) => e.route); + expect( + thin, + 'non-sdk entries must say WHY they are not SDK surface: who builds this URL instead, and why ' + + 'the SDK deliberately does not.', + ).toEqual([]); + }); + + it('the whole surface is audited as reaching NO client method', () => { + // Said as a MEASUREMENT rather than left implicit, because the + // assertion above holds vacuously while no row is `sdk` (the + // `service-datasource` rule). What is measured is the #11882 audit's + // finding: `@objectstack/client` was grepped for `cloud-connection`, + // `marketplace`, `runtime/config` and `install-local`, and the only hit + // in the package is a doc comment (`index.ts:1526`). No client method + // builds any of these URLs. + const claimed = CLOUD_CONNECTION_ROUTE_LEDGER.filter((e) => e.client != null).map((e) => e.route); + expect( + claimed, + `rows claiming a client method: ${claimed.join(', ')}. Promoting a row to SDK surface is a ` + + 'public-surface widening and belongs in the PR that adds the method, with the disposition ' + + 're-reviewed there.', + ).toEqual([]); + }); + + it('gap and mismatch counts only shrink', () => { + // Ratchet, not aspiration. This surface audited at ZERO of each + // (#11882): every route is either a same-origin console/CLI door or an + // anonymous boot/browse surface, so there is nothing the SDK is + // silently missing. + expect(CLOUD_CONNECTION_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length).toBeLessThanOrEqual(0); + expect(CLOUD_CONNECTION_ROUTE_LEDGER.filter((e) => e.disposition === 'mismatch').length).toBeLessThanOrEqual(0); + }); +}); + +describe('scan machinery, pinned in both directions', () => { + it('the comment stripper drops prose paths and keeps every path in code', () => { + const fixture = [ + "// mounts '/api/v1/commented-out'", + "/* block quoting '/api/v1/in-block' */", + 'rawApp.post(\'/api/v1/real/:id\', h);', + 'const glob = \'/api/v1/wild/*\';', + 'const url = "https://host/api/v1/double";', + 'rawApp.get(`/api/v1/tpl`, h);', + ].join('\n'); + const stripped = stripComments(fixture); + expect(stripped).not.toContain('commented-out'); + expect(stripped).not.toContain('in-block'); + // The ORDER is the second half of the pin: the wildcard and the URL sit + // BEFORE the last code path, so a stripper that read either string's + // punctuation as a comment opener would eat everything after it and + // `/api/v1/tpl` would be missing. That is the direction that matters — + // a stripper which swallows live code makes the census read clean while + // measuring nothing. + expect(stripped).toContain('/api/v1/tpl'); + expect(stripped).toContain('/api/v1/wild/*'); + }); + + it('the stripper preserves line numbers, so a finding points at the real line', () => { + // Every finding above quotes `file:line`. This package's headers run to + // eighty lines, so a stripper that swallowed block-comment newlines + // would report a mount eighty lines short of where it is — an accurate + // finding that reads as a wrong one. + const src = '/* a\nb\nc */\nrawApp.get(`/api/v1/x`, h);\n'; + const census = censusOf(['fake.ts'], () => src); + expect(census.routes[0].line).toBe(4); + }); + + it('resolves the three argument spellings this package actually uses', () => { + const constants = new Map([['ROUTE_BASE', '/api/v1/marketplace/install-local'], ['P', '/api/v1/cloud-connection']]); + // bare identifier — marketplace-install-local-plugin.ts + expect(resolveFirstArg('ROUTE_BASE, handler)', constants)).toBe('/api/v1/marketplace/install-local'); + // template + const — cloud-connection-plugin.ts + expect(resolveFirstArg('`${P}/status`, handler)', constants)).toBe('/api/v1/cloud-connection/status'); + // plain literal — runtime-config-plugin.ts + expect(resolveFirstArg("'/api/v1/runtime/config', handler)", constants)).toBe('/api/v1/runtime/config'); + }); + + it('refuses what it cannot resolve instead of inventing a path', () => { + const constants = new Map([['P', '/api/v1/x']]); + // An unresolved interpolation must REFUSE, not emit a path containing `${…}`. + expect(resolveFirstArg('`${P}/${dynamic}`, h)', constants)).toBeNull(); + expect(resolveFirstArg('`${UNKNOWN}/x`, h)', constants)).toBeNull(); + expect(resolveFirstArg('someLowerCaseVar, h)', constants)).toBeNull(); + }); + + it('an unledgered mount added to a mount source is REDDENED, naming the route', () => { + // LOAD-BEARING POSITIVE. Without this the accounting assertions could + // pass because the recogniser matches nothing at all. Driven through + // the same `censusOf` the live limbs use, with source injected. + const fake = "const P = '/api/v1/cloud-connection';\nrawApp.get(`${P}/zzz-new`, h);\n"; + const census = censusOf(['fake.ts'], () => fake); + expect(census.routes.map((r) => r.route)).toEqual(['GET /api/v1/cloud-connection/zzz-new']); + expect(new Set(CLOUD_CONNECTION_ROUTE_LEDGER.map((e) => e.route)).has('GET /api/v1/cloud-connection/zzz-new')).toBe(false); + }); + + it('a lane is not a route, and an unreadable member is a finding', () => { + const laneOnly = censusOf(['fake.ts'], () => "rawApp.use('*', mw);\n"); + expect(laneOnly.routes).toEqual([]); + expect(laneOnly.lanes.map((l) => l.member)).toEqual(['use']); + expect(laneOnly.unreadable).toEqual([]); + + const odd = censusOf(['fake.ts'], () => "rawApp.on('POST', '/api/v1/x', h);\n"); + expect(odd.unreadable.length).toBe(1); + expect(odd.unreadable[0]).toContain('rawApp.on'); + }); + + it('a commented-out mount is not a mount', () => { + const census = censusOf(['fake.ts'], () => "// rawApp.get('/api/v1/ghost', h);\n/* rawApp.post('/api/v1/ghost2', h); */\n"); + expect(census.routes).toEqual([]); + expect(census.unreadable).toEqual([]); + }); +}); diff --git a/packages/cloud-connection/src/cloud-connection-route-ledger.ts b/packages/cloud-connection/src/cloud-connection-route-ledger.ts new file mode 100644 index 0000000000..99fd667cec --- /dev/null +++ b/packages/cloud-connection/src/cloud-connection-route-ledger.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * cloud-connection route ledger — the audited disposition of every HTTP route + * this package mounts on the host app's framework-native handle, against what + * `@objectstack/client` can express (#11882, in the #3636 / #11863 pattern). + * + * WHY THIS EXISTS. All four registrars in this package resolve the + * `http-server` service at `kernel:ready`, take the Hono handle through + * `getRawApp()`, and register straight on it. That mount is outside every + * ledger the platform had, and — this is the part that makes a per-package + * ledger the ONLY available instrument — outside the reach of the #7526 + * live-mount parity gate as well: + * + * - the dispatcher ledger (`packages/runtime/src/route-ledger.ts`) sees + * `RouteManager` branches; none of these is one; + * - the REST ledger (`packages/rest/src/rest-route-ledger.ts`) sees whatever + * `RestServer.getRoutes()` reports, which never sees these mounts; + * - `route-ledger-live-mount-parity.dogfood.test.ts` boots a server and reads + * `IHttpServer.getMountedRoutes()`, and "routes an adapter mounts on its + * framework-native handle behind `getRawApp` are outside this table by + * construction" — the contract's own words + * (`packages/spec/src/contracts/http-server.ts`). These routes are not + * "not yet found" by that gate; they are UNFINDABLE by it. + * + * So the #3636 shape is the right one, and the guard is + * `cloud-connection-route-ledger.conformance.test.ts`. That guard reads this + * package's own SOURCE rather than driving four plugin lifecycles: every one of + * these registrars mounts from inside a `kernel:ready` hook behind a + * multi-service resolution (`env-registry`, `kernel-manager`, `manifest`, + * `metadata`, `objectql`), so a lifecycle drive would be mostly mock scaffolding + * and would fail OPEN — observing no mounts — exactly when a resolution changed. + * A source scan cannot be quietly emptied that way, and it is the shape + * `check-auth-mount-ledger.mjs` (#10534) established for `rawApp` mounts. + * + * SCOPE, re-derived on `origin/main` @ 2ba4329e rather than inherited from the + * filing: sixteen routes across four registrar families. Every path below was + * read off the mount call itself, with the module-scope prefix constant + * resolved; none is composed from configuration, so each row carries its wire + * path verbatim. + * + * WHY NO ROW IS `sdk`, measured rather than assumed. `@objectstack/client` was + * grepped for all four families — `cloud-connection`, `marketplace`, + * `runtime/config`, `install-local`. There is exactly ONE hit in the whole + * package and it is a doc comment (`index.ts:1526`, describing a payload shape + * "the same shape `marketplace-install-local` consumes"). No client method + * builds any of these URLs. That is the #11882 audit's finding, and the guard + * asserts it as a measurement rather than letting the `sdk`-row hygiene rule + * hold vacuously. + * + * The live half of that measurement is enforced next door BY OMISSION: this + * ledger is deliberately NOT one of `client-url-conformance.test.ts`'s union + * inputs, so a client method that started calling one of these routes would + * fail there for matching no ledger row at all. Adding this file to that union + * would remove exactly that protection. + * + * This module is package-internal (not exported from `index.ts`): it is the + * guard's data, not public API — nothing imports it into the bundle, so the + * published surface of `@objectstack/cloud-connection` is unchanged. It must + * stay import-free. + */ + +/** Disposition of a single cloud-connection route. Same vocabulary as the REST ledger. */ +export type CloudConnectionRouteDisposition = + /** 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 (inbound integration doors, loopbacks). */ + | 'server-only' + /** Public, unauthenticated browser-facing route. */ + | 'public' + /** Server and client disagree on the shape — needs reconciliation. */ + | 'mismatch'; + +export interface CloudConnectionRouteLedgerEntry { + /** `VERB /api/v1/...` — the full wire path, verbatim as mounted. */ + route: string; + /** Registrar family, for grouping and diff messages. */ + family: string; + /** The `src/` file whose mount call produced this row. */ + mountedIn: string; + disposition: CloudConnectionRouteDisposition; + /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + client?: string; + /** + * Name of the `@objectstack/spec/api` export declaring this route's response + * PAYLOAD. + * + * ⛔ DO NOT FILL A ROW THAT HAS NO CONFORMANCE COVERAGE — the same rule the + * REST, storage and datasource ledgers carry (#3877). A name written ahead + * of the test it points at would BE the "declared but unverified" surface + * the programme exists to remove. Every row below is unfilled: this + * package's suites assert status + body per outcome directly, not a payload + * schema, so none has earned the field. + */ + responseSchema?: string; + /** One-line rationale. Required for every non-`sdk` disposition. */ + note?: string; +} + +export const CLOUD_CONNECTION_ROUTE_LEDGER: readonly CloudConnectionRouteLedgerEntry[] = [ + // ── cloud binding + install proxy (ADR-0008 Phase 1) ─────────────── + // Same-origin doors for the Console's Setup surface. The SPA cannot call + // the control plane from a tenant subdomain (cross-origin, cross-site + // cookie), so the runtime answers on its own origin and talks to cloud + // server-to-server. The caller is the Console on the SAME origin, holding + // an environment session — not an SDK consumer. + { + route: 'GET /api/v1/cloud-connection/status', + family: 'cloud-connection', + mountedIn: 'cloud-connection-plugin.ts', + disposition: 'server-only', + note: + 'boot probe for the Console Setup panel: is this environment bound to a cloud account? Measured, not assumed — ' + + 'no `@objectstack/client` method builds a `/cloud-connection/*` URL; the consumer is the Console SPA\'s ' + + 'CloudConnectionPanel on the SAME origin, which this plugin\'s own header already names as the shape it serves. ' + + 'A same-origin deployment/binding console surface is deliberately not application-developer SDK surface.', + }, + { + route: 'POST /api/v1/cloud-connection/bind/start', + family: 'cloud-connection', + mountedIn: 'cloud-connection-plugin.ts', + disposition: 'server-only', + note: + 'begins an RFC 8628 device-code bind — the RUNTIME is the genuine device-flow client; it asks cloud for a device ' + + 'and user code and hands them to the Setup UI for an operator to approve in the cloud console. An SDK method ' + + 'here would put the device-flow client in the browser, which is the opposite of what ADR-0008 Phase 1 wires.', + }, + { + route: 'POST /api/v1/cloud-connection/bind/poll', + family: 'cloud-connection', + mountedIn: 'cloud-connection-plugin.ts', + disposition: 'server-only', + note: + 'polls the device-token endpoint and PERSISTS the resulting `oscc_…` runtime bearer to the on-disk credential ' + + 'store. The secret must never reach a browser, so this half of the bind flow is structurally server-side; the ' + + 'Console only drives it. No client method builds this URL.', + }, + { + route: 'POST /api/v1/cloud-connection/unbind', + family: 'cloud-connection', + mountedIn: 'cloud-connection-plugin.ts', + disposition: 'server-only', + note: + 'clears the persisted runtime binding — the mirror of bind/poll, and server-side for the same reason: it mutates ' + + 'the on-disk credential store, which no browser-side SDK can or should reach. NOTE: this route is absent from ' + + 'the plugin file\'s own header list, which documents seven of the eight; the census below reads the mount calls, ' + + 'not the header, which is how it is ledgered here at all.', + }, + { + route: 'POST /api/v1/cloud-connection/install', + family: 'cloud-connection', + mountedIn: 'cloud-connection-plugin.ts', + disposition: 'server-only', + note: + 'installs a package into this environment VIA the control plane, authorized by the env→cloud service credential ' + + 'the browser never holds. The SPA drives it same-origin; the credential and the cloud round-trip stay on the ' + + 'runtime. No `@objectstack/client` method builds a `/cloud-connection/install` URL.', + }, + { + route: 'GET /api/v1/cloud-connection/installation', + family: 'cloud-connection', + mountedIn: 'cloud-connection-plugin.ts', + disposition: 'server-only', + note: + 'single-package installed-state probe, read by the Console marketplace to decide whether to offer Install or ' + + 'Open. Same posture as its siblings: a same-origin proxy over a credentialed control-plane read, consumed by ' + + 'the Console rather than by any SDK method.', + }, + { + route: 'GET /api/v1/cloud-connection/installed', + family: 'cloud-connection', + mountedIn: 'cloud-connection-plugin.ts', + disposition: 'server-only', + note: + 'the environment\'s full installed list, backing the Console\'s "Installed" view. Proxies a credentialed ' + + 'control-plane read on the runtime\'s own origin; no client method builds this URL and the browser holds no ' + + 'credential that could replace the proxy.', + }, + { + route: 'GET /api/v1/cloud-connection/org-packages', + family: 'cloud-connection', + mountedIn: 'cloud-connection-plugin.ts', + disposition: 'server-only', + note: + 'the owning organization\'s own catalog, backing the Console\'s "Your organization" view. Requires the env→cloud ' + + 'service credential to enumerate private org packages, so it is a runtime-side proxy by construction rather ' + + 'than an SDK call the browser could make itself.', + }, + + // ── offline / local install (cloud ADR-0009) ──────────────────────── + // The one family with a measured NON-browser consumer: the CLI builds + // these URLs directly (`packages/cli/src/commands/package/install.ts:170`, + // `${runtime}/api/v1/marketplace/install-local`). That is the evidence the + // `server-only` claim rests on — a named caller that is not the SDK. + { + route: 'POST /api/v1/marketplace/install-local', + family: 'marketplace-install-local', + mountedIn: 'marketplace-install-local-plugin.ts', + disposition: 'server-only', + note: + 'installs a marketplace package into THIS kernel and caches the manifest to disk. Who builds this URL instead, ' + + 'measured: `packages/cli/src/commands/package/install.ts:170` composes it directly against the runtime base, ' + + 'and the Console\'s "Installed Apps" view calls it same-origin. Gated on `manage_metadata` (#8976). No ' + + '`@objectstack/client` method builds it.', + }, + { + route: 'GET /api/v1/marketplace/install-local', + family: 'marketplace-install-local', + mountedIn: 'marketplace-install-local-plugin.ts', + disposition: 'server-only', + note: + 'lists locally installed marketplace packages. Requires an authenticated principal (anonymous → 401), and ' + + '`installedBy` / `storageDir` are served only to a `manage_metadata` holder (#9011) — a per-principal ' + + 'projection the SDK does not model. Consumed by the CLI and the Console\'s Installed Apps view, not by any ' + + 'client method.', + }, + { + route: 'DELETE /api/v1/marketplace/install-local/:manifestId', + family: 'marketplace-install-local', + mountedIn: 'marketplace-install-local-plugin.ts', + disposition: 'server-only', + note: + 'removes the cached manifest from this runtime\'s disk; the kernel must restart to fully unload, since ' + + '`engine.registerApp` is additive only. A filesystem-mutating, restart-coupled operation local to one runtime ' + + 'is deliberately not SDK surface — the CLI and the Console Setup view drive it. Requires `manage_metadata` (#8976).', + }, + { + route: 'POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data', + family: 'marketplace-install-local', + mountedIn: 'marketplace-install-local-plugin.ts', + disposition: 'server-only', + note: + 'replays a packaged app\'s sample-data seed into this runtime — a local development/demo affordance behind ' + + '`manage_metadata` (#8976), driven from the Console\'s Installed Apps view. Not modelled by any client method, ' + + 'and not a shape an application SDK should be able to trigger against a remote environment.', + }, + { + route: 'POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data', + family: 'marketplace-install-local', + mountedIn: 'marketplace-install-local-plugin.ts', + disposition: 'server-only', + note: + 'the destructive mirror of reseed: drops the packaged app\'s seeded rows from this runtime. Behind ' + + '`manage_metadata` (#8976) and driven from the Console. No `@objectstack/client` method builds this URL, and ' + + 'a bulk data-purge door is deliberately not something the application SDK exposes.', + }, + + // ── marketplace browse passthrough ───────────────────────────────── + { + route: 'ALL /api/v1/marketplace/*', + family: 'marketplace-proxy', + mountedIn: 'marketplace-proxy-plugin.ts', + disposition: 'public', + note: + 'forwards marketplace browse to the configured control plane. `public` and not `server-only` because it is ' + + 'exactly what that word means here — an anonymous BROWSER surface: the cloud catalog endpoint is ' + + 'unauthenticated (it exposes only `sys_package.marketplace_listed = true` packages) and this proxy "passes ' + + 'through without any credentials", in the plugin header\'s own words. It exists so the Console SPA stays on ' + + 'the tenant origin and needs no CORS on the cloud side. Ledgered rather than waved through as a lane: an ' + + '`.all()` that ANSWERS requests is a route, per the trigger-api precedent (#11863); the auth ledger\'s ' + + 'catch-all exclusion covers a `.all()` that DELEGATES to a vendor router, which this does not.', + }, + + // ── boot-time runtime configuration ──────────────────────────────── + { + route: 'GET /api/v1/runtime/config', + family: 'runtime-config', + mountedIn: 'runtime-config-plugin.ts', + disposition: 'public', + note: + 'the anonymous boot-time read that tells the Console/Studio SPA its cloud URL, capability flags, branding and ' + + 'telemetry posture. Unauthenticated by construction — grepped for a session/principal/401 gate in this plugin ' + + 'and there is none, because the SPA must read it BEFORE it can authenticate. An SDK method here would be ' + + 'circular: this payload is what a client needs in order to know where to point, so it cannot be fetched ' + + 'through a configured client.', + }, + { + route: 'GET /api/v1/studio/runtime-config', + family: 'runtime-config', + mountedIn: 'runtime-config-plugin.ts', + disposition: 'public', + note: + 'legacy alias for older Studio bundles, mounted with the SAME handler instance as `/api/v1/runtime/config` — ' + + 'identical payload, identical anonymous posture. Ledgered as its own row because it is its own wire path: a ' + + 'census that folded aliases into their canonical sibling would stop reporting the day one of them moved.', + }, +]; diff --git a/packages/metadata/src/metadata-route-ledger.conformance.test.ts b/packages/metadata/src/metadata-route-ledger.conformance.test.ts new file mode 100644 index 0000000000..d166c09a66 --- /dev/null +++ b/packages/metadata/src/metadata-route-ledger.conformance.test.ts @@ -0,0 +1,468 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * metadata route-ledger conformance (#11882) — the guard that keeps this + * package's `getRawApp()`-mounted HTTP surface and its reviewed dispositions + * from drifting apart, in the #3636 / #11863 pattern. + * + * WHY A SOURCE SCAN AND NOT A LIFECYCLE DRIVE. `MetadataPlugin.start()` reaches + * this mount only after resolving a metadata artifact, a loader and an HTTP + * server, each behind a `try`/`catch` that continues quietly when the service + * is absent. A lifecycle drive over that fails OPEN — it observes no mount and + * every accounting assertion passes vacuously — precisely when one of those + * resolutions changes. That is the "completed census" defect these ledgers + * exist to remove, so this guard reads SOURCE TEXT, the shape + * `check-auth-mount-ledger.mjs` (#10534) established for `rawApp` mounts. + * + * FOUR LIMBS: the census is real; accounting is exact in both directions; the + * POPULATION is an identity (a second registrar cannot hide behind a two-row + * ledger that reads as a completed census); and hygiene is backed by the + * anti-vacuity measurement. + * + * THE SEAM LIMB, which is specific to this package. `registerMetadataHmrRoutes` + * takes an `options.path` that would move both wire paths. The ledger's rows are + * only exact because that seam is unreachable: the function is not re-exported + * from `index.ts` / `node.ts`, and its sole in-repo caller passes no options. + * Both halves are asserted, so the rows stop being the whole truth LOUDLY the + * day either changes. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { METADATA_ROUTE_LEDGER } from './metadata-route-ledger.js'; + +/** + * Seeded from `import.meta.url`, the spelling `check:cross-package-test-inputs` + * resolves STATICALLY (this package is `"type": "module"`). The read does not + * escape the package — it is this package's own `src/`. + */ +const SRC_DIR = dirname(fileURLToPath(import.meta.url)); + +/** The registrar module whose mount calls this census reads. */ +const MOUNT_SOURCES = ['routes/hmr-routes.ts'] as const; + +/** The one module that takes the host app handle and hands it on. */ +const HOST_APP_REACH_FILE = 'plugin.ts'; + +/** The ledger module is excluded from the scan: it is the DECLARATION. */ +const SCAN_EXCLUDED = new Set(['metadata-route-ledger.ts']); + +/** Members that MOUNT a route. */ +const ROUTING_MEMBERS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'all']); + +/** Members that are lanes, not routes — recorded, then ignored. */ +const NON_ROUTE_MEMBERS = new Set(['use', 'notFound', 'onError', 'fire', 'fetch', 'request', 'route']); + +// --------------------------------------------------------------------------- +// Scanning machinery +// --------------------------------------------------------------------------- + +/** + * Strip comments before scanning. Prose cannot mount a route, and this + * package's headers quote the wire paths they serve — a raw-text scan would + * report a documented path as an unledgered mount. Newlines inside block + * comments are PRESERVED so every finding's `file:line` points at the real + * line; `hmr-routes.ts` opens with a 27-line header, so swallowing them would + * report the mount 27 lines short of where it is. + */ +export function stripComments(source: string): string { + let out = ''; + let i = 0; + while (i < source.length) { + const c = source[i]; + const next = source[i + 1]; + if (c === '/' && next === '/') { + while (i < source.length && source[i] !== '\n') i++; + continue; + } + if (c === '/' && next === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { + if (source[i] === '\n') out += '\n'; + i++; + } + i += 2; + continue; + } + if (c === '\'' || c === '"' || c === '`') { + const quote = c; + out += c; + i++; + while (i < source.length) { + if (source[i] === '\\') { out += source.slice(i, i + 2); i += 2; continue; } + out += source[i]; + if (source[i] === quote) { i++; break; } + i++; + } + continue; + } + out += c; + i++; + } + return out; +} + +/** + * Blank out string CONTENTS, preserving quotes, length and newlines. + * + * Needed because `stripComments` deliberately keeps string contents — the + * census resolves wire paths out of them. But a STRUCTURAL question ("how many + * times is `getRawApp()` actually called?") must not count an occurrence inside + * a log message, and `plugin.ts` carries exactly that: a warning that reads + * `'HTTP server with getRawApp() not available — skipping HMR endpoint'`. + * Measured, not hypothetical — this masking step exists because the assertion + * below read 2 call sites when the truth is 1. + */ +export function maskStrings(code: string): string { + let out = ''; + let i = 0; + while (i < code.length) { + const c = code[i]; + if (c === '\'' || c === '"' || c === '`') { + const quote = c; + out += c; + i++; + while (i < code.length) { + if (code[i] === '\\') { out += ' '; i += 2; continue; } + if (code[i] === quote) { out += quote; i++; break; } + out += code[i] === '\n' ? '\n' : ' '; + i++; + } + continue; + } + out += c; + i++; + } + return out; +} + +/** + * Path bindings this module's mounts use. Two spellings are resolved: + * + * const ROUTE = '/literal'; // module-scope constant + * const routePath = options.path ?? '/default'; // the configurable seam + * + * The second is resolved to its DEFAULT, which is what the ledger rows carry — + * and the seam limb below is what makes that exact rather than a guess. + */ +export function pathBindings(code: string): Map { + const out = new Map(); + for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(['"])([^'"]*)\2\s*;/g)) { + out.set(m[1], m[3]); + } + for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*[\w.?]+\s*\?\?\s*(['"])([^'"]*)\2\s*;/g)) { + out.set(m[1], m[3]); + } + return out; +} + +/** Resolve a mount call's first argument to a wire path, or null (a FINDING). */ +export function resolveFirstArg(rest: string, bindings: Map): string | null { + let i = 0; + while (i < rest.length && /\s/.test(rest[i])) i++; + const quote = rest[i]; + if (quote !== '`' && quote !== '\'' && quote !== '"') { + const ident = /^([A-Za-z_$][\w$]*)\s*[,)]/.exec(rest.slice(i)); + if (ident && bindings.has(ident[1])) return bindings.get(ident[1])!; + return null; + } + let raw = ''; + for (let j = i + 1; j < rest.length; j++) { + const ch = rest[j]; + if (ch === '\\') { raw += ch + (rest[j + 1] ?? ''); j += 1; continue; } + if (ch === quote) { + if (quote !== '`') return raw.includes('${') ? null : raw; + const resolved = raw.replace(/\$\{([A-Za-z_$][\w$]*)\}/g, (whole, name: string) => + bindings.has(name) ? bindings.get(name)! : whole, + ); + return resolved.includes('${') ? null : resolved; + } + if (ch === '\n' && quote !== '`') return null; + raw += ch; + } + return null; +} + +interface Census { + routes: { route: string; file: string; line: number }[]; + lanes: { member: string; file: string; line: number }[]; + unreadable: string[]; +} + +/** + * Every mount call on a host-app handle, classified. The handle here is the + * `app` PARAMETER of `registerMetadataHmrRoutes`, not a `rawApp` local — this + * package receives the handle rather than resolving it at the mount site. + */ +export function censusOf(files: readonly string[], read: (f: string) => string): Census { + const routes: Census['routes'] = []; + const lanes: Census['lanes'] = []; + const unreadable: string[] = []; + + for (const file of files) { + const code = stripComments(read(file)); + const bindings = pathBindings(code); + const lineOf = (index: number) => code.slice(0, index).split('\n').length; + + for (const m of code.matchAll(/\bapp\s*\[/g)) { + unreadable.push( + `${file}:${lineOf(m.index)} mounts through a COMPUTED member (\`app[…]\`), whose verb this scan ` + + 'cannot resolve. Express it with a verb method, or teach this scan to read it.', + ); + } + + for (const m of code.matchAll(/\bapp\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/g)) { + const member = m[1]; + const line = lineOf(m.index); + if (NON_ROUTE_MEMBERS.has(member)) { lanes.push({ member, file, line }); continue; } + if (!ROUTING_MEMBERS.has(member)) { + unreadable.push( + `${file}:${line} calls \`app.${member}(…)\`, which is not a member this scan can read ` + + 'per-route. An unrecognised mount spelling is a FINDING, never a silent skip.', + ); + continue; + } + const path = resolveFirstArg(code.slice(m.index + m[0].length), bindings); + if (path === null) { + unreadable.push( + `${file}:${line} calls \`app.${member}(…)\` with a first argument this scan cannot resolve ` + + 'to a wire path, so the route it mounts cannot be enumerated.', + ); + continue; + } + routes.push({ route: `${member.toUpperCase()} ${path}`, file, line }); + } + } + + routes.sort((a, b) => a.route.localeCompare(b.route)); + return { routes, lanes, unreadable }; +} + +const readSource = (file: string): string => readFileSync(join(SRC_DIR, file), 'utf8'); + +/** Every non-test `.ts` under this package's `src/`, POSIX-spelled, recursively. */ +function packageSourceFiles(dir = SRC_DIR): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const abs = join(dir, entry); + if (statSync(abs).isDirectory()) { out.push(...packageSourceFiles(abs)); continue; } + if (!entry.endsWith('.ts') || entry.endsWith('.test.ts')) continue; + if (SCAN_EXCLUDED.has(entry)) continue; + out.push(relative(SRC_DIR, abs).split(sep).join('/')); + } + return out.sort(); +} + +/** The spellings by which a module reaches the HOST app. */ +const HOST_APP_REACH = /getRawApp|['"`]http-server['"`]|['"`]http\.server['"`]/; + +/** A mount-shaped call on a handle named `app` — how a SECOND registrar would look. */ +const MOUNT_SHAPED = /\bapp\s*\.\s*(?:get|post|put|patch|delete|options|head|all)\s*\(/; + +const ledgerRoutes = (): Set => new Set(METADATA_ROUTE_LEDGER.map((e) => e.route)); +const liveCensus = (): Census => censusOf(MOUNT_SOURCES, readSource); + +// --------------------------------------------------------------------------- + +describe('metadata route ledger ↔ source census', () => { + it('the census is real — the scan observed mounts in this package', () => { + // ZERO IS NOT A CLEAN PACKAGE, IT IS A BROKEN SCAN. + const { routes } = liveCensus(); + expect(routes.length, 'the source scan observed NO mount at all — the scan is broken').toBeGreaterThan(0); + }); + + it('every mount lands through a member this scan can read', () => { + const { unreadable } = liveCensus(); + expect(unreadable, `mounts this guard cannot account for:\n${unreadable.join('\n')}`).toEqual([]); + }); + + it('every route mounted in source has a ledger entry', () => { + const ledger = ledgerRoutes(); + // EXACT equality on `METHOD /wire/path` — a prefix is never credited to + // a longer sibling (#10534). + const missing = liveCensus().routes.filter((r) => !ledger.has(r.route)); + expect( + missing.map((r) => `${r.route} (${r.file}:${r.line})`), + 'routes with no METADATA_ROUTE_LEDGER row. A new route needs a reviewed disposition in ' + + 'metadata-route-ledger.ts (#11882) — a row is not enough, it must carry its evidence.', + ).toEqual([]); + }); + + it('every ledger entry is really mounted in source', () => { + const live = new Set(liveCensus().routes.map((r) => r.route)); + const stale = [...ledgerRoutes()].filter((r) => !live.has(r)); + expect(stale, 'METADATA_ROUTE_LEDGER rows this package no longer mounts').toEqual([]); + }); + + it('every row names the file it is mounted in', () => { + const byRoute = new Map(liveCensus().routes.map((r) => [r.route, r.file])); + const wrong = METADATA_ROUTE_LEDGER + .filter((e) => byRoute.has(e.route) && byRoute.get(e.route) !== e.mountedIn) + .map((e) => `${e.route}: ledgered as '${e.mountedIn}' but mounted in '${byRoute.get(e.route)}'`); + expect(wrong, 'mountedIn values that do not match the census').toEqual([]); + }); + + it('no route is ledgered twice', () => { + const seen = new Set(); + const dupes = METADATA_ROUTE_LEDGER.map((e) => e.route).filter((r) => !seen.add(r)); + expect(dupes, `duplicate METADATA_ROUTE_LEDGER rows: ${dupes.join(', ')}`).toEqual([]); + }); +}); + +describe('metadata mount population', () => { + it('plugin.ts is the only file that reaches for the host app', () => { + // An IDENTITY, not a count: the day a second module resolves + // `http-server` or calls `getRawApp()`, this names it. + const reaching = packageSourceFiles().filter((f) => HOST_APP_REACH.test(readSource(f))); + expect( + reaching, + 'files reaching for the host HTTP app. A second registrar is invisible to the census above — ' + + 'ledger its routes and add its module to MOUNT_SOURCES before adding it here.', + ).toEqual([HOST_APP_REACH_FILE]); + }); + + it('routes/hmr-routes.ts is the only file that mounts on a passed-in app handle', () => { + // The reach check above cannot see a registrar that RECEIVES the handle + // as a parameter — which is exactly how this package's own mount is + // written. So the mount SHAPE is swept package-wide too; without this, + // a second `register*Routes(app)` module would be invisible to both. + const mounting = packageSourceFiles().filter((f) => MOUNT_SHAPED.test(stripComments(readSource(f)))); + expect( + mounting, + 'files mounting on a passed-in app handle. A new one must be added to MOUNT_SOURCES and its ' + + 'routes ledgered.', + ).toEqual([...MOUNT_SOURCES]); + }); + + it('the host app handle is passed to exactly one registrar', () => { + // Strings are masked as well as comments: `plugin.ts` names + // `getRawApp()` inside a warning message, and a structural count that + // included it would report two call sites where there is one. + const code = maskStrings(stripComments(readSource(HOST_APP_REACH_FILE))); + const calls = [...code.matchAll(/getRawApp\s*\(\s*\)/g)]; + expect(calls.length, 'getRawApp() invocation sites in plugin.ts').toBe(1); + expect( + /registerMetadataHmrRoutes\s*\(\s*[\w.]*getRawApp\s*\(\s*\)/.test(code), + 'the one getRawApp() result is no longer handed straight to registerMetadataHmrRoutes — the ' + + 'handle now reaches somewhere this census does not read.', + ).toBe(true); + }); +}); + +describe('the options.path seam — why the rows are exact', () => { + it('the seam still defaults to the ledgered wire path', () => { + const code = stripComments(readSource('routes/hmr-routes.ts')); + expect( + /options\.path\s*\?\?\s*'\/api\/v1\/dev\/metadata-events'/.test(code), + 'the HMR default path changed. Both ledger rows carry the DEFAULT, so they are now wrong.', + ).toBe(true); + }); + + it('the seam is unreachable from outside this package', () => { + // The rows are exact ONLY because nothing can pass a custom path. If + // `registerMetadataHmrRoutes` is exported, a consumer can move both wire + // paths and this ledger silently stops describing the surface. + for (const entry of ['index.ts', 'node.ts']) { + const code = stripComments(readSource(entry)); + expect( + code.includes('registerMetadataHmrRoutes') || code.includes('hmr-routes'), + `${entry} now re-exports the HMR registrar. Its ` + + '`options.path` seam becomes reachable by consumers, so the ledger rows stop being the ' + + 'whole truth — re-review the rows and this assertion together.', + ).toBe(false); + } + }); + + it('the sole in-repo caller passes no options', () => { + const code = stripComments(readSource(HOST_APP_REACH_FILE)); + const call = /registerMetadataHmrRoutes\s*\(([^;]*)\)\s*;/.exec(code); + expect(call, 'the registrar call in plugin.ts could not be read').not.toBeNull(); + expect( + call![1].includes('path'), + 'plugin.ts now passes a `path` option, so the mounted wire path is no longer the default the ' + + 'ledger rows carry.', + ).toBe(false); + }); +}); + +describe('metadata route ledger hygiene', () => { + it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => { + const sdkWithout = METADATA_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route); + expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]); + + // A FLOOR, not a proof — so that pasting three words is not the + // cheapest path (`check-auth-mount-ledger.mjs`'s rationale half). + const thin = METADATA_ROUTE_LEDGER + .filter((e) => e.disposition !== 'sdk' && (e.note ?? '').length < 60) + .map((e) => e.route); + expect(thin, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]); + }); + + it('the whole surface is audited as reaching NO client method', () => { + // Said as a MEASUREMENT rather than left implicit, because the + // assertion above holds vacuously while no row is `sdk` (the + // `service-datasource` rule). Measured for #11882: + // `@objectstack/client` grepped for `metadata-events` — zero hits. + const claimed = METADATA_ROUTE_LEDGER.filter((e) => e.client != null).map((e) => e.route); + expect( + claimed, + `rows claiming a client method: ${claimed.join(', ')}. Promoting a row to SDK surface is a ` + + 'public-surface widening and belongs in the PR that adds the method.', + ).toEqual([]); + }); + + it('gap and mismatch counts only shrink', () => { + // Ratchet, not aspiration. This surface audited at ZERO of each + // (#11882): one SSE stream the SDK's transport does not model, one + // build-tool loopback. + expect(METADATA_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length).toBeLessThanOrEqual(0); + expect(METADATA_ROUTE_LEDGER.filter((e) => e.disposition === 'mismatch').length).toBeLessThanOrEqual(0); + }); +}); + +describe('scan machinery, pinned in both directions', () => { + it('the comment stripper drops prose paths, keeps code paths, and preserves line numbers', () => { + const stripped = stripComments("// app.get('/api/v1/ghost', h)\n/* a\nb */\napp.get(`/api/v1/real`, h);\n"); + expect(stripped).not.toContain('ghost'); + expect(stripped).toContain('/api/v1/real'); + expect(censusOf(['f.ts'], () => "/* a\nb\nc */\napp.get('/api/v1/x', h);\n").routes[0].line).toBe(4); + }); + + it('the string masker hides a call spelled inside a log message, and keeps real code', () => { + // The real case, from plugin.ts: a warning naming `getRawApp()`. Before + // this masking step the structural count above read 2 where the truth + // is 1 — an accurate-looking number that was measuring prose. + const src = "log('HTTP server with getRawApp() not available');\nconst a = http.getRawApp();\n"; + const masked = maskStrings(src); + expect([...masked.matchAll(/getRawApp\s*\(\s*\)/g)].length).toBe(1); + // Structure survives: quotes, line count and the live call are intact. + expect(masked.split('\n').length).toBe(src.split('\n').length); + expect(masked).toContain('http.getRawApp()'); + }); + + it('resolves both binding spellings this package uses, and refuses the rest', () => { + const b = pathBindings("const routePath = options.path ?? '/api/v1/dev/metadata-events';\nconst FIXED = '/api/v1/fixed';\n"); + expect(b.get('routePath')).toBe('/api/v1/dev/metadata-events'); + expect(b.get('FIXED')).toBe('/api/v1/fixed'); + expect(resolveFirstArg('routePath, h)', b)).toBe('/api/v1/dev/metadata-events'); + expect(resolveFirstArg('unknownVar, h)', b)).toBeNull(); + expect(resolveFirstArg('`${dynamic}/x`, h)', b)).toBeNull(); + }); + + it('an unledgered mount is REDDENED, and a lane is not a route', () => { + // LOAD-BEARING POSITIVE: without it the accounting assertions could + // pass because the recogniser matches nothing at all. + const added = censusOf(['f.ts'], () => "app.post('/api/v1/dev/zzz-new', h);\n"); + expect(added.routes.map((r) => r.route)).toEqual(['POST /api/v1/dev/zzz-new']); + expect(ledgerRoutes().has('POST /api/v1/dev/zzz-new')).toBe(false); + + const lane = censusOf(['f.ts'], () => "app.use('*', mw);\n"); + expect(lane.routes).toEqual([]); + expect(lane.lanes.map((l) => l.member)).toEqual(['use']); + + const odd = censusOf(['f.ts'], () => "app.on('POST', '/api/v1/x', h);\n"); + expect(odd.unreadable.length).toBe(1); + }); +}); diff --git a/packages/metadata/src/metadata-route-ledger.ts b/packages/metadata/src/metadata-route-ledger.ts new file mode 100644 index 0000000000..88f0d7a1a7 --- /dev/null +++ b/packages/metadata/src/metadata-route-ledger.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * metadata route ledger — the audited disposition of every HTTP route this + * package mounts on the host app's framework-native handle, against what + * `@objectstack/client` can express (#11882, in the #3636 / #11863 pattern). + * + * WHY THIS EXISTS. `MetadataPlugin.start()` resolves the + * `http.server`/`http-server` service, takes the Hono handle through + * `getRawApp()`, and hands it to `registerMetadataHmrRoutes()` + * (`plugin.ts:468`), which registers the HMR endpoints straight on it. That + * mount is outside every ledger the platform had, and outside the reach of the + * #7526 live-mount parity gate as well: that gate reads + * `IHttpServer.getMountedRoutes()`, and "routes an adapter mounts on its + * framework-native handle behind `getRawApp` are outside this table by + * construction" — the contract's own words + * (`packages/spec/src/contracts/http-server.ts`). These two routes are not "not + * yet found" by it; they are UNFINDABLE by it. Per-package ledgers (#3636) are + * the only instrument that reaches them. + * + * SCOPE, re-derived on `origin/main` @ 2ba4329e rather than inherited from the + * filing: two routes, one registrar module, one wire path served by both verbs. + * + * THE PATH HAS A CONFIGURABLE SEAM, AND IT IS UNUSED. `registerMetadataHmrRoutes` + * accepts `options.path` and falls back to `/api/v1/dev/metadata-events` + * (`routes/hmr-routes.ts:74`). Both rows below carry the DEFAULT, and that is + * exact rather than approximate, because the seam is unreachable from outside + * this package: `registerMetadataHmrRoutes` is not re-exported from `index.ts` + * or `node.ts`, and its sole in-repo caller — `plugin.ts:468` — passes no + * options at all. The guard asserts both halves, so the day the seam is + * exported or a caller starts passing a path, these rows stop being the whole + * truth loudly rather than quietly. + * + * WHY NEITHER ROW IS `sdk`, measured rather than assumed. `@objectstack/client` + * was grepped for `metadata-events`: zero hits. No client method builds this + * URL. That is the #11882 audit's finding for this package, and the guard + * asserts it as a measurement rather than letting the `sdk`-row hygiene rule + * hold vacuously. + * + * The live half of that measurement is enforced next door BY OMISSION: this + * ledger is deliberately NOT one of `client-url-conformance.test.ts`'s union + * inputs, so a client method that started calling this route would fail there + * for matching no ledger row at all. + * + * This module is package-internal (not exported from `index.ts`): it is the + * guard's data, not public API — nothing imports it into the bundle, so the + * published surface of `@objectstack/metadata` is unchanged. It must stay + * import-free. + */ + +/** Disposition of a single metadata route. Same vocabulary as the REST ledger. */ +export type MetadataRouteDisposition = + /** 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 (inbound integration doors, loopbacks). */ + | 'server-only' + /** Public, unauthenticated browser-facing route. */ + | 'public' + /** Server and client disagree on the shape — needs reconciliation. */ + | 'mismatch'; + +export interface MetadataRouteLedgerEntry { + /** `VERB /api/v1/...` — the full wire path, verbatim as mounted. */ + route: string; + /** Registrar family, for grouping and diff messages. */ + family: string; + /** The `src/`-relative file whose mount call produced this row. */ + mountedIn: string; + disposition: MetadataRouteDisposition; + /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + client?: string; + /** + * Name of the `@objectstack/spec/api` export declaring this route's response + * PAYLOAD. + * + * ⛔ DO NOT FILL A ROW THAT HAS NO CONFORMANCE COVERAGE — the same rule the + * REST, storage and datasource ledgers carry (#3877). A name written ahead + * of the test it points at would BE the "declared but unverified" surface + * the programme exists to remove. Both rows below are unfilled: the SSE + * stream emits `event:`-framed JSON rather than the `{ success, data }` + * envelope, and no schema export declares either payload. + */ + responseSchema?: string; + /** One-line rationale. Required for every non-`sdk` disposition. */ + note?: string; +} + +export const METADATA_ROUTE_LEDGER: readonly MetadataRouteLedgerEntry[] = [ + // ── metadata HMR (dev preview loop) ──────────────────────────────── + { + route: 'GET /api/v1/dev/metadata-events', + family: 'metadata-hmr', + mountedIn: 'routes/hmr-routes.ts', + disposition: 'public', + note: + 'a Server-Sent Events stream that pushes `metadata-change` / `reload` frames to Studio so an agent edit ' + + 'refreshes the preview without a manual reload. `public` because that is what the word means here — an ' + + 'anonymous browser surface: grepped for a session/principal/401 gate in `routes/hmr-routes.ts` and there is ' + + 'none. Not SDK surface: the consumer is an EventSource in the Studio shell, and `@objectstack/client` models ' + + 'request/response calls, not long-lived SSE subscriptions (its realtime channel is a separate transport). ' + + 'Zero hits for `metadata-events` anywhere in the client package.', + }, + { + route: 'POST /api/v1/dev/metadata-events', + family: 'metadata-hmr', + mountedIn: 'routes/hmr-routes.ts', + disposition: 'server-only', + note: + 'the manual reload trigger an external watch-recompile pipeline posts to after rebuilding the artifact — the ' + + 'package header names the caller: `os dev` watching TS sources. Who builds this URL instead, measured: the ' + + 'CLI, not the SDK (`packages/cli/src/commands/dev.ts:553` documents the endpoint as the one it drives). A ' + + 'build-tool loopback is deliberately not application SDK surface. POSTURE, recorded because a ledger row is ' + + 'where it becomes reviewable: this door carries NO authentication and MetadataPlugin applies no environment ' + + 'gate of its own — the plugin mounts it whenever a raw-app-capable HTTP server is present, and the only ' + + '`isDev` guard in the tree is on the CLI\'s SUPPLEMENTARY composition in `serve.ts`, not on this mount. The ' + + 'plugin\'s own comment states the posture as "production deployments simply won\'t have a CLI POSTing to this ' + + 'endpoint", which is a claim about who calls it, not a gate that stops them.', + }, +]; From 669232cdc7ffc53b53f3848e227e3d36fcacf511 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 11:36:27 +0000 Subject: [PATCH 2/2] build(cli): exclude the console route ledger from the shipped tsc program `packages/cli` compiles its whole `include` program rather than an entry graph, so the #11882 route ledger -- a review record read only by its own conformance test, which the test globs already exclude -- was emitted into the published tarball as ~10KB of permanently dead module. Its two sibling ledgers needed no such line only because `tsup` bundles from `src/index.ts` and never reached them; this makes cli's end state match theirs. Safe against a future import, measured rather than assumed: `exclude` filters the `include` glob but does NOT remove a file that an included file imports -- TypeScript still pulls such a file in through the module graph and emits it. So this line can under-exclude, never dangle. The ledger remains in the package's `tsconfig.json` typecheck program; only the build config drops it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa --- packages/cli/tsconfig.build.json | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json index a628977864..5dd3e205bf 100644 --- a/packages/cli/tsconfig.build.json +++ b/packages/cli/tsconfig.build.json @@ -25,5 +25,29 @@ // `packages/cli/.objectstack` residue survived a fix to the source: the run // was still executing the pre-fix compiled duplicate. It also means a source // test could be edited to pass while its stale twin asserted the old thing. - "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/__tests__/**"] + // + // `console-route-ledger.ts` joins them for the same reason one step over + // (#11882). It is a REVIEW RECORD, not runtime code: the audited disposition + // of every route this package mounts on the host Hono app, read only by + // `console-route-ledger.conformance.test.ts` — which is itself already + // excluded by the test globs above. Nothing in the shipped CLI imports it, so + // without this line `tsc` emitted ~10KB of permanently dead module into the + // published tarball. This package compiles its whole `include` program rather + // than an entry graph, which is why its two sibling ledgers + // (`cloud-connection`, `metadata`) needed no such line: `tsup` bundles from + // `src/index.ts` and never reached them. The exclusion makes this package's + // END STATE match theirs. + // + // Safe against a future import, and that is a measured property rather than a + // hope: `exclude` filters the `include` glob, it does NOT remove a file that + // an included file imports — TypeScript still pulls such a file in through + // the module graph and emits it. So if shipped code ever imports this ledger, + // it comes back into the build automatically; this line can under-exclude, + // never dangle. + "exclude": [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/__tests__/**", + "src/utils/console-route-ledger.ts" + ] }