-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathrest-route-ledger.ts
More file actions
255 lines (233 loc) · 22.7 KB
/
Copy pathrest-route-ledger.ts
File metadata and controls
255 lines (233 loc) · 22.7 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
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
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: '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: '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/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: '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' },
];