@superbee/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/legacy-router.d.ts +7 -0
- package/dist/legacy-router.d.ts.map +1 -0
- package/dist/legacy-router.js +48 -0
- package/dist/legacy-router.js.map +1 -0
- package/dist/router.d.ts +194 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +798 -0
- package/dist/router.js.map +1 -0
- package/dist/serve.d.ts +55 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +131 -0
- package/dist/serve.js.map +1 -0
- package/package.json +36 -0
package/dist/router.js
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-protocol v0 fetch router — the wire-protocol v0 seam-over-HTTP contract
|
|
3
|
+
* (`docs/WIRE-PROTOCOL.md`) implemented as a plain
|
|
4
|
+
* `(req: Request) => Promise<Response>` function using Web-standard `Request`/
|
|
5
|
+
* `Response`, so the identical router can mount unchanged in another Fetch-compatible runtime.
|
|
6
|
+
*
|
|
7
|
+
* ONE-ENGINE RULE: this module contains NO parsing/link/OKF logic of its own.
|
|
8
|
+
* Doc WRITES route through the engine (`writeDocVersioned` from `@superbee/core`)
|
|
9
|
+
* so server-side OKF enforcement (non-empty `type`, id safety, reserved-file
|
|
10
|
+
* rejection) comes free — the engine keeps ALL semantics, the router only maps
|
|
11
|
+
* HTTP <-> the `StorageBackend` seam. Reads / list / versions / reserved-file
|
|
12
|
+
* access go through the bundle's `StorageBackend` DIRECTLY (protocol principle 2:
|
|
13
|
+
* "the seam is the schema" — every endpoint maps 1:1 to a seam method), not
|
|
14
|
+
* through the engine's `query`/`readDoc` wrappers.
|
|
15
|
+
*
|
|
16
|
+
* See `docs/WIRE-PROTOCOL.md` "Implemented by reference" section for the exact
|
|
17
|
+
* endpoint set and any recorded deviations from the draft.
|
|
18
|
+
*/
|
|
19
|
+
import { InvalidInputError, VersionConflict, assertSafeBlobKey, assertSafeConceptId, assertSafeReservedDir, isReservedFile, pathFromConceptId, stripETagWrapper, } from "@superbee/core/storage";
|
|
20
|
+
import { queryHeads, writeDocVersioned } from "@superbee/core/engine";
|
|
21
|
+
/** Default page size for `GET /docs` when `limit` is not supplied. */
|
|
22
|
+
const DEFAULT_LIST_LIMIT = 50;
|
|
23
|
+
/** True when `err` carries the `ENOENT`-shaped `.code` the seam's adapters use for "absent". */
|
|
24
|
+
function isEnoent(err) {
|
|
25
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
26
|
+
}
|
|
27
|
+
/** A bundle-relative concept id from a `docs/{id...}` path tail: decode each `/`-separated segment. */
|
|
28
|
+
function decodeId(rawPathTail) {
|
|
29
|
+
try {
|
|
30
|
+
return rawPathTail
|
|
31
|
+
.split("/")
|
|
32
|
+
.map((seg) => decodeURIComponent(seg))
|
|
33
|
+
.join("/");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new InvalidInputError(`invalid percent-encoding in document id '${rawPathTail}'`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** A bundle-relative blob key from a `blobs/{key...}` path tail — same per-segment decode as {@link decodeId}. */
|
|
40
|
+
function decodeBlobKey(rawPathTail) {
|
|
41
|
+
try {
|
|
42
|
+
return rawPathTail
|
|
43
|
+
.split("/")
|
|
44
|
+
.map((seg) => decodeURIComponent(seg))
|
|
45
|
+
.join("/");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new InvalidInputError(`invalid percent-encoding in blob key '${rawPathTail}'`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate a doc-route id is safe (no path traversal / absolute escape) and is not a
|
|
53
|
+
* reserved filename, THROWING an `InvalidInputError` (mapped to `400 USAGE` by the router's
|
|
54
|
+
* catch-all) before any backend call. `read`/`exists` (HEAD)/`versions`/`readMany` are
|
|
55
|
+
* called directly against the `StorageBackend` (protocol principle 2 — "the seam is
|
|
56
|
+
* the schema"), bypassing the engine's `assertSafeConceptId` guard that only fires on
|
|
57
|
+
* the engine-routed write path. Without this, `FilesystemBackend.read('../../etc/hosts')`
|
|
58
|
+
* resolves OUTSIDE the bundle root (`path.join` does not sandbox `..`) — a
|
|
59
|
+
* network-reachable path-traversal read. This closes that gap server-side (protocol
|
|
60
|
+
* principle 4: OKF/id-safety invariants are enforced server-side too).
|
|
61
|
+
*/
|
|
62
|
+
function assertValidDocId(id) {
|
|
63
|
+
assertSafeConceptId(id);
|
|
64
|
+
if (isReservedFile(pathFromConceptId(id))) {
|
|
65
|
+
throw new InvalidInputError(`'${id}' is a reserved file, not a concept document`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Build a JSON `Response` with the standard `content-type`, merging in any extra headers. */
|
|
69
|
+
function jsonResponse(status, body, headers = {}) {
|
|
70
|
+
return new Response(JSON.stringify(body), {
|
|
71
|
+
status,
|
|
72
|
+
headers: { "content-type": "application/json; charset=utf-8", ...headers },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Build the `{ error: { code, message, details? } }` envelope (`docs/WIRE-PROTOCOL.md` Conventions). */
|
|
76
|
+
function errorResponse(status, code, message, details) {
|
|
77
|
+
return jsonResponse(status, {
|
|
78
|
+
error: details === undefined ? { code, message } : { code, message, details },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Map a thrown error to its wire status + envelope. Only the engine's typed
|
|
83
|
+
* `InvalidInputError` is a `400 USAGE`; unknown failures are server/runtime defects and
|
|
84
|
+
* remain retryable `500 RUNTIME` responses. `VersionConflict` and ENOENT retain their
|
|
85
|
+
* narrower protocol meanings.
|
|
86
|
+
*/
|
|
87
|
+
function errorFromCaught(err) {
|
|
88
|
+
if (err instanceof VersionConflict) {
|
|
89
|
+
return errorResponse(412, "VERSION_CONFLICT", err.message, { expected: err.expected, actual: err.actual });
|
|
90
|
+
}
|
|
91
|
+
if (isEnoent(err)) {
|
|
92
|
+
return errorResponse(404, "NOT_FOUND", "document or blob not found");
|
|
93
|
+
}
|
|
94
|
+
if (err instanceof InvalidInputError) {
|
|
95
|
+
return errorResponse(400, "USAGE", err.message);
|
|
96
|
+
}
|
|
97
|
+
return errorResponse(500, "RUNTIME", "internal server error");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read `If-Match` / `If-None-Match: *` off a request into the seam's `WriteOptions.expectedVersion`.
|
|
101
|
+
*
|
|
102
|
+
* `If-Match` is passed through `stripETagWrapper`: the router accepts the bare, unwrapped token,
|
|
103
|
+
* but a client or intermediary MAY reflect back a quoted (`"sha256:..."`) or weak (`W/"sha256:..."`)
|
|
104
|
+
* form now that the router's OWN responses are correctly quoted (see `versionHeaders` below), so
|
|
105
|
+
* parsing tolerates both wrapped and bare forms rather than requiring the bare one.
|
|
106
|
+
*/
|
|
107
|
+
function writeOptionsFromRequest(req, attribution) {
|
|
108
|
+
const options = {};
|
|
109
|
+
if (req.headers.get("If-None-Match") === "*") {
|
|
110
|
+
options.expectedVersion = null;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const ifMatch = req.headers.get("If-Match");
|
|
114
|
+
if (ifMatch !== null)
|
|
115
|
+
options.expectedVersion = stripETagWrapper(ifMatch);
|
|
116
|
+
}
|
|
117
|
+
options.actor = attribution.actor;
|
|
118
|
+
if (attribution.agent !== undefined)
|
|
119
|
+
options.agent = attribution.agent;
|
|
120
|
+
return options;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Read `If-Match` off a request into the seam's `DeleteOptions.expectedVersion` — the delete
|
|
124
|
+
* counterpart to {@link writeOptionsFromRequest}, deliberately narrower: no `If-None-Match: *`
|
|
125
|
+
* branch (expect-absent is meaningless for a delete — there is no "create" reading of removing
|
|
126
|
+
* something that isn't there) and no `X-Actor` (a delete records no new revision to attribute).
|
|
127
|
+
* Quote/weak-prefix-tolerant via {@link stripETagWrapper}, same as `If-Match` on a write.
|
|
128
|
+
*/
|
|
129
|
+
function deleteOptionsFromHeaders(req) {
|
|
130
|
+
const ifMatch = req.headers.get("If-Match");
|
|
131
|
+
return ifMatch !== null ? { expectedVersion: stripETagWrapper(ifMatch) } : {};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Both version-transport headers for a response that carries `version`: `X-Version` (the bare
|
|
135
|
+
* token — the PRIMARY vehicle, a custom header no intermediary has any reason to rewrite) and a
|
|
136
|
+
* properly RFC-7232-QUOTED `ETag` (secondary, for HTTP-ecosystem tooling that expects one).
|
|
137
|
+
*
|
|
138
|
+
* A bare-token `ETag` (`ETag: sha256:<hex>`) is RFC-7232-invalid and may be stripped by an
|
|
139
|
+
* intermediary. Losing this version can silently downgrade the next guarded write to an
|
|
140
|
+
* unconditional one. `X-Version` is therefore the primary transport; the correctly quoted ETag
|
|
141
|
+
* remains for HTTP compatibility.
|
|
142
|
+
*/
|
|
143
|
+
function versionHeaders(version) {
|
|
144
|
+
return { "X-Version": version, ETag: `"${version}"` };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The complete public route/method registry for the reference router. Runtime dispatch consumes
|
|
148
|
+
* this table, and the repository contract test compares the same rows with WIRE-PROTOCOL.md, so a
|
|
149
|
+
* route cannot become reachable without entering the documented agreement surface.
|
|
150
|
+
*/
|
|
151
|
+
export const WIRE_ENDPOINTS = [
|
|
152
|
+
{
|
|
153
|
+
id: "capabilities",
|
|
154
|
+
resource: "capabilities",
|
|
155
|
+
method: "GET",
|
|
156
|
+
path: "/v0/capabilities",
|
|
157
|
+
accessClass: "public",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "docs-list",
|
|
161
|
+
resource: "docs",
|
|
162
|
+
method: "GET",
|
|
163
|
+
path: "/v0/bundles/{bundle}/docs",
|
|
164
|
+
accessClass: "read",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "docs-read-many",
|
|
168
|
+
resource: "docs-read-many",
|
|
169
|
+
method: "POST",
|
|
170
|
+
path: "/v0/bundles/{bundle}/docs:read-many",
|
|
171
|
+
accessClass: "read",
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: "doc-read",
|
|
175
|
+
resource: "doc",
|
|
176
|
+
method: "GET",
|
|
177
|
+
path: "/v0/bundles/{bundle}/docs/{id...}",
|
|
178
|
+
accessClass: "read",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "doc-write",
|
|
182
|
+
resource: "doc",
|
|
183
|
+
method: "PUT",
|
|
184
|
+
path: "/v0/bundles/{bundle}/docs/{id...}",
|
|
185
|
+
accessClass: "write",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "doc-head",
|
|
189
|
+
resource: "doc",
|
|
190
|
+
method: "HEAD",
|
|
191
|
+
path: "/v0/bundles/{bundle}/docs/{id...}",
|
|
192
|
+
accessClass: "read",
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: "doc-delete",
|
|
196
|
+
resource: "doc",
|
|
197
|
+
method: "DELETE",
|
|
198
|
+
path: "/v0/bundles/{bundle}/docs/{id...}",
|
|
199
|
+
accessClass: "write",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "doc-versions",
|
|
203
|
+
resource: "doc-versions",
|
|
204
|
+
method: "GET",
|
|
205
|
+
path: "/v0/bundles/{bundle}/docs/{id...}/versions",
|
|
206
|
+
accessClass: "read",
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: "reserved-read",
|
|
210
|
+
resource: "reserved",
|
|
211
|
+
method: "GET",
|
|
212
|
+
path: "/v0/bundles/{bundle}/reserved/{name}",
|
|
213
|
+
accessClass: "read",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "reserved-write",
|
|
217
|
+
resource: "reserved",
|
|
218
|
+
method: "PUT",
|
|
219
|
+
path: "/v0/bundles/{bundle}/reserved/{name}",
|
|
220
|
+
accessClass: "write",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: "blobs-list",
|
|
224
|
+
resource: "blobs",
|
|
225
|
+
method: "GET",
|
|
226
|
+
path: "/v0/bundles/{bundle}/blobs",
|
|
227
|
+
accessClass: "read",
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: "blob-read",
|
|
231
|
+
resource: "blob",
|
|
232
|
+
method: "GET",
|
|
233
|
+
path: "/v0/bundles/{bundle}/blobs/{key...}",
|
|
234
|
+
accessClass: "read",
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: "blob-write",
|
|
238
|
+
resource: "blob",
|
|
239
|
+
method: "PUT",
|
|
240
|
+
path: "/v0/bundles/{bundle}/blobs/{key...}",
|
|
241
|
+
accessClass: "write",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: "blob-head",
|
|
245
|
+
resource: "blob",
|
|
246
|
+
method: "HEAD",
|
|
247
|
+
path: "/v0/bundles/{bundle}/blobs/{key...}",
|
|
248
|
+
accessClass: "read",
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "blob-delete",
|
|
252
|
+
resource: "blob",
|
|
253
|
+
method: "DELETE",
|
|
254
|
+
path: "/v0/bundles/{bundle}/blobs/{key...}",
|
|
255
|
+
accessClass: "write",
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
/** Typed resolution failure retained as a stable host-side rejection seam. */
|
|
259
|
+
export class WireRequestResolutionError extends Error {
|
|
260
|
+
status;
|
|
261
|
+
code;
|
|
262
|
+
constructor(status, code, message) {
|
|
263
|
+
super(message);
|
|
264
|
+
this.name = "WireRequestResolutionError";
|
|
265
|
+
this.status = status;
|
|
266
|
+
this.code = code;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const BUNDLE_ID_RE = /^bnd_[0-9a-f]{32}$/;
|
|
270
|
+
export function isCanonicalBundleId(value) {
|
|
271
|
+
return BUNDLE_ID_RE.test(value);
|
|
272
|
+
}
|
|
273
|
+
function literalSpecificity(path) {
|
|
274
|
+
return path.split("/").filter((segment) => segment !== "" && !segment.startsWith("{")).length;
|
|
275
|
+
}
|
|
276
|
+
/** Match a registry template without decoding route parameters. */
|
|
277
|
+
function matchTemplate(template, pathname) {
|
|
278
|
+
const templateSegments = template.split("/");
|
|
279
|
+
const pathSegments = pathname.split("/");
|
|
280
|
+
const params = {};
|
|
281
|
+
let pathIndex = 0;
|
|
282
|
+
for (let templateIndex = 0; templateIndex < templateSegments.length; templateIndex++, pathIndex++) {
|
|
283
|
+
const expected = templateSegments[templateIndex];
|
|
284
|
+
const actual = pathSegments[pathIndex];
|
|
285
|
+
if (expected.startsWith("{") && expected.endsWith("...}")) {
|
|
286
|
+
const suffixLength = templateSegments.length - templateIndex - 1;
|
|
287
|
+
const captureEnd = pathSegments.length - suffixLength;
|
|
288
|
+
if (actual === undefined || actual === "" || captureEnd <= pathIndex)
|
|
289
|
+
return undefined;
|
|
290
|
+
params[expected.slice(1, -4)] = pathSegments.slice(pathIndex, captureEnd).join("/");
|
|
291
|
+
pathIndex = captureEnd - 1;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (actual === undefined)
|
|
295
|
+
return undefined;
|
|
296
|
+
if (expected.startsWith("{") && expected.endsWith("}")) {
|
|
297
|
+
if (actual === "")
|
|
298
|
+
return undefined;
|
|
299
|
+
params[expected.slice(1, -1)] = actual;
|
|
300
|
+
}
|
|
301
|
+
else if (expected !== actual) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return pathIndex === pathSegments.length ? params : undefined;
|
|
306
|
+
}
|
|
307
|
+
function resourceFromMatch(resource, params, searchParams) {
|
|
308
|
+
switch (resource) {
|
|
309
|
+
case "capabilities":
|
|
310
|
+
return { kind: "capabilities" };
|
|
311
|
+
case "docs":
|
|
312
|
+
return { kind: "docs" };
|
|
313
|
+
case "docs-read-many":
|
|
314
|
+
return { kind: "docs-read-many" };
|
|
315
|
+
case "doc": {
|
|
316
|
+
const id = decodeId(params.id);
|
|
317
|
+
assertValidDocId(id);
|
|
318
|
+
return { kind: "doc", id };
|
|
319
|
+
}
|
|
320
|
+
case "doc-versions": {
|
|
321
|
+
const id = decodeId(params.id);
|
|
322
|
+
assertValidDocId(id);
|
|
323
|
+
return { kind: "doc-versions", id };
|
|
324
|
+
}
|
|
325
|
+
case "reserved": {
|
|
326
|
+
const name = params.name;
|
|
327
|
+
if (name !== "index.md" && name !== "log.md") {
|
|
328
|
+
throw new InvalidInputError(`reserved file name must be index.md or log.md, got '${name}'`);
|
|
329
|
+
}
|
|
330
|
+
const dir = searchParams.get("dir") ?? "";
|
|
331
|
+
assertSafeReservedDir(dir);
|
|
332
|
+
return { kind: "reserved", dir, name };
|
|
333
|
+
}
|
|
334
|
+
case "blobs":
|
|
335
|
+
return { kind: "blobs" };
|
|
336
|
+
case "blob": {
|
|
337
|
+
const key = decodeBlobKey(params.key);
|
|
338
|
+
assertSafeBlobKey(key);
|
|
339
|
+
return { kind: "blob", key };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function invalidBundleFromPath(pathname) {
|
|
344
|
+
const prefix = "/v0/bundles/";
|
|
345
|
+
if (!pathname.startsWith(prefix))
|
|
346
|
+
return undefined;
|
|
347
|
+
const rawBundle = pathname.slice(prefix.length).split("/", 1)[0] ?? "";
|
|
348
|
+
if (!isCanonicalBundleId(rawBundle)) {
|
|
349
|
+
return new WireRequestResolutionError(400, "USAGE", `invalid bundle id '${rawBundle}'`);
|
|
350
|
+
}
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
function resolvePathAndMethod(pathname, searchParams, method) {
|
|
354
|
+
const bundleError = invalidBundleFromPath(pathname);
|
|
355
|
+
if (bundleError)
|
|
356
|
+
throw bundleError;
|
|
357
|
+
const shapeMatches = WIRE_ENDPOINTS.flatMap((endpoint) => {
|
|
358
|
+
const params = matchTemplate(endpoint.path, pathname);
|
|
359
|
+
return params ? [{ endpoint, params }] : [];
|
|
360
|
+
});
|
|
361
|
+
if (shapeMatches.length === 0) {
|
|
362
|
+
throw new WireRequestResolutionError(404, "NOT_FOUND", `no route for ${pathname}`);
|
|
363
|
+
}
|
|
364
|
+
const methodMatches = shapeMatches
|
|
365
|
+
.filter(({ endpoint }) => endpoint.method === method)
|
|
366
|
+
.sort((a, b) => literalSpecificity(b.endpoint.path) - literalSpecificity(a.endpoint.path));
|
|
367
|
+
const selected = methodMatches[0];
|
|
368
|
+
if (!selected) {
|
|
369
|
+
throw new WireRequestResolutionError(400, "USAGE", `unsupported method ${method} for ${routeLabel(shapeMatches[0].endpoint.resource)}`);
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const resource = resourceFromMatch(selected.endpoint.resource, selected.params, searchParams);
|
|
373
|
+
if (selected.endpoint.id === "capabilities") {
|
|
374
|
+
return {
|
|
375
|
+
scope: "deployment",
|
|
376
|
+
endpointId: "capabilities",
|
|
377
|
+
accessClass: "public",
|
|
378
|
+
resource: { kind: "capabilities" },
|
|
379
|
+
searchParams,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const bundleId = selected.params.bundle;
|
|
383
|
+
if (!isCanonicalBundleId(bundleId)) {
|
|
384
|
+
throw new WireRequestResolutionError(400, "USAGE", `invalid bundle id '${bundleId}'`);
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
scope: "bundle",
|
|
388
|
+
bundleId,
|
|
389
|
+
endpointId: selected.endpoint.id,
|
|
390
|
+
accessClass: selected.endpoint.accessClass,
|
|
391
|
+
resource,
|
|
392
|
+
searchParams,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (error instanceof WireRequestResolutionError)
|
|
397
|
+
throw error;
|
|
398
|
+
if (error instanceof InvalidInputError) {
|
|
399
|
+
throw new WireRequestResolutionError(400, "USAGE", error.message);
|
|
400
|
+
}
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** Resolve route identity, canonical bundle selection, decoded resource, and access once. */
|
|
405
|
+
export function resolveWireRequest(request) {
|
|
406
|
+
const { url: requestUrl, method } = request;
|
|
407
|
+
let url;
|
|
408
|
+
try {
|
|
409
|
+
url = new URL(requestUrl);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
throw new WireRequestResolutionError(400, "USAGE", "invalid request URL");
|
|
413
|
+
}
|
|
414
|
+
const { pathname, searchParams } = url;
|
|
415
|
+
return resolvePathAndMethod(pathname, searchParams, method);
|
|
416
|
+
}
|
|
417
|
+
function routeLabel(resource) {
|
|
418
|
+
let label;
|
|
419
|
+
switch (resource) {
|
|
420
|
+
case "docs":
|
|
421
|
+
label = "/docs";
|
|
422
|
+
break;
|
|
423
|
+
case "docs-read-many":
|
|
424
|
+
label = "/docs:read-many";
|
|
425
|
+
break;
|
|
426
|
+
case "doc":
|
|
427
|
+
case "doc-versions":
|
|
428
|
+
label = "a doc route";
|
|
429
|
+
break;
|
|
430
|
+
case "reserved":
|
|
431
|
+
label = "a reserved-file route";
|
|
432
|
+
break;
|
|
433
|
+
case "blobs":
|
|
434
|
+
label = "/blobs";
|
|
435
|
+
break;
|
|
436
|
+
case "blob":
|
|
437
|
+
label = "a blob route";
|
|
438
|
+
break;
|
|
439
|
+
case "capabilities":
|
|
440
|
+
label = "/v0/capabilities";
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
return label;
|
|
444
|
+
}
|
|
445
|
+
/** Fetch requires every HEAD response to be bodyless, including host- and runtime-generated errors. */
|
|
446
|
+
function responseForMethod(isHead, response) {
|
|
447
|
+
if (!isHead)
|
|
448
|
+
return response;
|
|
449
|
+
return new Response(null, {
|
|
450
|
+
status: response.status,
|
|
451
|
+
statusText: response.statusText,
|
|
452
|
+
headers: response.headers,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* The sole raw URL/method boundary. Inner dispatch receives only a registered endpoint, its
|
|
457
|
+
* decoded-shape input, and query parameters; ordinary handler additions cannot make an undeclared
|
|
458
|
+
* method/path pair reachable by branching around the registry.
|
|
459
|
+
*/
|
|
460
|
+
function registeredWireRouter(dispatch) {
|
|
461
|
+
return async function handle(req) {
|
|
462
|
+
const isHead = req.method === "HEAD";
|
|
463
|
+
let response;
|
|
464
|
+
let url;
|
|
465
|
+
try {
|
|
466
|
+
try {
|
|
467
|
+
url = new URL(req.url);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
throw new WireRequestResolutionError(400, "USAGE", "invalid request URL");
|
|
471
|
+
}
|
|
472
|
+
const resolved = resolvePathAndMethod(url.pathname, url.searchParams, req.method);
|
|
473
|
+
response = await dispatch(req, { resolved });
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
if (err instanceof WireRequestResolutionError) {
|
|
477
|
+
const message = err.status === 404 ? `no route for ${url.pathname}` : err.message;
|
|
478
|
+
response = errorResponse(err.status, err.code, message);
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
response = errorFromCaught(err);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return responseForMethod(isHead, response);
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Build the fetch-style router for Worker runtimes. Bundle routes resolve context exactly once
|
|
489
|
+
* after canonical route resolution; deployment capabilities bypass context and storage entirely.
|
|
490
|
+
*/
|
|
491
|
+
export function createRouter(options) {
|
|
492
|
+
return buildRouter(options);
|
|
493
|
+
}
|
|
494
|
+
function buildRouter(options) {
|
|
495
|
+
async function handleReadDoc(backend, id) {
|
|
496
|
+
assertValidDocId(id);
|
|
497
|
+
try {
|
|
498
|
+
const { doc, version } = await backend.read(id);
|
|
499
|
+
return jsonResponse(200, { id: doc.id, frontmatter: doc.frontmatter, body: doc.body }, versionHeaders(version));
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
if (isEnoent(err))
|
|
503
|
+
return errorResponse(404, "NOT_FOUND", `no concept document '${id}'`);
|
|
504
|
+
throw err;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async function handleHeadDoc(backend, id) {
|
|
508
|
+
// HEAD responses carry no body regardless of status (including a validation
|
|
509
|
+
// rejection) — caught locally rather than falling through to the JSON-enveloped
|
|
510
|
+
// catch-all.
|
|
511
|
+
try {
|
|
512
|
+
assertValidDocId(id);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
return new Response(null, { status: 400 });
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
const { version } = await backend.read(id);
|
|
519
|
+
return new Response(null, { status: 200, headers: versionHeaders(version) });
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
if (isEnoent(err))
|
|
523
|
+
return new Response(null, { status: 404 });
|
|
524
|
+
throw err;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
async function handleWriteDoc(backend, attribution, id, req) {
|
|
528
|
+
let payload;
|
|
529
|
+
try {
|
|
530
|
+
payload = (await req.json());
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return errorResponse(400, "USAGE", "request body must be JSON { frontmatter, body }");
|
|
534
|
+
}
|
|
535
|
+
if (payload === null || typeof payload !== "object" || payload.frontmatter === undefined) {
|
|
536
|
+
return errorResponse(400, "USAGE", "request body must include a frontmatter object");
|
|
537
|
+
}
|
|
538
|
+
if (payload.body !== undefined && typeof payload.body !== "string") {
|
|
539
|
+
return errorResponse(400, "USAGE", "request body field body must be a string when present");
|
|
540
|
+
}
|
|
541
|
+
const writeOptions = writeOptionsFromRequest(req, attribution);
|
|
542
|
+
const result = await writeDocVersioned(backend, { id, frontmatter: payload.frontmatter, body: payload.body ?? "" }, writeOptions);
|
|
543
|
+
const status = writeOptions.expectedVersion === null ? 201 : 200;
|
|
544
|
+
return jsonResponse(status, { version: result.version }, versionHeaders(result.version));
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* `DELETE /docs/{id}`. Same validate-id-before-backend posture as `handleReadDoc`
|
|
548
|
+
* (`assertValidDocId` — traversal and reserved-filename ids reject `400 USAGE`, backend
|
|
549
|
+
* never called). `backend.delete` already returns `false` for an absent target rather
|
|
550
|
+
* than throwing, so there is no `try/catch` shaping needed here beyond the shared
|
|
551
|
+
* catch-all (`VersionConflict` -> `412` via `errorFromCaught`, same as a write). `200
|
|
552
|
+
* { deleted }` unconditionally — never a `404`, matching the wire's absence-is-success
|
|
553
|
+
* contract (AXI P6).
|
|
554
|
+
*/
|
|
555
|
+
async function handleDeleteDoc(backend, id, req) {
|
|
556
|
+
assertValidDocId(id);
|
|
557
|
+
const options = deleteOptionsFromHeaders(req);
|
|
558
|
+
const deleted = await backend.delete(id, options);
|
|
559
|
+
return jsonResponse(200, { deleted });
|
|
560
|
+
}
|
|
561
|
+
async function handleVersions(backend, id) {
|
|
562
|
+
assertValidDocId(id);
|
|
563
|
+
const history = await backend.versions(id);
|
|
564
|
+
return jsonResponse(200, { versions: history });
|
|
565
|
+
}
|
|
566
|
+
async function handleReadMany(backend, req) {
|
|
567
|
+
let payload;
|
|
568
|
+
try {
|
|
569
|
+
payload = (await req.json());
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return errorResponse(400, "USAGE", "request body must be JSON { ids: string[] }");
|
|
573
|
+
}
|
|
574
|
+
if (!payload || !Array.isArray(payload.ids) || !payload.ids.every((x) => typeof x === "string")) {
|
|
575
|
+
return errorResponse(400, "USAGE", "request body must include an ids: string[] array");
|
|
576
|
+
}
|
|
577
|
+
const ids = payload.ids;
|
|
578
|
+
if (ids.length === 0)
|
|
579
|
+
return jsonResponse(200, { results: [] });
|
|
580
|
+
// Validate EVERY id before touching the backend at all — a single bad id (e.g. a
|
|
581
|
+
// traversal payload) rejects the whole batch rather than silently reading the rest.
|
|
582
|
+
for (const id of ids) {
|
|
583
|
+
try {
|
|
584
|
+
assertValidDocId(id);
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return errorResponse(400, "USAGE", `invalid document id '${id}'`, { id });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// Determine the missing set up front (§ readMany: "404 + { missing }" if ANY id is
|
|
591
|
+
// absent) rather than relying on the batch read to fail generically mid-way.
|
|
592
|
+
const existsFlags = await Promise.all(ids.map((id) => backend.exists(id)));
|
|
593
|
+
const missing = ids.filter((_, i) => !existsFlags[i]);
|
|
594
|
+
if (missing.length > 0) {
|
|
595
|
+
return errorResponse(404, "NOT_FOUND", `${missing.length} id(s) not found`, { missing });
|
|
596
|
+
}
|
|
597
|
+
const results = await backend.readMany(ids);
|
|
598
|
+
return jsonResponse(200, {
|
|
599
|
+
results: results.map((r) => ({ id: r.doc.id, frontmatter: r.doc.frontmatter, body: r.doc.body, version: r.version })),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
async function handleList(backend, searchParams) {
|
|
603
|
+
const prefix = searchParams.get("prefix") ?? undefined;
|
|
604
|
+
const type = searchParams.get("type") ?? undefined;
|
|
605
|
+
const tags = searchParams.getAll("tag");
|
|
606
|
+
const fields = searchParams.get("fields");
|
|
607
|
+
const limitParam = searchParams.get("limit");
|
|
608
|
+
const parsedLimit = limitParam ? parseInt(limitParam, 10) : NaN;
|
|
609
|
+
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : DEFAULT_LIST_LIMIT;
|
|
610
|
+
const cursor = searchParams.get("cursor") ?? undefined;
|
|
611
|
+
// HEAD-FIRST scan (this route only ever projects frontmatter — bodies never leave it),
|
|
612
|
+
// via core's ONE `queryHeads` implementation: it prefers the backend's optional
|
|
613
|
+
// push-down (an indexed adapter can answer without reading bodies),
|
|
614
|
+
// re-applies the canonical `matchesFilter` to whatever came back, and falls back to
|
|
615
|
+
// the delete-tolerant `list` + batch-read walk for every other backend (a doc deleted
|
|
616
|
+
// mid-scan is SKIPPED, not a scan-failing 404 — the server half of STATUS item 33; a
|
|
617
|
+
// MALFORMED doc still fails loudly, since quarantining it over the wire needs a
|
|
618
|
+
// `skipped` response shape — recorded as an open question). The router deliberately
|
|
619
|
+
// does NOT re-implement the prefer-else-fallback dance itself.
|
|
620
|
+
const heads = await queryHeads(backend, { prefix, type, tags });
|
|
621
|
+
const count = heads.length;
|
|
622
|
+
let page = heads;
|
|
623
|
+
if (cursor) {
|
|
624
|
+
const idx = page.findIndex((r) => r.id === cursor);
|
|
625
|
+
// Vanished-cursor fallback: MUST use the SAME comparator the sort above used
|
|
626
|
+
// (`localeCompare`) — a code-unit `>` here diverges from locale order for ids like
|
|
627
|
+
// `B` vs `a`, re-emitting or skipping rows when the cursor doc was deleted or
|
|
628
|
+
// edited out of the filter between pages.
|
|
629
|
+
page = idx >= 0 ? page.slice(idx + 1) : page.filter((r) => r.id.localeCompare(cursor) > 0);
|
|
630
|
+
}
|
|
631
|
+
const limited = page.slice(0, limit);
|
|
632
|
+
const nextCursor = page.length > limit ? (limited[limited.length - 1]?.id ?? null) : null;
|
|
633
|
+
const docs = limited.map(({ id, frontmatter, version }) => fields === "frontmatter"
|
|
634
|
+
? { id, version, frontmatter }
|
|
635
|
+
: {
|
|
636
|
+
id,
|
|
637
|
+
version,
|
|
638
|
+
type: frontmatter.type,
|
|
639
|
+
title: frontmatter.title,
|
|
640
|
+
timestamp: frontmatter.timestamp,
|
|
641
|
+
});
|
|
642
|
+
return jsonResponse(200, { count, docs, next_cursor: nextCursor });
|
|
643
|
+
}
|
|
644
|
+
async function handleReadReserved(backend, dir, name) {
|
|
645
|
+
assertSafeReservedDir(dir);
|
|
646
|
+
const result = await backend.readReserved(dir, name);
|
|
647
|
+
if (result === null)
|
|
648
|
+
return errorResponse(404, "NOT_FOUND", `no reserved file '${name}' at dir '${dir}'`);
|
|
649
|
+
return jsonResponse(200, { content: result.content }, versionHeaders(result.version));
|
|
650
|
+
}
|
|
651
|
+
async function handleWriteReserved(backend, attribution, dir, name, req) {
|
|
652
|
+
assertSafeReservedDir(dir);
|
|
653
|
+
let payload;
|
|
654
|
+
try {
|
|
655
|
+
payload = (await req.json());
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
return errorResponse(400, "USAGE", "request body must be JSON { content }");
|
|
659
|
+
}
|
|
660
|
+
if (typeof payload.content !== "string") {
|
|
661
|
+
return errorResponse(400, "USAGE", "request body must include a content: string field");
|
|
662
|
+
}
|
|
663
|
+
const writeOptions = writeOptionsFromRequest(req, attribution);
|
|
664
|
+
const version = await backend.writeReserved(dir, name, payload.content, writeOptions);
|
|
665
|
+
const status = writeOptions.expectedVersion === null ? 201 : 200;
|
|
666
|
+
return jsonResponse(status, { version }, versionHeaders(version));
|
|
667
|
+
}
|
|
668
|
+
// ── blobs: opaque bytes served by content-type (wire-protocol v0.1) ─────────
|
|
669
|
+
//
|
|
670
|
+
// Same `StorageBackend`-direct posture as reads/list/reserved-file access
|
|
671
|
+
// (principle 2 — "the seam is the schema"): `readBlob`/`writeBlob`/`existsBlob`/
|
|
672
|
+
// `listBlobs` map 1:1 to their routes. `assertSafeBlobKey` (Part A) is the ONE
|
|
673
|
+
// guard — it already rejects a `.md`-ending key (case-insensitively, covering the
|
|
674
|
+
// two reserved filenames), traversal/absolute escape, and dot-prefixed segments —
|
|
675
|
+
// applied identically on EVERY blob route including GET/HEAD (I1: a probing read
|
|
676
|
+
// must not bypass the guard writes enforce). Bytes cross the wire as the RAW
|
|
677
|
+
// request/response body (`req.arrayBuffer()` / a `Uint8Array` `Response` body),
|
|
678
|
+
// never JSON — B1: no `Buffer` anywhere in this module, so it stays edge-runtime compatible.
|
|
679
|
+
async function handleReadBlob(backend, key) {
|
|
680
|
+
assertSafeBlobKey(key);
|
|
681
|
+
const result = await backend.readBlob(key);
|
|
682
|
+
if (result === null)
|
|
683
|
+
return errorResponse(404, "NOT_FOUND", `no blob '${key}'`);
|
|
684
|
+
return new Response(result.bytes, {
|
|
685
|
+
status: 200,
|
|
686
|
+
headers: { "content-type": result.contentType, ...versionHeaders(result.version) },
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
async function handleHeadBlob(backend, key) {
|
|
690
|
+
// Bodiless on EVERY status, including a validation rejection — mirrors handleHeadDoc.
|
|
691
|
+
try {
|
|
692
|
+
assertSafeBlobKey(key);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return new Response(null, { status: 400 });
|
|
696
|
+
}
|
|
697
|
+
const result = await backend.readBlob(key);
|
|
698
|
+
if (result === null)
|
|
699
|
+
return new Response(null, { status: 404 });
|
|
700
|
+
return new Response(null, {
|
|
701
|
+
status: 200,
|
|
702
|
+
headers: { "content-type": result.contentType, ...versionHeaders(result.version) },
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
async function handleWriteBlob(backend, attribution, key, req) {
|
|
706
|
+
assertSafeBlobKey(key);
|
|
707
|
+
const bytes = new Uint8Array(await req.arrayBuffer());
|
|
708
|
+
const contentTypeHeader = req.headers.get("content-type");
|
|
709
|
+
const contentType = contentTypeHeader && contentTypeHeader.trim() !== "" ? contentTypeHeader : undefined;
|
|
710
|
+
const writeOptions = writeOptionsFromRequest(req, attribution);
|
|
711
|
+
const version = await backend.writeBlob(key, bytes, contentType, writeOptions);
|
|
712
|
+
const status = writeOptions.expectedVersion === null ? 201 : 200;
|
|
713
|
+
return jsonResponse(status, { version }, versionHeaders(version));
|
|
714
|
+
}
|
|
715
|
+
/** `DELETE /blobs/{key}`, mirroring `handleDeleteDoc` exactly (`assertSafeBlobKey` rejects `.md`/traversal keys before the backend is ever called). */
|
|
716
|
+
async function handleDeleteBlob(backend, key, req) {
|
|
717
|
+
assertSafeBlobKey(key);
|
|
718
|
+
const options = deleteOptionsFromHeaders(req);
|
|
719
|
+
const deleted = await backend.deleteBlob(key, options);
|
|
720
|
+
return jsonResponse(200, { deleted });
|
|
721
|
+
}
|
|
722
|
+
async function handleListBlobs(backend, searchParams) {
|
|
723
|
+
// Mirrors handleList's prefix/limit/cursor pagination shape (B2) — no type/tag
|
|
724
|
+
// filters (blobs carry no frontmatter to filter on), and rows are bare keys.
|
|
725
|
+
const prefix = searchParams.get("prefix") ?? undefined;
|
|
726
|
+
const limitParam = searchParams.get("limit");
|
|
727
|
+
const parsedLimit = limitParam ? parseInt(limitParam, 10) : NaN;
|
|
728
|
+
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : DEFAULT_LIST_LIMIT;
|
|
729
|
+
const cursor = searchParams.get("cursor") ?? undefined;
|
|
730
|
+
const keys = await backend.listBlobs(prefix); // already sorted (localeCompare, backend contract)
|
|
731
|
+
const count = keys.length;
|
|
732
|
+
let page = keys;
|
|
733
|
+
if (cursor) {
|
|
734
|
+
const idx = page.findIndex((k) => k === cursor);
|
|
735
|
+
// Same vanished-cursor comparator rule as handleList: the backends sort blob keys
|
|
736
|
+
// with `localeCompare`, so the fallback must compare the same way.
|
|
737
|
+
page = idx >= 0 ? page.slice(idx + 1) : page.filter((k) => k.localeCompare(cursor) > 0);
|
|
738
|
+
}
|
|
739
|
+
const limited = page.slice(0, limit);
|
|
740
|
+
const nextCursor = page.length > limit ? (limited[limited.length - 1] ?? null) : null;
|
|
741
|
+
return jsonResponse(200, { count, keys: limited, next_cursor: nextCursor });
|
|
742
|
+
}
|
|
743
|
+
function handleCapabilities() {
|
|
744
|
+
const caps = options.capabilities;
|
|
745
|
+
return jsonResponse(200, {
|
|
746
|
+
history: caps.history ?? caps.enforced_cas,
|
|
747
|
+
enforced_cas: caps.enforced_cas,
|
|
748
|
+
projections: caps.projections ?? true,
|
|
749
|
+
backlinks: caps.backlinks ?? false,
|
|
750
|
+
blobs: caps.blobs,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
return registeredWireRouter(async (req, { resolved }) => {
|
|
754
|
+
if (resolved.scope === "deployment")
|
|
755
|
+
return handleCapabilities();
|
|
756
|
+
const context = await options.resolveContext(req, resolved);
|
|
757
|
+
if (context instanceof Response)
|
|
758
|
+
return context;
|
|
759
|
+
const { backend, attribution } = context;
|
|
760
|
+
switch (resolved.endpointId) {
|
|
761
|
+
case "capabilities":
|
|
762
|
+
throw new Error("deployment route reached bundle dispatch");
|
|
763
|
+
case "docs-list":
|
|
764
|
+
return await handleList(backend, resolved.searchParams);
|
|
765
|
+
case "docs-read-many":
|
|
766
|
+
return await handleReadMany(backend, req);
|
|
767
|
+
case "doc-versions":
|
|
768
|
+
return await handleVersions(backend, resolved.resource.id);
|
|
769
|
+
case "doc-read":
|
|
770
|
+
return await handleReadDoc(backend, resolved.resource.id);
|
|
771
|
+
case "doc-write":
|
|
772
|
+
return await handleWriteDoc(backend, attribution, resolved.resource.id, req);
|
|
773
|
+
case "doc-head":
|
|
774
|
+
return await handleHeadDoc(backend, resolved.resource.id);
|
|
775
|
+
case "doc-delete":
|
|
776
|
+
return await handleDeleteDoc(backend, resolved.resource.id, req);
|
|
777
|
+
case "reserved-read": {
|
|
778
|
+
const resource = resolved.resource;
|
|
779
|
+
return await handleReadReserved(backend, resource.dir, resource.name);
|
|
780
|
+
}
|
|
781
|
+
case "reserved-write": {
|
|
782
|
+
const resource = resolved.resource;
|
|
783
|
+
return await handleWriteReserved(backend, attribution, resource.dir, resource.name, req);
|
|
784
|
+
}
|
|
785
|
+
case "blobs-list":
|
|
786
|
+
return await handleListBlobs(backend, resolved.searchParams);
|
|
787
|
+
case "blob-read":
|
|
788
|
+
return await handleReadBlob(backend, resolved.resource.key);
|
|
789
|
+
case "blob-write":
|
|
790
|
+
return await handleWriteBlob(backend, attribution, resolved.resource.key, req);
|
|
791
|
+
case "blob-head":
|
|
792
|
+
return await handleHeadBlob(backend, resolved.resource.key);
|
|
793
|
+
case "blob-delete":
|
|
794
|
+
return await handleDeleteBlob(backend, resolved.resource.key, req);
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
//# sourceMappingURL=router.js.map
|