Skip to content

Commit 5bdd72e

Browse files
committed
feat(rest): REST route ledger + conformance guard — route audit tranche 2 PR-1 (#3587)
The dispatcher tranche (#3569..#3579) closed its 27 gaps and guards them; this brings the same regime to the second, larger surface. All 89 routes @objectstack/rest mounts now carry a reviewed disposition in rest-route-ledger.ts: 38 sdk, 43 gap (ratcheted), 3 server-only, 3 public, 2 mismatch. The guard enumerates BOTH sources for real — RouteManager routes via the getRoutes() introspection seam that already existed, and the two RouteManager-bypassing registrars (package-routes.ts, external-datasource-routes.ts) via captured mock-server registrations — so no pinned-by-hand list anywhere. The client-method direction lives in packages/client next to the SDK (same package-boundary split as tranche 1; a rest->client edge is unbuildable). All five guard directions were negative-tested and fail with the offending route named. Mismatches the audit surfaced, ledgered not fixed: POST /api/v1/packages is a publish-vs-install shape collision between REST and the dispatcher (REST registers first and wins); REST's GET /ui/view/:object/:type path dialect is unreachable by the SDK's query-param dialect. The service-storage / service-i18n autonomous mounts are a third surface, explicitly scoped out and noted in the ledger header. No behavior change: data + tests only, plus a scope-note refresh in the runtime ledger. Refs #3587 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5
1 parent 0024abf commit 5bdd72e

5 files changed

Lines changed: 479 additions & 1 deletion

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/rest": patch
3+
"@objectstack/client": patch
4+
"@objectstack/runtime": patch
5+
---
6+
7+
feat(rest): route audit tranche 2 — the REST surface gets its own ledger +
8+
conformance guard (#3587, follow-up to #3563)
9+
10+
The dispatcher tranche closed its 27 gaps and guards them (#3569#3579), but
11+
`@objectstack/rest` mounts a second, larger surface the client also reaches —
12+
89 routes, never audited. `rest-route-ledger.ts` now records a reviewed
13+
disposition for every one of them (38 sdk, 43 gap, 3 server-only, 3 public,
14+
2 mismatch), and the guard is real enumeration on both sources: RouteManager
15+
routes via the `getRoutes()` introspection seam, and the two
16+
RouteManager-bypassing registrars (`package-routes.ts`,
17+
`external-datasource-routes.ts`) via captured mock-server registrations — no
18+
pinned-by-hand list. The client half
19+
(`rest-route-ledger-coverage.test.ts`) verifies every claimed method exists;
20+
a 43-gap ratchet is wired into CI. Every guard direction was negative-tested.
21+
22+
Notable dispositions the audit surfaced: `POST /api/v1/packages` is a
23+
publish/install shape collision between REST and the dispatcher (REST
24+
registers first and wins) — ledgered `mismatch`; the REST
25+
`GET /ui/view/:object/:type` path dialect is unreachable by the SDK's
26+
query-param dialect — ledgered `mismatch`; `service-storage` /
27+
`service-i18n` mount a third route surface outside `@objectstack/rest`,
28+
explicitly out of scope here and tracked under #3587.
29+
30+
No behavior change — data + tests only, plus a scope-note refresh in the
31+
runtime ledger pointing at the new REST ledger.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* REST route-ledger ↔ client-surface conformance (#3587) — the client half of
5+
* the guard whose server half lives in
6+
* `packages/rest/src/rest-route-ledger.conformance.test.ts`. Same contract as
7+
* the dispatcher-ledger guard next door (`route-ledger-coverage.test.ts`):
8+
* every REST ledger entry that names a client method must resolve to a real
9+
* function on an instantiated client.
10+
*
11+
* The ledger is imported as a relative SOURCE file deliberately: it is pure
12+
* data (no imports), and a client→rest package edge for it would be backwards
13+
* — the REST server is where the routes are declared, so the ledger lives
14+
* there, and each package verifies its own half.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { ObjectStackClient } from './index';
19+
import { REST_ROUTE_LEDGER } from '../../rest/src/rest-route-ledger';
20+
21+
describe('REST route ledger ↔ @objectstack/client surface', () => {
22+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:9' });
23+
24+
const resolve = (path: string): unknown =>
25+
path.split('.').reduce<unknown>((o, k) => (o == null ? o : (o as Record<string, unknown>)[k]), client);
26+
27+
it('every REST ledger entry naming a client method resolves to a real function', () => {
28+
const broken = REST_ROUTE_LEDGER.filter((e) => e.client != null)
29+
.filter((e) => typeof resolve(e.client!) !== 'function')
30+
.map((e) => `${e.route} → client.${e.client}`);
31+
expect(
32+
broken,
33+
`REST ledger entries claiming a client method that does not exist: ${broken.join('; ')}`,
34+
).toEqual([]);
35+
});
36+
});
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* REST route-ledger conformance (#3587) — the guard that keeps the REST
5+
* server's route surface and `@objectstack/client` from drifting apart
6+
* silently, mirroring the dispatcher guard (#3569).
7+
*
8+
* Directions made loud here:
9+
*
10+
* 1. A route mounted by `@objectstack/rest` with no ledger entry — a new
11+
* route landed without a reviewed SDK disposition.
12+
* 2. A ledger entry for a route the server no longer mounts — the ledger
13+
* went stale.
14+
*
15+
* Enumeration is real on BOTH sources: `route-manager` rows against
16+
* `RestServer.getRoutes()` (the introspection seam RouteManager already
17+
* provides), and `direct-mount` rows against the registration calls the two
18+
* bypass registrars make on a mock `IHttpServer` — no pinned-by-hand list.
19+
*
20+
* The third direction — "every `sdk` row names a client method that exists" —
21+
* lives in `packages/client/src/rest-route-ledger-coverage.test.ts`, next to
22+
* the SDK it introspects, for the same build-cycle reason as tranche 1: a
23+
* rest→client edge is unbuildable (client's devDeps already reach back into
24+
* the server packages, and CI's per-package test tasks build only their own
25+
* dependency closure).
26+
*/
27+
28+
import { describe, it, expect, vi } from 'vitest';
29+
import { RestServer } from './rest-server';
30+
import { registerPackageRoutes } from './package-routes';
31+
import { registerExternalDatasourceRoutes } from './external-datasource-routes';
32+
import { REST_ROUTE_LEDGER } from './rest-route-ledger';
33+
34+
/** Minimal IHttpServer mock that records registrations. */
35+
function createMockServer() {
36+
return {
37+
get: vi.fn(),
38+
post: vi.fn(),
39+
put: vi.fn(),
40+
delete: vi.fn(),
41+
patch: vi.fn(),
42+
use: vi.fn(),
43+
listen: vi.fn().mockResolvedValue(undefined),
44+
close: vi.fn().mockResolvedValue(undefined),
45+
};
46+
}
47+
48+
/**
49+
* Protocol mock with the batch capabilities present so the four
50+
* protocol-capability-gated batch routes register (rest-server.ts).
51+
*/
52+
function createCapableProtocol() {
53+
return {
54+
getDiscovery: vi.fn().mockResolvedValue({}),
55+
getMetaTypes: vi.fn().mockResolvedValue([]),
56+
getMetaItems: vi.fn().mockResolvedValue([]),
57+
getMetaItem: vi.fn().mockResolvedValue({}),
58+
findData: vi.fn().mockResolvedValue([]),
59+
getData: vi.fn().mockResolvedValue({}),
60+
createData: vi.fn().mockResolvedValue({ id: '1' }),
61+
updateData: vi.fn().mockResolvedValue({}),
62+
deleteData: vi.fn().mockResolvedValue({ success: true }),
63+
batchData: vi.fn().mockResolvedValue({}),
64+
createManyData: vi.fn().mockResolvedValue([]),
65+
updateManyData: vi.fn().mockResolvedValue([]),
66+
deleteManyData: vi.fn().mockResolvedValue([]),
67+
};
68+
}
69+
70+
/** `VERB /path` keys for every route RouteManager holds at default config. */
71+
function enumerateRouteManagerRoutes(): Set<string> {
72+
const rest = new RestServer(createMockServer() as any, createCapableProtocol() as any, {} as any);
73+
rest.registerRoutes();
74+
return new Set(rest.getRoutes().map((r) => `${r.method.toUpperCase()} ${r.path}`));
75+
}
76+
77+
/** `VERB /path` keys captured from the two RouteManager-bypassing registrars. */
78+
function enumerateDirectMountRoutes(): Set<string> {
79+
const server = createMockServer();
80+
registerPackageRoutes(server as any, {} as any);
81+
registerExternalDatasourceRoutes(server as any, { getService: () => undefined } as any);
82+
const keys = new Set<string>();
83+
for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) {
84+
for (const call of (server[verb] as any).mock.calls) {
85+
keys.add(`${verb.toUpperCase()} ${call[0]}`);
86+
}
87+
}
88+
return keys;
89+
}
90+
91+
function ledgerKeys(source: 'route-manager' | 'direct-mount'): Set<string> {
92+
return new Set(REST_ROUTE_LEDGER.filter((e) => e.source === source).map((e) => e.route));
93+
}
94+
95+
describe('REST route ledger ↔ RouteManager enumeration', () => {
96+
it('every RouteManager-registered route has a ledger entry', () => {
97+
const ledger = ledgerKeys('route-manager');
98+
const missing = [...enumerateRouteManagerRoutes()].filter((k) => !ledger.has(k));
99+
expect(
100+
missing,
101+
`REST routes with no rest-route-ledger entry: ${missing.join(', ')}. ` +
102+
'A new route needs a reviewed disposition in rest-route-ledger.ts (#3587).',
103+
).toEqual([]);
104+
});
105+
106+
it('every route-manager ledger entry is a live RouteManager route', () => {
107+
const live = enumerateRouteManagerRoutes();
108+
const stale = [...ledgerKeys('route-manager')].filter((k) => !live.has(k));
109+
expect(
110+
stale,
111+
`rest-route-ledger entries the server no longer mounts: ${stale.join(', ')}. ` +
112+
'Remove or reclassify them so the ledger stays truthful.',
113+
).toEqual([]);
114+
});
115+
});
116+
117+
describe('REST route ledger ↔ direct-mount registrars', () => {
118+
it('every directly-mounted route has a ledger entry', () => {
119+
const ledger = ledgerKeys('direct-mount');
120+
const missing = [...enumerateDirectMountRoutes()].filter((k) => !ledger.has(k));
121+
expect(
122+
missing,
123+
`Directly-mounted routes with no rest-route-ledger entry: ${missing.join(', ')}.`,
124+
).toEqual([]);
125+
});
126+
127+
it('every direct-mount ledger entry is really registered by its registrar', () => {
128+
const live = enumerateDirectMountRoutes();
129+
const stale = [...ledgerKeys('direct-mount')].filter((k) => !live.has(k));
130+
expect(
131+
stale,
132+
`direct-mount rest-route-ledger entries no registrar mounts: ${stale.join(', ')}.`,
133+
).toEqual([]);
134+
});
135+
});
136+
137+
describe('REST route ledger hygiene', () => {
138+
it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => {
139+
const sdkWithout = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route);
140+
expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]);
141+
142+
const bareNonSdk = REST_ROUTE_LEDGER.filter((e) => e.disposition !== 'sdk' && !e.note).map((e) => e.route);
143+
expect(bareNonSdk, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]);
144+
});
145+
146+
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
147+
// Ratchet, not aspiration: 43 audited gaps at #3587 PR-1 (metadata 9,
148+
// data-actions 2, search 1, email 1, analytics 1, security-explain 2,
149+
// record-shares 3, sharing-rules 5, reports 8, approvals 6,
150+
// external-datasource 5). Closing a gap = reclassify to `sdk` AND lower
151+
// this bound. Raising it demands an explicit, reviewed decision.
152+
const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
153+
expect(gaps).toBeLessThanOrEqual(43);
154+
});
155+
});

0 commit comments

Comments
 (0)