diff --git a/.changeset/route-ledger-audit-guard.md b/.changeset/route-ledger-audit-guard.md new file mode 100644 index 0000000000..31e7958b9c --- /dev/null +++ b/.changeset/route-ledger-audit-guard.md @@ -0,0 +1,18 @@ +--- +"@objectstack/runtime": patch +"@objectstack/client": patch +--- + +Route ledger + conformance guard for the dispatcher↔client surface (#3563) + +#3528's root-cause class — a route that exists and works while +`@objectstack/client` has no way to express it — now has an inventory and a +ratchet. `route-ledger.ts` records the audited disposition of every dispatcher +route (sdk / gap / server-only / public / dynamic / mismatch); +The guard is split along the package boundary (a runtime→client edge is a +build cycle): runtime's `route-ledger.conformance.test.ts` fails when a +dispatcher domain lands with no ledger entry and ratchets the audited gap +count (27 at PR-1); client's `route-ledger-coverage.test.ts` fails when a +ledger entry claims a client method that doesn't exist. Findings and follow-up slicing live +in `docs/audits/2026-07-dispatcher-client-route-coverage.md`. No runtime +behavior change. diff --git a/docs/audits/2026-07-dispatcher-client-route-coverage.md b/docs/audits/2026-07-dispatcher-client-route-coverage.md new file mode 100644 index 0000000000..f0086673bb --- /dev/null +++ b/docs/audits/2026-07-dispatcher-client-route-coverage.md @@ -0,0 +1,145 @@ +# Dispatcher ↔ client route-coverage audit (#3563) + +**Date:** 2026-07-27 · **Trigger:** #3528 → #3552 · **Machine ledger:** `packages/runtime/src/route-ledger.ts` · **Guard:** `packages/runtime/src/route-ledger.conformance.test.ts` + +#3528 shipped because a route existed, worked, and was documented while the SDK +had no way to call it (`automation resume`, fixed in #3552). This audit asks how +many more instances of that class exist. Answer: **27 dispatcher routes with no +SDK expression, across 6 domains — plus a second, larger un-audited surface in +`@objectstack/rest`.** + +The audit is three-column, not two. A route can fail to reach users at three +distinct layers, and instances of all three were found: + +``` +dispatch() branch ↔ HTTP mount (dispatcher-plugin.ts) ↔ client method +``` + +## 1. Coverage by domain (dispatcher surface) + +Full per-route dispositions live in the ledger; this is the shape of it. + +| Domain | Routes | sdk | gap | other | +|---|---|---|---|---| +| `/automation` | 15 | 12 | 3 (`/actions`, `/connectors`, `/_status`) | — | +| `/packages` | 17 | 6 | 11 (publish/drafts/commits/revert/rollback/export/adopt/duplicate/edit) | — | +| `/meta` | 8 | 4 | 3 (`published`, `_drafts`, FSM states) | 1 server-only | +| `/data` | 6 | 6 | — | — | +| `/actions` | 3 | 0 | **3 — the largest functional hole** | — | +| `/share-links` | 5 | 0 | 3 | 2 public | +| `/security` | 3 | 0 | 3 | — | +| `/keys` | 1 | 0 | 1 (no SDK path to mint an API key at all) | — | +| `/i18n`, `/notifications` | 6 | 6 | — | — | +| `/analytics` | 3 | 1 | — | 2 **mismatch** (§4) | +| `/storage` | 2 | 0 | — | 2 **mismatch** (§4) | +| `/ui` | 1 | 1 (as `meta.getView`) | — | — | +| `/auth`, `/ai`, `/mcp*`, probes, discovery, openapi | — | — | — | wildcard / dynamic / server-only | + +`engine.registerAction`-registered actions (`POST /actions/...`, three shapes, +`http-dispatcher.ts:1951-1953`) are entirely unreachable from the SDK — every +console today hand-rolls `fetch` for them. + +## 2. Reachability: dispatch branches with no HTTP mount + +The dispatcher has **no catch-all**; `dispatcher-plugin.ts` mounts routes +explicitly, so a `dispatch()` branch without a mount is dead over HTTP in a +plain runtime (`dispatcher-plugin.routes.test.ts:9-14` records `/mcp`, `/keys`, +`/ready` shipping broken exactly this way). Currently mount-less: + +- `/security/*`, `/share-links/*`, `/ui/view/*`, `/meta/*`, `/data/*`, + `/openapi.json`, `/automation/actions|connectors|_status`, and the i18n + query-param variants. + +They are reachable only where `@objectstack/rest` or a host-mounted catch-all +(cloud) fronts the dispatcher — noted in the domain files themselves +(`domains/security.ts:13-15`, `domains/share-links.ts:11-14`). The +`route-parity.integration.test.ts` gate (#3369) probes a hardcoded list of six +paths; extending its probe list from the ledger is the natural follow-up. + +## 3. The stale spec route table + +`DEFAULT_DISPATCHER_ROUTES` (`packages/spec/src/api/dispatcher.zod.ts:141-164`) +is consumed by **nothing in `packages/runtime`** — only by its own tests and +`api-surface.json`. It lists `/workflow` and `/realtime` (no dispatcher branch +exists; `/realtime` is deliberately never advertised, +`http-dispatcher.ts:1128-1133`) and omits `/keys`, `/mcp`, `/mcp/skill`, +`/actions`, `/security`, `/share-links`, `/ready`, `/openapi.json`. + +Worse, `packages/client/CLIENT_SPEC_COMPLIANCE.md:14` anchors its "✅ FULLY +COMPLIANT" claim on that table — compliance measured against a route list that +predates five of the domains it should be measuring. Disposition: deprecate the +export (removal is a spec major) and retire the compliance doc or regenerate it +from the ledger. + +## 4. Shape mismatches (client speaks REST, dispatcher speaks dispatcher) + +| Dispatcher route | Client call | Effect | +|---|---|---| +| `GET /analytics/meta` (`domains/analytics.ts:49`) | `analytics.meta(cube)` → `GET /analytics/meta/:cube` | extra segment only REST understands | +| `POST /analytics/sql` (`analytics.ts:55`) | `analytics.explain()` → `POST /analytics/explain` | different route name entirely | +| `POST /storage/upload` (`domains/storage.ts:47`) | presigned/chunked protocol (`/upload/presigned`, `/upload/complete`, `/upload/chunked/*`) | client can't upload through a bare dispatcher | +| `GET /storage/file/:id` (`storage.ts:56`) | `storage.getDownloadUrl` → `GET /storage/files/:id/url` | ditto for download | + +These are ledgered as `mismatch`, not `sdk`: the method exists but does not +speak the dispatcher's dialect. Reconciliation (pick one shape, alias the +other) is its own follow-up. + +## 5. Client-internal findings + +- **`trigger` vs `execute`** hit different URLs for the same intent + (`index.ts:2170` `POST /automation/trigger/:name` vs `index.ts:2283` + `POST /automation/:name/trigger`). +- **`client.analytics.*` skips `unwrapResponse()`** (`index.ts:533-554`) — + callers get the raw envelope; every other surface unwraps. +- **`ScopedProjectClient` silently drops `If-Match`** on `update`/`delete` + (`index.ts:3567`, `:3608` vs unscoped `:3053`, `:3152`) — OCC capability loss + in environment-scoped code, and re-expresses only 4 of 20 surfaces. +- **`getRoute()` invents `views` and `permissions`** (`index.ts:3330-3331`) — + not in `ApiRoutesSchema`, so discovery can never override them; `projects` + (23 methods) bypasses routing entirely with hardcoded `/api/v1/cloud/*`. +- **`client.events` (`RealtimeAPI`) is a non-functional stub** — an in-memory + buffer nothing populates over the network (`realtime-api.ts:163-168`), while + `client.realtime.*` is the real HTTP surface. Two surfaces, one working. + +## 6. Documentation drift (all hand-written; no generator exists) + +`packages/client/README.md` documents **six methods that do not exist** +(`meta.getObject`, `views.share`, `views.setDefault`, `workflow.approve`, +`workflow.reject`, `ai.chat` — the last three were deliberately removed) and +claims "13 namespaces" where the SDK ships 20 (~171 methods; README says +"95+"). `content/docs/api/client-sdk.mdx` names `organizations` / +`projects` / `oauth` in its feature bullet and documents none of their 52 +methods. No script anywhere generates any of these lists. + +## 7. Test deserts + +Five entire surfaces have **zero test references**: `analytics`, `storage`, +`projects` (23), `organizations` (23), `oauth` — 62 methods, ~36% of the SDK. +(Grep across all five client test files; `tests/integration/` is additionally +excluded from `pnpm test` by config.) + +## 8. The second surface (out of ledger scope, tracked here) + +`@objectstack/rest` mounts routes the dispatcher never sees; the client +already targets some (views CRUD, permissions, workflow, approvals, realtime, +notification devices/preferences, presigned storage, import jobs). Zero client +expression exists for: `GET /search`, `POST /email/send`, `forms/:slug` +(3 routes), record shares (`/data/:object/:id/shares*`), `POST .../clone`, +`POST /analytics/dataset/query`, `sharing/rules` (5), `reports` (8), +`external-datasource/*` (`rest-server.ts:4514-5734`, +`external-datasource-routes.ts`). Auditing that surface with the same +three-column method is the next tranche of #3563. + +## Follow-up slicing (proposed) + +1. **`client.actions.invoke(...)`** — closes the largest hole (3 routes). +2. **`keys` / `share-links` / `security`** surfaces — 7 gaps, small and mechanical. +3. **Packages lifecycle** (11 gaps) — publish/drafts/commits/revert/export. +4. **Meta drafts/published/FSM + automation descriptors** (6 gaps). +5. **Mismatch reconciliation** (§4) — server-side aliases or client-side fixes. +6. **Docs**: delete or regenerate README surface table + CLIENT_SPEC_COMPLIANCE.md; extend client-sdk.mdx. +7. **Deprecate `DEFAULT_DISPATCHER_ROUTES`**; point at the ledger. +8. **REST-surface tranche** (§8) with the same ledger+guard treatment. + +Each gap closed must flip its ledger row to `sdk` and lower the ratchet bound +in the conformance test — the guard enforces both directions from PR-1 onward. diff --git a/packages/client/src/route-ledger-coverage.test.ts b/packages/client/src/route-ledger-coverage.test.ts new file mode 100644 index 0000000000..9017cfce04 --- /dev/null +++ b/packages/client/src/route-ledger-coverage.test.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Route-ledger ↔ client-surface conformance (#3563) — the client half of the + * guard whose dispatcher half lives in + * `packages/runtime/src/route-ledger.conformance.test.ts`. + * + * Every ledger entry that names a client method must resolve to a real + * function on an instantiated client — the ledger cannot claim coverage the + * SDK does not have. This is the direction #3528 shipped through: the ledger + * equivalent of the day would have said "resume → automation.resume" while no + * such method existed. + * + * The ledger is imported as a relative SOURCE file deliberately: it is pure + * data (no imports), and a client→runtime package edge for it would be + * backwards — runtime is where 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 { ROUTE_LEDGER } from '../../runtime/src/route-ledger'; + +describe('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 ledger entry naming a client method resolves to a real function', () => { + const broken = ROUTE_LEDGER.filter((e) => e.client != null) + .filter((e) => typeof resolve(e.client!) !== 'function') + .map((e) => `${e.route} → client.${e.client}`); + expect( + broken, + `Ledger entries claiming a client method that does not exist: ${broken.join('; ')}`, + ).toEqual([]); + }); +}); diff --git a/packages/runtime/src/route-ledger.conformance.test.ts b/packages/runtime/src/route-ledger.conformance.test.ts new file mode 100644 index 0000000000..ff055b3a93 --- /dev/null +++ b/packages/runtime/src/route-ledger.conformance.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Route-ledger conformance (#3563) — the guard that keeps the dispatcher's + * route surface and `@objectstack/client` from drifting apart silently again. + * + * #3528's root cause was exactly such a drift: the resume route existed and + * worked while the SDK had no way to express it, and nothing failed. These + * assertions make the two failure directions loud: + * + * 1. A domain registered on the dispatcher with no ledger entry — a new + * route surface landed without a reviewed SDK disposition. + * 2. A ledger entry claiming a client method that does not exist — the + * ledger says "covered" but the SDK cannot make the call. + * + * The ledger's per-route rows are documentation; the machine contract here is + * domain-level (registry introspection) plus method-level (client instance + * introspection). The legacy if-chain prefixes cannot be enumerated from the + * registry, so they are pinned in LEGACY_CHAIN_PREFIXES — when ADR-0076 D11 + * extracts another branch, that list and the ledger move together. + */ + +import { describe, it, expect } from 'vitest'; +import { HttpDispatcher } from './http-dispatcher.js'; +import { ROUTE_LEDGER, LEGACY_CHAIN_PREFIXES } from './route-ledger.js'; + +/** Minimal kernel — enough for the constructor to register builtin domains. */ +function fakeKernel(): any { + return { + getState: () => 'running', + getService: () => undefined, + getServiceAsync: async () => undefined, + }; +} + +/** Live registry prefixes, normalized (`/keys?` query-form → `/keys`). */ +function registryPrefixes(): Set { + const dispatcher = new HttpDispatcher(fakeKernel()); + const routes = (dispatcher as any).domainRegistry.list() as Array<{ prefix: string }>; + return new Set(routes.map((r) => (r.prefix.endsWith('?') ? r.prefix.slice(0, -1) : r.prefix))); +} + +describe('route ledger ↔ dispatcher domain registry', () => { + it('every registered dispatcher domain has at least one ledger entry', () => { + const ledgerDomains = new Set(ROUTE_LEDGER.map((e) => e.domain)); + const missing = [...registryPrefixes()].filter((p) => !ledgerDomains.has(p)); + expect( + missing, + `Dispatcher domains with no route-ledger entry: ${missing.join(', ')}. ` + + 'A new route surface needs a reviewed disposition in route-ledger.ts (#3563).', + ).toEqual([]); + }); + + it('every ledger domain is a live registry prefix or a pinned legacy branch', () => { + const live = registryPrefixes(); + const legacy = new Set(LEGACY_CHAIN_PREFIXES); + const stale = [...new Set(ROUTE_LEDGER.map((e) => e.domain))].filter( + (d) => !live.has(d) && !legacy.has(d), + ); + expect( + stale, + `Route-ledger domains that no longer exist on the dispatcher: ${stale.join(', ')}. ` + + 'Remove or reclassify them so the ledger stays truthful.', + ).toEqual([]); + }); +}); + +// The client-instance direction — "every named client method actually exists" +// — lives in packages/client/src/route-ledger-coverage.test.ts, next to the +// SDK it introspects. It cannot live here: a runtime→client edge (package OR +// dist import) is unbuildable — client's own devDeps point back at runtime +// (turbo rejects the cycle), and CI's per-package test tasks build only their +// own dependency closure, so the client dist does not exist for this suite. +describe('route ledger hygiene', () => { + it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => { + const sdkWithout = 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 = 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: 27 audited gaps at #3563 PR-1. Closing one = + // reclassify the entry to `sdk` AND lower this bound. Raising it demands + // an explicit, reviewed decision to ship a new un-expressed route. + const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length; + expect(gaps).toBeLessThanOrEqual(27); + }); +}); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts new file mode 100644 index 0000000000..99352d1c66 --- /dev/null +++ b/packages/runtime/src/route-ledger.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Route ledger — the audited disposition of every HTTP route the dispatcher + * serves, against what `@objectstack/client` can express (#3563). + * + * WHY THIS EXISTS. #3528 shipped because a route could exist, work, and be + * documented while the SDK had no way to call it (`resume` — fixed in #3552). + * That was one instance of a class: at audit time, six whole domains had zero + * client expression. This ledger makes the class visible and ratchets it — + * `route-ledger.conformance.test.ts` fails when a dispatcher domain appears + * with no ledger entry, and when an entry claims a client method that does not + * exist. A new route therefore lands with an explicit, reviewed disposition or + * not at all. + * + * SCOPE. Dispatcher (`http-dispatcher.ts`) routes only, expressed as + * 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 + * `docs/audits/2026-07-dispatcher-client-route-coverage.md`. + * + * This module is runtime-internal (not exported from the package index): it is + * the guard's data, not public API. Promotion to `@objectstack/spec` is a + * deliberate later step if other packages need it. + */ + +/** Disposition of a single dispatcher route. */ +export type RouteDisposition = + /** 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 (ops probes, agent/protocol endpoints). */ + | 'server-only' + /** Public, unauthenticated browser-facing route (token links etc.). */ + | 'public' + /** Route set is built at runtime (plugins/metadata) — not statically enumerable. */ + | 'dynamic' + /** Dispatcher and client disagree on the shape — needs reconciliation. */ + | 'mismatch'; + +export interface RouteLedgerEntry { + /** `VERB /cleanPath/:param` (or `* /prefix/**` for wildcard families). */ + route: string; + /** Owning domain — a registry prefix (e.g. `/automation`) or legacy-chain prefix. */ + domain: string; + disposition: RouteDisposition; + /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + client?: string; + /** One-line rationale. Required for every non-`sdk` disposition. */ + note?: string; +} + +/** + * Legacy `dispatch()` if-chain prefixes — branches not yet lifted into the + * `DomainHandlerRegistry` (ADR-0076 D11 step ③ is still extracting them). + * The conformance test asserts this list matches the ledger's `domain` set + * minus the live registry prefixes; when a branch is extracted or added, + * this list — and the ledger — must move with it. + */ +export const LEGACY_CHAIN_PREFIXES = [ + '/discovery', + '/meta', + '/data', + '/mcp/skill', + '/mcp', + '/actions', + '/openapi.json', + '/__api-endpoint', // handleApiEndpoint catch-all (metadata-declared endpoints) +] as const; + +export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ + // ── ops probes ──────────────────────────────────────────────────────────── + { route: 'GET /health', domain: '/health', disposition: 'server-only', note: 'liveness probe for orchestrators, not app traffic' }, + { route: 'GET /ready', domain: '/ready', disposition: 'server-only', note: 'readiness probe for orchestrators, not app traffic' }, + + // ── discovery ───────────────────────────────────────────────────────────── + { route: 'GET /discovery', domain: '/discovery', disposition: 'sdk', client: 'connect' }, + + // ── analytics ───────────────────────────────────────────────────────────── + { route: 'POST /analytics/query', domain: '/analytics', disposition: 'sdk', client: 'analytics.query' }, + { route: 'GET /analytics/meta', domain: '/analytics', disposition: 'mismatch', client: 'analytics.meta', + note: 'dispatcher serves GET /analytics/meta; client calls GET /analytics/meta/:cube — extra path segment only the REST server understands' }, + { route: 'POST /analytics/sql', domain: '/analytics', disposition: 'mismatch', client: 'analytics.explain', + note: 'dispatcher serves POST /analytics/sql; client calls POST /analytics/explain — different route name entirely' }, + + // ── i18n ────────────────────────────────────────────────────────────────── + { route: 'GET /i18n/locales', domain: '/i18n', disposition: 'sdk', client: 'i18n.getLocales' }, + { route: 'GET /i18n/translations/:locale', domain: '/i18n', disposition: 'sdk', client: 'i18n.getTranslations' }, + { route: 'GET /i18n/labels/:object/:locale', domain: '/i18n', disposition: 'sdk', client: 'i18n.getFieldLabels' }, + + // ── notifications ───────────────────────────────────────────────────────── + { route: 'GET /notifications', domain: '/notifications', disposition: 'sdk', client: 'notifications.list' }, + { route: 'POST /notifications/read', domain: '/notifications', disposition: 'sdk', client: 'notifications.markRead' }, + { route: 'POST /notifications/read/all', domain: '/notifications', disposition: 'sdk', client: 'notifications.markAllRead' }, + + // ── security (ADR-0090 suggested audience bindings) ─────────────────────── + { route: 'GET /security/suggested-bindings', domain: '/security', disposition: 'gap', note: 'no client expression for the whole domain' }, + { route: 'POST /security/suggested-bindings/:id/confirm', domain: '/security', disposition: 'gap', note: 'no client expression' }, + { route: 'POST /security/suggested-bindings/:id/dismiss', domain: '/security', disposition: 'gap', note: 'no client expression' }, + + // ── keys ────────────────────────────────────────────────────────────────── + { route: 'POST /keys', domain: '/keys', disposition: 'gap', + note: 'mints a sys_api_key (secret returned once); there is NO SDK path to create an API key' }, + + // ── storage ─────────────────────────────────────────────────────────────── + { route: 'POST /storage/upload', domain: '/storage', disposition: 'mismatch', client: 'storage.upload', + note: 'dispatcher serves plain POST /storage/upload; client speaks the REST presigned/chunked protocol (/upload/presigned, /upload/complete, /upload/chunked/*)' }, + { route: 'GET /storage/file/:id', domain: '/storage', disposition: 'mismatch', client: 'storage.getDownloadUrl', + note: 'dispatcher serves /storage/file/:id; client calls /storage/files/:id/url (REST shape)' }, + + // ── ui ──────────────────────────────────────────────────────────────────── + { route: 'GET /ui/view/:object/:type?', domain: '/ui', disposition: 'sdk', client: 'meta.getView', + note: 'the one ui-route call in the SDK is filed under client.meta, not a ui surface' }, + + // ── share-links ─────────────────────────────────────────────────────────── + { route: 'POST /share-links', domain: '/share-links', disposition: 'gap', note: 'create a link — no client expression for the whole domain' }, + { route: 'GET /share-links', domain: '/share-links', disposition: 'gap', note: 'list own links — no client expression' }, + { route: 'DELETE /share-links/:idOrToken', domain: '/share-links', disposition: 'gap', note: 'revoke — no client expression' }, + { route: 'GET /share-links/:token/resolve', domain: '/share-links', disposition: 'public', note: 'unauthenticated token resolution for shared-record pages' }, + { route: 'GET /share-links/:token/messages', domain: '/share-links', disposition: 'public', note: 'unauthenticated shared-conversation messages' }, + + // ── packages ────────────────────────────────────────────────────────────── + { route: 'GET /packages', domain: '/packages', disposition: 'sdk', client: 'packages.list' }, + { route: 'POST /packages', domain: '/packages', disposition: 'sdk', client: 'packages.install' }, + { route: 'GET /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.get' }, + { route: 'DELETE /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.uninstall' }, + { route: 'PATCH /packages/:id/enable', domain: '/packages', disposition: 'sdk', client: 'packages.enable' }, + { route: 'PATCH /packages/:id/disable', domain: '/packages', disposition: 'sdk', client: 'packages.disable' }, + { route: 'PATCH /packages/:id', domain: '/packages', disposition: 'gap', note: 'edit manifest — Studio-only via raw fetch today' }, + { route: 'POST /packages/:id/publish', domain: '/packages', disposition: 'gap', note: 'ADR-0033 publish — no client expression' }, + { route: 'POST /packages/:id/publish-drafts', domain: '/packages', disposition: 'gap', note: 'ADR-0033 — no client expression' }, + { route: 'POST /packages/:id/discard-drafts', domain: '/packages', disposition: 'gap', note: 'ADR-0033 — no client expression' }, + { route: 'GET /packages/:id/commits', domain: '/packages', disposition: 'gap', note: 'ADR-0067 timeline — no client expression' }, + { route: 'POST /packages/:id/commits/:commitId/revert', domain: '/packages', disposition: 'gap', note: 'ADR-0067 — no client expression' }, + { route: 'POST /packages/:id/rollback', domain: '/packages', disposition: 'gap', note: 'ADR-0067 — no client expression' }, + { route: 'POST /packages/:id/revert', domain: '/packages', disposition: 'gap', note: 'revert to published — no client expression' }, + { route: 'GET /packages/:id/export', domain: '/packages', disposition: 'gap', note: 'ADR-0070 export — no client expression' }, + { route: 'POST /packages/:id/adopt-orphans', domain: '/packages', disposition: 'gap', note: 'ADR-0070 D5 — no client expression' }, + { route: 'POST /packages/:id/duplicate', domain: '/packages', disposition: 'gap', note: 'ADR-0070 D4 — no client expression' }, + + // ── automation ──────────────────────────────────────────────────────────── + { route: 'POST /automation/trigger/:name', domain: '/automation', disposition: 'sdk', client: 'automation.trigger', + note: 'legacy verb-first shape; duplicates execute() against a different URL — candidates for consolidation' }, + { route: 'GET /automation', domain: '/automation', disposition: 'sdk', client: 'automation.list' }, + { route: 'POST /automation', domain: '/automation', disposition: 'sdk', client: 'automation.create' }, + { route: 'GET /automation/actions', domain: '/automation', disposition: 'gap', note: 'ADR-0018 action descriptors — no client expression' }, + { route: 'GET /automation/connectors', domain: '/automation', disposition: 'gap', note: 'ADR-0022 connector descriptors — no client expression' }, + { route: 'GET /automation/_status', domain: '/automation', disposition: 'gap', note: 'per-flow runtime state — no client expression' }, + { route: 'POST /automation/:name/trigger', domain: '/automation', disposition: 'sdk', client: 'automation.execute' }, + { route: 'POST /automation/:name/toggle', domain: '/automation', disposition: 'sdk', client: 'automation.toggle' }, + { route: 'POST /automation/:name/runs/:runId/resume', domain: '/automation', disposition: 'sdk', client: 'automation.resume' }, + { route: 'GET /automation/:name/runs/:runId/screen', domain: '/automation', disposition: 'sdk', client: 'automation.getScreen' }, + { route: 'GET /automation/:name/runs/:runId', domain: '/automation', disposition: 'sdk', client: 'automation.getRun' }, + { route: 'GET /automation/:name/runs', domain: '/automation', disposition: 'sdk', client: 'automation.listRuns' }, + { route: 'GET /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.get' }, + { route: 'PUT /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.update' }, + { route: 'DELETE /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.delete' }, + + // ── auth (better-auth passthrough) ──────────────────────────────────────── + { route: '* /auth/**', domain: '/auth', disposition: 'sdk', client: 'auth.me', + note: 'wholesale delegate to the auth service; the client expresses 26 selected better-auth endpoints (auth/oauth/organizations surfaces) — not enumerable route-by-route here' }, + + // ── ai (dynamic route table) ────────────────────────────────────────────── + { route: '* /ai/**', domain: '/ai', disposition: 'dynamic', + note: 'routes come from service-ai buildAIRoutes() at plugin start; client expresses nlq/suggest/insights against the REST AI routes' }, + + // ── meta (legacy chain) ─────────────────────────────────────────────────── + { route: 'GET /meta', domain: '/meta', disposition: 'sdk', client: 'meta.getTypes' }, + { route: 'GET /meta/types', domain: '/meta', disposition: 'server-only', note: 'richer types listing consumed by Studio tooling directly; client uses GET /meta' }, + { route: 'GET /meta/:type', domain: '/meta', disposition: 'sdk', client: 'meta.getItems' }, + { route: 'GET /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.getItem' }, + { route: 'PUT /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.saveItem' }, + { route: 'GET /meta/:type/:name/published', domain: '/meta', disposition: 'gap', note: 'ADR-0033 published version — no client expression' }, + { route: 'GET /meta/_drafts', domain: '/meta', disposition: 'gap', note: 'ADR-0033 pending drafts — no client expression' }, + { route: 'GET /meta/objects/:name/state/:field', domain: '/meta', disposition: 'gap', note: 'ADR-0020 legal next FSM states — no client expression' }, + + // ── data (legacy chain) ─────────────────────────────────────────────────── + { route: 'POST /data/:object/query', domain: '/data', disposition: 'sdk', client: 'data.query' }, + { route: 'GET /data/:object', domain: '/data', disposition: 'sdk', client: 'data.find' }, + { route: 'GET /data/:object/:id', domain: '/data', disposition: 'sdk', client: 'data.get' }, + { route: 'POST /data/:object', domain: '/data', disposition: 'sdk', client: 'data.create' }, + { route: 'PATCH /data/:object/:id', domain: '/data', disposition: 'sdk', client: 'data.update' }, + { route: 'DELETE /data/:object/:id', domain: '/data', disposition: 'sdk', client: 'data.delete' }, + + // ── mcp ─────────────────────────────────────────────────────────────────── + { route: 'GET /mcp/skill', domain: '/mcp/skill', disposition: 'server-only', note: 'public SKILL.md for agents; not JS-SDK surface' }, + { route: '* /mcp/**', domain: '/mcp', disposition: 'server-only', note: 'MCP Streamable HTTP transport — consumed by MCP clients, not this SDK' }, + + // ── actions ─────────────────────────────────────────────────────────────── + { route: 'POST /actions/:object/:action', domain: '/actions', disposition: 'gap', + note: 'server-registered actions (engine.registerAction) are entirely unreachable from the SDK — the largest functional hole' }, + { route: 'POST /actions/:object/:action/:recordId', domain: '/actions', disposition: 'gap', note: 'record-scoped variant — same hole' }, + { route: 'POST /actions/global/:action', domain: '/actions', disposition: 'gap', note: 'wildcard variant — same hole' }, + + // ── misc legacy ─────────────────────────────────────────────────────────── + { route: 'GET /openapi.json', domain: '/openapi.json', disposition: 'server-only', note: 'docs tooling; falls through when metadata service lacks a generator' }, + { route: '* (unmatched)', domain: '/__api-endpoint', disposition: 'dynamic', note: 'metadata-declared custom endpoints (flow/script/object_operation/proxy)' }, +]; diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index 8603048ea6..8bd526c2d9 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -6,6 +6,11 @@ import path from 'node:path'; export default defineConfig({ resolve: { alias: { + // Subpath before the bare package: the object form prefix-replaces, so + // without this entry `@objectstack/core/logger` (imported by the built + // client, see route-ledger.conformance.test.ts) resolves to the garbage + // path `…/core/src/index.ts/logger`. + '@objectstack/core/logger': path.resolve(__dirname, '../core/src/logger.ts'), '@objectstack/core': path.resolve(__dirname, '../core/src/index.ts'), '@objectstack/rest': path.resolve(__dirname, '../rest/src/index.ts'), '@objectstack/spec/ai': path.resolve(__dirname, '../spec/src/ai/index.ts'),