diff --git a/.changeset/rest-route-ledger-audit-guard.md b/.changeset/rest-route-ledger-audit-guard.md new file mode 100644 index 000000000..ca230610d --- /dev/null +++ b/.changeset/rest-route-ledger-audit-guard.md @@ -0,0 +1,31 @@ +--- +"@objectstack/rest": patch +"@objectstack/client": patch +"@objectstack/runtime": patch +--- + +feat(rest): route audit tranche 2 — the REST surface gets its own ledger + +conformance guard (#3587, follow-up to #3563) + +The dispatcher tranche closed its 27 gaps and guards them (#3569…#3579), but +`@objectstack/rest` mounts a second, larger surface the client also reaches — +89 routes, never audited. `rest-route-ledger.ts` now records a reviewed +disposition for every one of them (38 sdk, 43 gap, 3 server-only, 3 public, +2 mismatch), and the guard is real enumeration on both sources: RouteManager +routes via the `getRoutes()` introspection seam, and the two +RouteManager-bypassing registrars (`package-routes.ts`, +`external-datasource-routes.ts`) via captured mock-server registrations — no +pinned-by-hand list. The client half +(`rest-route-ledger-coverage.test.ts`) verifies every claimed method exists; +a 43-gap ratchet is wired into CI. Every guard direction was negative-tested. + +Notable dispositions the audit surfaced: `POST /api/v1/packages` is a +publish/install shape collision between REST and the dispatcher (REST +registers first and wins) — ledgered `mismatch`; the REST +`GET /ui/view/:object/:type` path dialect is unreachable by the SDK's +query-param dialect — ledgered `mismatch`; `service-storage` / +`service-i18n` mount a third route surface outside `@objectstack/rest`, +explicitly out of scope here and tracked under #3587. + +No behavior change — data + tests only, plus a scope-note refresh in the +runtime ledger pointing at the new REST ledger. diff --git a/packages/client/src/rest-route-ledger-coverage.test.ts b/packages/client/src/rest-route-ledger-coverage.test.ts new file mode 100644 index 000000000..7b60e82f3 --- /dev/null +++ b/packages/client/src/rest-route-ledger-coverage.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * REST route-ledger ↔ client-surface conformance (#3587) — the client half of + * the guard whose server half lives in + * `packages/rest/src/rest-route-ledger.conformance.test.ts`. Same contract as + * the dispatcher-ledger guard next door (`route-ledger-coverage.test.ts`): + * every REST ledger entry that names a client method must resolve to a real + * function on an instantiated client. + * + * The ledger is imported as a relative SOURCE file deliberately: it is pure + * data (no imports), and a client→rest package edge for it would be backwards + * — the REST server is where the routes are declared, so the ledger lives + * there, and each package verifies its own half. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackClient } from './index'; +import { REST_ROUTE_LEDGER } from '../../rest/src/rest-route-ledger'; + +describe('REST route ledger ↔ @objectstack/client surface', () => { + const client = new ObjectStackClient({ baseUrl: 'http://localhost:9' }); + + const resolve = (path: string): unknown => + path.split('.').reduce((o, k) => (o == null ? o : (o as Record)[k]), client); + + it('every REST ledger entry naming a client method resolves to a real function', () => { + const broken = REST_ROUTE_LEDGER.filter((e) => e.client != null) + .filter((e) => typeof resolve(e.client!) !== 'function') + .map((e) => `${e.route} → client.${e.client}`); + expect( + broken, + `REST ledger entries claiming a client method that does not exist: ${broken.join('; ')}`, + ).toEqual([]); + }); +}); diff --git a/packages/rest/src/rest-route-ledger.conformance.test.ts b/packages/rest/src/rest-route-ledger.conformance.test.ts new file mode 100644 index 000000000..e0ada11a9 --- /dev/null +++ b/packages/rest/src/rest-route-ledger.conformance.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * REST route-ledger conformance (#3587) — the guard that keeps the REST + * server's route surface and `@objectstack/client` from drifting apart + * silently, mirroring the dispatcher guard (#3569). + * + * Directions made loud here: + * + * 1. A route mounted by `@objectstack/rest` with no ledger entry — a new + * route landed without a reviewed SDK disposition. + * 2. A ledger entry for a route the server no longer mounts — the ledger + * went stale. + * + * Enumeration is real on BOTH sources: `route-manager` rows against + * `RestServer.getRoutes()` (the introspection seam RouteManager already + * provides), and `direct-mount` rows against the registration calls the two + * bypass registrars make on a mock `IHttpServer` — no pinned-by-hand list. + * + * The third direction — "every `sdk` row names a client method that exists" — + * lives in `packages/client/src/rest-route-ledger-coverage.test.ts`, next to + * the SDK it introspects, for the same build-cycle reason as tranche 1: a + * rest→client edge is unbuildable (client's devDeps already reach back into + * the server packages, and CI's per-package test tasks build only their own + * dependency closure). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; +import { registerPackageRoutes } from './package-routes'; +import { registerExternalDatasourceRoutes } from './external-datasource-routes'; +import { REST_ROUTE_LEDGER } from './rest-route-ledger'; + +/** Minimal IHttpServer mock that records registrations. */ +function createMockServer() { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +/** + * Protocol mock with the batch capabilities present so the four + * protocol-capability-gated batch routes register (rest-server.ts). + */ +function createCapableProtocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({}), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + batchData: vi.fn().mockResolvedValue({}), + createManyData: vi.fn().mockResolvedValue([]), + updateManyData: vi.fn().mockResolvedValue([]), + deleteManyData: vi.fn().mockResolvedValue([]), + }; +} + +/** `VERB /path` keys for every route RouteManager holds at default config. */ +function enumerateRouteManagerRoutes(): Set { + const rest = new RestServer(createMockServer() as any, createCapableProtocol() as any, {} as any); + rest.registerRoutes(); + return new Set(rest.getRoutes().map((r) => `${r.method.toUpperCase()} ${r.path}`)); +} + +/** `VERB /path` keys captured from the two RouteManager-bypassing registrars. */ +function enumerateDirectMountRoutes(): Set { + const server = createMockServer(); + registerPackageRoutes(server as any, {} as any); + registerExternalDatasourceRoutes(server as any, { getService: () => undefined } as any); + const keys = new Set(); + for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) { + for (const call of (server[verb] as any).mock.calls) { + keys.add(`${verb.toUpperCase()} ${call[0]}`); + } + } + return keys; +} + +function ledgerKeys(source: 'route-manager' | 'direct-mount'): Set { + return new Set(REST_ROUTE_LEDGER.filter((e) => e.source === source).map((e) => e.route)); +} + +describe('REST route ledger ↔ RouteManager enumeration', () => { + it('every RouteManager-registered route has a ledger entry', () => { + const ledger = ledgerKeys('route-manager'); + const missing = [...enumerateRouteManagerRoutes()].filter((k) => !ledger.has(k)); + expect( + missing, + `REST routes with no rest-route-ledger entry: ${missing.join(', ')}. ` + + 'A new route needs a reviewed disposition in rest-route-ledger.ts (#3587).', + ).toEqual([]); + }); + + it('every route-manager ledger entry is a live RouteManager route', () => { + const live = enumerateRouteManagerRoutes(); + const stale = [...ledgerKeys('route-manager')].filter((k) => !live.has(k)); + expect( + stale, + `rest-route-ledger entries the server no longer mounts: ${stale.join(', ')}. ` + + 'Remove or reclassify them so the ledger stays truthful.', + ).toEqual([]); + }); +}); + +describe('REST route ledger ↔ direct-mount registrars', () => { + it('every directly-mounted route has a ledger entry', () => { + const ledger = ledgerKeys('direct-mount'); + const missing = [...enumerateDirectMountRoutes()].filter((k) => !ledger.has(k)); + expect( + missing, + `Directly-mounted routes with no rest-route-ledger entry: ${missing.join(', ')}.`, + ).toEqual([]); + }); + + it('every direct-mount ledger entry is really registered by its registrar', () => { + const live = enumerateDirectMountRoutes(); + const stale = [...ledgerKeys('direct-mount')].filter((k) => !live.has(k)); + expect( + stale, + `direct-mount rest-route-ledger entries no registrar mounts: ${stale.join(', ')}.`, + ).toEqual([]); + }); +}); + +describe('REST route ledger hygiene', () => { + it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => { + const sdkWithout = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route); + expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]); + + const bareNonSdk = REST_ROUTE_LEDGER.filter((e) => e.disposition !== 'sdk' && !e.note).map((e) => e.route); + expect(bareNonSdk, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]); + }); + + it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => { + // Ratchet, not aspiration: 43 audited gaps at #3587 PR-1 (metadata 9, + // data-actions 2, search 1, email 1, analytics 1, security-explain 2, + // record-shares 3, sharing-rules 5, reports 8, approvals 6, + // external-datasource 5). Closing a gap = reclassify to `sdk` AND lower + // this bound. Raising it demands an explicit, reviewed decision. + const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length; + expect(gaps).toBeLessThanOrEqual(43); + }); +}); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts new file mode 100644 index 000000000..cd5956db9 --- /dev/null +++ b/packages/rest/src/rest-route-ledger.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * REST route ledger — the audited disposition of every HTTP route + * `@objectstack/rest` mounts, against what `@objectstack/client` can express + * (#3587, tranche 2 of the #3563 audit). + * + * WHY THIS EXISTS. The dispatcher tranche (#3569…#3579) closed its 27 gaps and + * guards them; but the REST server mounts a second, LARGER surface the client + * also reaches, and until this file it had never been audited — the exact + * structural risk that shipped #3528 (a working route the SDK could not call, + * with nothing failing). `rest-route-ledger.conformance.test.ts` fails when a + * REST route appears with no ledger entry, and when an entry claims a client + * method that does not exist (client half: + * `packages/client/src/rest-route-ledger-coverage.test.ts`). A new REST route + * therefore lands with an explicit, reviewed disposition or not at all. + * + * SCOPE & SHAPE. Rows carry full wire paths at the DEFAULT unscoped base + * (`/api/v1`), exactly as `RestServer.getRoutes()` reports them — unlike the + * dispatcher ledger, which uses dispatcher-internal cleanPath patterns. When + * project scoping is on, every row is additionally mirrored under + * `/api/v1/environments/:environmentId` (rest-server.ts registerRoutes); the + * mirror is a mechanical duplication and is deliberately not re-ledgered. + * + * SOURCES. `route-manager` rows are enumerable via `RestServer.getRoutes()`. + * `direct-mount` rows come from the two registrars that bypass RouteManager + * and register straight on `IHttpServer` (`package-routes.ts`, + * `external-datasource-routes.ts`) — the conformance test enumerates those by + * capturing a mock server's registration calls, so they are guarded too. + * + * NOT COVERED HERE (known third surface): services that autonomously mount + * routes on the host `IHttpServer` — `service-storage` (`storage-routes.ts`, + * the SDK's whole storage surface) and `service-i18n`. Those live outside + * `@objectstack/rest`; auditing them is follow-up work under #3587. + * + * This module is package-internal (not exported from the index): it is the + * guard's data, not public API. It must stay import-free — the client-side + * guard imports it as a relative SOURCE file. + */ + +/** Disposition of a single REST route. Same vocabulary as the dispatcher ledger. */ +export type RestRouteDisposition = + /** 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 (docs pages, machine-readable spec, aliases). */ + | 'server-only' + /** Public, unauthenticated browser-facing route (anonymous forms etc.). */ + | 'public' + /** Server and client disagree on the shape — needs reconciliation. */ + | 'mismatch'; + +export interface RestRouteLedgerEntry { + /** `VERB /api/v1/...` — full wire path at the default unscoped base. */ + route: string; + /** Registrar family, for grouping and diff messages. */ + family: string; + /** How the route is registered — determines which enumeration guards it. */ + source: 'route-manager' | 'direct-mount'; + disposition: RestRouteDisposition; + /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + client?: string; + /** One-line rationale. Required for every non-`sdk` disposition. */ + note?: string; +} + +export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ + // ── discovery ───────────────────────────────────────────────────────────── + { route: 'GET /api/v1', family: 'discovery', source: 'route-manager', disposition: 'server-only', + note: 'bare-base discovery alias; the SDK connects via /api/v1/discovery' }, + { route: 'GET /api/v1/discovery', family: 'discovery', source: 'route-manager', disposition: 'sdk', client: 'connect', + note: 'duplicate mount with the dispatcher /discovery branch — REST registers first and wins' }, + + // ── openapi / docs ──────────────────────────────────────────────────────── + { route: 'GET /api/v1/openapi.json', family: 'openapi', source: 'route-manager', disposition: 'server-only', + note: 'machine-readable OpenAPI 3.1 (503 when not bundled); docs tooling, not SDK surface' }, + { route: 'GET /api/v1/docs', family: 'openapi', source: 'route-manager', disposition: 'server-only', + note: 'interactive Scalar HTML page' }, + + // ── metadata ────────────────────────────────────────────────────────────── + { route: 'GET /api/v1/meta', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getTypes' }, + { route: 'GET /api/v1/meta/diagnostics', family: 'metadata', source: 'route-manager', disposition: 'gap', + note: 'spec-validation diagnostics listing — Studio-grade admin data with no SDK expression' }, + { route: 'GET /api/v1/meta/_drafts', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.listDrafts' }, + { route: 'GET /api/v1/meta/:type', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItems' }, + { route: 'GET /api/v1/meta/:type/:name/references', family: 'metadata', source: 'route-manager', disposition: 'gap', + note: 'reverse-reference listing with no SDK expression' }, + { route: 'GET /api/v1/meta/book/:name/tree', family: 'metadata', source: 'route-manager', disposition: 'gap', + note: 'ADR-0046 §6 book-spine resolution with no SDK expression' }, + { route: 'GET /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem' }, + { route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem' }, + { route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem', + note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path' }, + { route: 'GET /api/v1/meta/:type/:name/history', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getHistory', + note: 'REST-only: the dispatcher /meta branch swallows /history as a compound name and 404s' }, + { route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'gap', + note: 'protection-audit events with no SDK expression' }, + { route: 'POST /api/v1/meta/:type/:name/publish', family: 'metadata', source: 'route-manager', disposition: 'gap', + note: 'per-item draft→active publish (ADR-0033) with no SDK expression; packages.publishDrafts covers only the package-scoped flow' }, + { route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'gap', + note: 'restore-at-history-version with no SDK expression' }, + { route: 'GET /api/v1/meta/:type/:name/diff', family: 'metadata', source: 'route-manager', disposition: 'gap', + note: 'metadata version diff (?from/?to) with no SDK expression' }, + { route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'gap', + note: 'compound-name read; meta.getItem URL-encodes the slash so it cannot reach this shape — needs the unencoded pass-through meta.getPublished got in #3579' }, + { route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'gap', + note: 'compound-name save; same encoding barrier as the compound read' }, + + // ── ui ──────────────────────────────────────────────────────────────────── + { route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'mismatch', + note: 'path-param dialect; meta.getView sends /ui/view/:object?type= (query dialect), which this pattern cannot match — the dispatcher /ui domain answers it instead. Reconcile shapes or retire one dialect.' }, + + // ── data CRUD ───────────────────────────────────────────────────────────── + { route: 'GET /api/v1/data/:object', family: 'crud', source: 'route-manager', disposition: 'sdk', client: 'data.find' }, + { route: 'GET /api/v1/data/:object/:id', family: 'crud', source: 'route-manager', disposition: 'sdk', client: 'data.get' }, + { route: 'POST /api/v1/data/:object', family: 'crud', source: 'route-manager', disposition: 'sdk', client: 'data.create' }, + { route: 'POST /api/v1/data/:object/query', family: 'crud', source: 'route-manager', disposition: 'sdk', client: 'data.query' }, + { route: 'PATCH /api/v1/data/:object/:id', family: 'crud', source: 'route-manager', disposition: 'sdk', client: 'data.update' }, + { route: 'DELETE /api/v1/data/:object/:id', family: 'crud', source: 'route-manager', disposition: 'sdk', client: 'data.delete' }, + + // ── data actions (clone / import / import jobs / export) ────────────────── + { route: 'POST /api/v1/data/:object/:id/clone', family: 'data-actions', source: 'route-manager', disposition: 'gap', + note: 'record clone (object enable.clone) with no SDK expression' }, + { route: 'POST /api/v1/data/:object/import', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.import' }, + { route: 'POST /api/v1/data/:object/import/jobs', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.createImportJob' }, + { route: 'POST /api/v1/data/import/jobs/:jobId/cancel', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.cancelImportJob' }, + { route: 'POST /api/v1/data/import/jobs/:jobId/undo', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.undoImportJob' }, + { route: 'GET /api/v1/data/import/jobs/:jobId/results', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.getImportJobResults' }, + { route: 'GET /api/v1/data/import/jobs/:jobId', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.getImportJobProgress' }, + { route: 'GET /api/v1/data/import/jobs', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.listImportJobs' }, + { route: 'GET /api/v1/data/:object/export', family: 'data-actions', source: 'route-manager', disposition: 'gap', + note: 'streaming CSV/JSON/XLSX export with no SDK expression' }, + + // ── search ──────────────────────────────────────────────────────────────── + { route: 'GET /api/v1/search', family: 'search', source: 'route-manager', disposition: 'gap', + note: 'global cross-object search with no SDK expression' }, + + // ── email ───────────────────────────────────────────────────────────────── + { route: 'POST /api/v1/email/send', family: 'email', source: 'route-manager', disposition: 'gap', + note: 'transactional send via EmailService with no SDK expression' }, + + // ── public forms ────────────────────────────────────────────────────────── + { route: 'GET /api/v1/forms/:slug', family: 'forms', source: 'route-manager', disposition: 'public', + note: 'anonymous public-form spec resolution — browser form runner, not authenticated SDK surface' }, + { route: 'POST /api/v1/forms/:slug/submit', family: 'forms', source: 'route-manager', disposition: 'public', + note: 'anonymous public-form submission' }, + { route: 'GET /api/v1/forms/:slug/lookup/:field', family: 'forms', source: 'route-manager', disposition: 'public', + note: 'anonymous scoped lookup picker (publicPicker-gated)' }, + + // ── analytics (semantic layer) ──────────────────────────────────────────── + { route: 'POST /api/v1/analytics/dataset/query', family: 'analytics', source: 'route-manager', disposition: 'gap', + note: 'ADR-0021 dataset preview/query with no SDK expression (analytics.query speaks the dispatcher dialect)' }, + + // ── security explain (ADR-0090 D6) ──────────────────────────────────────── + { route: 'GET /api/v1/security/explain', family: 'security-explain', source: 'route-manager', disposition: 'gap', + note: 'authorization explain (query form) with no SDK expression' }, + { route: 'POST /api/v1/security/explain', family: 'security-explain', source: 'route-manager', disposition: 'gap', + note: 'authorization explain (body form) with no SDK expression' }, + + // ── per-record shares ───────────────────────────────────────────────────── + { route: 'GET /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'gap', + note: 'per-record sharing grants listing with no SDK expression' }, + { route: 'POST /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'gap', + note: 'grant a per-record share with no SDK expression' }, + { route: 'DELETE /api/v1/data/:object/:id/shares/:shareId', family: 'record-shares', source: 'route-manager', disposition: 'gap', + note: 'revoke a per-record share with no SDK expression' }, + + // ── sharing rules ───────────────────────────────────────────────────────── + { route: 'GET /api/v1/sharing/rules', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', + note: 'sharing-rule listing with no SDK expression' }, + { route: 'POST /api/v1/sharing/rules', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', + note: 'sharing-rule upsert with no SDK expression' }, + { route: 'GET /api/v1/sharing/rules/:idOrName', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', + note: 'sharing-rule read with no SDK expression' }, + { route: 'DELETE /api/v1/sharing/rules/:idOrName', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', + note: 'sharing-rule delete (cascades materialised grants) with no SDK expression' }, + { route: 'POST /api/v1/sharing/rules/:idOrName/evaluate', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', + note: 'sharing-rule re-evaluation/reconcile with no SDK expression' }, + + // ── security suggested-bindings (ADR-0090 D5/D9) ────────────────────────── + { route: 'GET /api/v1/security/suggested-bindings', family: 'security', source: 'route-manager', disposition: 'sdk', client: 'security.suggestedBindings.list', + note: 'duplicate mount with the dispatcher /security domain — REST registers first and wins' }, + { route: 'POST /api/v1/security/suggested-bindings/:id/confirm', family: 'security', source: 'route-manager', disposition: 'sdk', client: 'security.suggestedBindings.confirm', + note: 'duplicate mount with the dispatcher /security domain' }, + { route: 'POST /api/v1/security/suggested-bindings/:id/dismiss', family: 'security', source: 'route-manager', disposition: 'sdk', client: 'security.suggestedBindings.dismiss', + note: 'duplicate mount with the dispatcher /security domain' }, + + // ── reports ─────────────────────────────────────────────────────────────── + { route: 'GET /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'gap', + note: 'saved-report listing with no SDK expression' }, + { route: 'POST /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'gap', + note: 'saved-report upsert with no SDK expression' }, + { route: 'GET /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'gap', + note: 'saved-report read with no SDK expression' }, + { route: 'DELETE /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'gap', + note: 'saved-report delete (cascades schedules) with no SDK expression' }, + { route: 'POST /api/v1/reports/:id/run', family: 'reports', source: 'route-manager', disposition: 'gap', + note: 'report execution with no SDK expression' }, + { route: 'POST /api/v1/reports/:id/schedule', family: 'reports', source: 'route-manager', disposition: 'gap', + note: 'recurring email schedule creation with no SDK expression' }, + { route: 'GET /api/v1/reports/:id/schedules', family: 'reports', source: 'route-manager', disposition: 'gap', + note: 'schedule listing with no SDK expression' }, + { route: 'DELETE /api/v1/reports/schedules/:scheduleId', family: 'reports', source: 'route-manager', disposition: 'gap', + note: 'schedule delete with no SDK expression' }, + + // ── approvals ───────────────────────────────────────────────────────────── + { route: 'GET /api/v1/approvals/requests', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.listRequests' }, + { route: 'GET /api/v1/approvals/requests/:id', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.getRequest' }, + { route: 'POST /api/v1/approvals/requests/:id/approve', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.approve' }, + { route: 'POST /api/v1/approvals/requests/:id/reject', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.reject' }, + { route: 'POST /api/v1/approvals/requests/:id/recall', family: 'approvals', source: 'route-manager', disposition: 'gap', + note: 'recall/withdraw with no SDK expression' }, + { route: 'POST /api/v1/approvals/requests/:id/revise', family: 'approvals', source: 'route-manager', disposition: 'gap', + note: 'send-back-for-revision (ADR-0044) with no SDK expression' }, + { route: 'POST /api/v1/approvals/requests/:id/resubmit', family: 'approvals', source: 'route-manager', disposition: 'gap', + note: 'resubmit-after-revision with no SDK expression' }, + { route: 'POST /api/v1/approvals/requests/:id/reassign', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.reassign' }, + { route: 'POST /api/v1/approvals/requests/:id/remind', family: 'approvals', source: 'route-manager', disposition: 'gap', + note: 'thread reminder with no SDK expression' }, + { route: 'POST /api/v1/approvals/requests/:id/request-info', family: 'approvals', source: 'route-manager', disposition: 'gap', + note: 'thread request-info with no SDK expression' }, + { route: 'POST /api/v1/approvals/requests/:id/comment', family: 'approvals', source: 'route-manager', disposition: 'gap', + note: 'thread comment with no SDK expression' }, + { route: 'GET /api/v1/approvals/requests/:id/actions', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.listActions' }, + + // ── batch ───────────────────────────────────────────────────────────────── + { route: 'POST /api/v1/batch', family: 'batch', source: 'route-manager', disposition: 'sdk', client: 'data.batchTransaction' }, + { route: 'POST /api/v1/data/:object/batch', family: 'batch', source: 'route-manager', disposition: 'sdk', client: 'data.batch' }, + { route: 'POST /api/v1/data/:object/createMany', family: 'batch', source: 'route-manager', disposition: 'sdk', client: 'data.createMany' }, + { route: 'POST /api/v1/data/:object/updateMany', family: 'batch', source: 'route-manager', disposition: 'sdk', client: 'data.updateMany' }, + { route: 'POST /api/v1/data/:object/deleteMany', family: 'batch', source: 'route-manager', disposition: 'sdk', client: 'data.deleteMany' }, + + // ── packages (direct-mount registrar, service-gated) ────────────────────── + { route: 'POST /api/v1/packages', family: 'packages', source: 'direct-mount', disposition: 'mismatch', + note: 'REST handler is registry publish ({manifest, metadata}); the dispatcher /packages domain gives POST /packages install semantics with a different body. REST registers first, so packages.install traffic lands here — reconcile before relying on either.' }, + { route: 'GET /api/v1/packages', family: 'packages', source: 'direct-mount', disposition: 'sdk', client: 'packages.list', + note: 'shadows the dispatcher twin (registered first); merges registry + database packages' }, + { route: 'GET /api/v1/packages/:id', family: 'packages', source: 'direct-mount', disposition: 'sdk', client: 'packages.get', + note: 'shadows the dispatcher twin (registered first)' }, + { route: 'DELETE /api/v1/packages/:id', family: 'packages', source: 'direct-mount', disposition: 'sdk', client: 'packages.uninstall', + note: 'shadows the dispatcher twin (registered first); full uninstall via protocol.deletePackage (#2747)' }, + + // ── external datasource federation (ADR-0015 §6.2, direct-mount) ────────── + { route: 'GET /api/v1/datasources/:name/external/tables', family: 'external-datasource', source: 'direct-mount', disposition: 'gap', + note: 'remote-table listing with no SDK expression (503-degrading when federation absent)' }, + { route: 'POST /api/v1/datasources/:name/external/tables/:remote/draft', family: 'external-datasource', source: 'direct-mount', disposition: 'gap', + note: 'object-draft generation with no SDK expression' }, + { route: 'POST /api/v1/datasources/:name/external/tables/:remote/import', family: 'external-datasource', source: 'direct-mount', disposition: 'gap', + note: 'import-as-federated-object with no SDK expression' }, + { route: 'POST /api/v1/datasources/:name/external/refresh-catalog', family: 'external-datasource', source: 'direct-mount', disposition: 'gap', + note: 'catalog refresh with no SDK expression' }, + { route: 'POST /api/v1/datasources/:name/external/validate', family: 'external-datasource', source: 'direct-mount', disposition: 'gap', + note: 'federated-object validation with no SDK expression' }, +]; diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index a6f56ee10..8b740b1ae 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -17,7 +17,8 @@ * dispatcher-internal `cleanPath` patterns (prepend `/api/v1` for the wire * path). The REST server (`@objectstack/rest`) mounts a second, larger surface * (search, forms, reports, sharing rules, …) that the client also reaches; - * auditing that surface is follow-up work tracked in #3563 — see + * that surface has its own ledger + guard since #3587 — + * `packages/rest/src/rest-route-ledger.ts`. Background: * `docs/audits/2026-07-dispatcher-client-route-coverage.md`. * * This module is runtime-internal (not exported from the package index): it is