@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/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