-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathrest-route-ledger.ts
More file actions
224 lines (201 loc) · 22.2 KB
/
Copy pathrest-route-ledger.ts
File metadata and controls
224 lines (201 loc) · 22.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
// 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 (the 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` and carry their own per-package ledgers + guards since
* #3636 (tranche 3): `packages/services/service-storage/src/storage-route-ledger.ts`
* and `packages/services/service-i18n/src/i18n-route-ledger.ts`. All three
* surfaces the SDK reaches are now ledgered.
*
* 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: 'sdk', client: 'meta.getDiagnostics' },
{ 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: 'sdk', client: 'meta.getReferences' },
{ route: 'GET /api/v1/meta/book/:name/tree', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getBookTree' },
{ 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: 'sdk', client: 'meta.getAudit' },
{ route: 'POST /api/v1/meta/:type/:name/publish', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.publishItem',
note: 'per-item ADR-0033 publish; packages.publishDrafts remains the package-scoped flow' },
{ route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.rollbackItem' },
{ route: 'GET /api/v1/meta/:type/:name/diff', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.diffItem' },
{ route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem',
note: 'compound names pass through getItem unencoded (URL-pinned in client.test.ts); only deleteItem encodes' },
{ route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem',
note: 'compound names pass through saveItem unencoded (URL-pinned in client.test.ts)' },
// ── ui ────────────────────────────────────────────────────────────────────
{ route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'sdk', client: 'meta.getView',
note: 'was mismatch — meta.getView spoke the ?type= query dialect this pattern cannot match; since #3611 the client sends the path form both surfaces accept' },
// ── 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: 'sdk', client: 'data.clone' },
{ 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: 'sdk', client: 'data.export',
note: 'file-stream response; the SDK returns the raw Response rather than a JSON envelope' },
// ── search ────────────────────────────────────────────────────────────────
{ route: 'GET /api/v1/search', family: 'search', source: 'route-manager', disposition: 'sdk', client: 'search' },
// ── email ─────────────────────────────────────────────────────────────────
{ route: 'POST /api/v1/email/send', family: 'email', source: 'route-manager', disposition: 'sdk', client: 'email.send' },
// ── 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: 'sdk', client: 'analytics.queryDataset' },
// ── security explain (ADR-0090 D6) ────────────────────────────────────────
{ route: 'GET /api/v1/security/explain', family: 'security-explain', source: 'route-manager', disposition: 'sdk', client: 'security.explain',
note: 'query transport of the same ExplainRequestSchema contract; the SDK speaks the POST form' },
{ route: 'POST /api/v1/security/explain', family: 'security-explain', source: 'route-manager', disposition: 'sdk', client: 'security.explain' },
// ── delegable scope (ADR-0090 D12 / ADR-0105 D8) ──────────────────────────
{ route: 'GET /api/v1/security/my-delegable-scope', family: 'security-explain', source: 'route-manager', disposition: 'sdk', client: 'security.describeDelegableScope',
note: "read half of the delegated-admin gate; strictly self-scoped (no target-user parameter), so a scoped-invitation picker can narrow to what the caller may actually delegate" },
// ── per-record shares ─────────────────────────────────────────────────────
{ route: 'GET /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.list' },
{ route: 'POST /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.grant' },
{ route: 'DELETE /api/v1/data/:object/:id/shares/:shareId', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.revoke' },
// ── sharing rules ─────────────────────────────────────────────────────────
{ route: 'GET /api/v1/sharing/rules', family: 'sharing-rules', source: 'route-manager', disposition: 'sdk', client: 'shares.rules.list' },
{ route: 'POST /api/v1/sharing/rules', family: 'sharing-rules', source: 'route-manager', disposition: 'sdk', client: 'shares.rules.save' },
{ route: 'GET /api/v1/sharing/rules/:idOrName', family: 'sharing-rules', source: 'route-manager', disposition: 'sdk', client: 'shares.rules.get' },
{ route: 'DELETE /api/v1/sharing/rules/:idOrName', family: 'sharing-rules', source: 'route-manager', disposition: 'sdk', client: 'shares.rules.delete' },
{ route: 'POST /api/v1/sharing/rules/:idOrName/evaluate', family: 'sharing-rules', source: 'route-manager', disposition: 'sdk', client: 'shares.rules.evaluate' },
// ── 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: 'sdk', client: 'reports.list' },
{ route: 'POST /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.save' },
{ route: 'GET /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.get' },
{ route: 'DELETE /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.delete' },
{ route: 'POST /api/v1/reports/:id/run', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.run' },
{ route: 'POST /api/v1/reports/:id/schedule', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.schedule' },
{ route: 'GET /api/v1/reports/:id/schedules', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.listSchedules' },
{ route: 'DELETE /api/v1/reports/schedules/:scheduleId', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.unschedule' },
// ── 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: 'sdk', client: 'approvals.recall' },
{ route: 'POST /api/v1/approvals/requests/:id/revise', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.revise' },
{ route: 'POST /api/v1/approvals/requests/:id/resubmit', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.resubmit' },
{ 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: 'sdk', client: 'approvals.remind' },
{ route: 'POST /api/v1/approvals/requests/:id/request-info', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.requestInfo' },
{ route: 'POST /api/v1/approvals/requests/:id/comment', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.comment' },
{ 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/publish', family: 'packages', source: 'direct-mount', disposition: 'server-only',
note: 'marketplace registry publish ({manifest, metadata}) — publisher tooling, not app-SDK surface. Moved off the bare POST /packages in #3610: that verb+path is the dispatcher install route, and REST registering it first swallowed every packages.install call with a 400.' },
{ 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: 'sdk', client: 'datasources.external.listTables' },
{ route: 'POST /api/v1/datasources/:name/external/tables/:remote/draft', family: 'external-datasource', source: 'direct-mount', disposition: 'sdk', client: 'datasources.external.draft' },
{ route: 'POST /api/v1/datasources/:name/external/tables/:remote/import', family: 'external-datasource', source: 'direct-mount', disposition: 'sdk', client: 'datasources.external.import' },
{ route: 'POST /api/v1/datasources/:name/external/refresh-catalog', family: 'external-datasource', source: 'direct-mount', disposition: 'sdk', client: 'datasources.external.refreshCatalog' },
{ route: 'POST /api/v1/datasources/:name/external/validate', family: 'external-datasource', source: 'direct-mount', disposition: 'sdk', client: 'datasources.external.validate' },
];