mrplex 0.0.1
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/LICENSE +21 -0
- package/README.md +284 -0
- package/dist/cli/config.d.ts +28 -0
- package/dist/cli/config.js +48 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/exit-codes.d.ts +13 -0
- package/dist/cli/exit-codes.js +39 -0
- package/dist/cli/exit-codes.js.map +1 -0
- package/dist/cli/format.d.ts +22 -0
- package/dist/cli/format.js +148 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/main.d.ts +16 -0
- package/dist/cli/main.js +1518 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/client/kernel-client.d.ts +97 -0
- package/dist/client/kernel-client.js +14 -0
- package/dist/client/kernel-client.js.map +1 -0
- package/dist/client/local.d.ts +26 -0
- package/dist/client/local.js +103 -0
- package/dist/client/local.js.map +1 -0
- package/dist/client/remote-mcp.d.ts +35 -0
- package/dist/client/remote-mcp.js +174 -0
- package/dist/client/remote-mcp.js.map +1 -0
- package/dist/embed/backfill.d.ts +24 -0
- package/dist/embed/backfill.js +33 -0
- package/dist/embed/backfill.js.map +1 -0
- package/dist/embed/chunker.d.ts +31 -0
- package/dist/embed/chunker.js +121 -0
- package/dist/embed/chunker.js.map +1 -0
- package/dist/embed/cmd-hook.d.ts +21 -0
- package/dist/embed/cmd-hook.js +100 -0
- package/dist/embed/cmd-hook.js.map +1 -0
- package/dist/embed/config.d.ts +61 -0
- package/dist/embed/config.js +109 -0
- package/dist/embed/config.js.map +1 -0
- package/dist/embed/hook.d.ts +38 -0
- package/dist/embed/hook.js +56 -0
- package/dist/embed/hook.js.map +1 -0
- package/dist/embed/http-hook.d.ts +13 -0
- package/dist/embed/http-hook.js +39 -0
- package/dist/embed/http-hook.js.map +1 -0
- package/dist/embed/worker.d.ts +62 -0
- package/dist/embed/worker.js +228 -0
- package/dist/embed/worker.js.map +1 -0
- package/dist/fixtures/hwe/characters/albion-ever.md +12 -0
- package/dist/fixtures/hwe/characters/cent.md +11 -0
- package/dist/fixtures/hwe/concepts/cent-demiurge-resonance.md +10 -0
- package/dist/fixtures/hwe/concepts/phase-looper.md +10 -0
- package/dist/fixtures/hwe/entities/demiurge.md +11 -0
- package/dist/fixtures/hwe/entities/monad.md +11 -0
- package/dist/fixtures/hwe/moc/globocorp-network.md +19 -0
- package/dist/fixtures/hwe/organizations/globocorp.md +11 -0
- package/dist/fixtures/hwe/readme.md +24 -0
- package/dist/fixtures/starship/crew/aria-okonkwo.md +14 -0
- package/dist/fixtures/starship/crew/bexley-orr.md +14 -0
- package/dist/fixtures/starship/crew/dax-thorne.md +14 -0
- package/dist/fixtures/starship/crew/isolde-marsh.md +16 -0
- package/dist/fixtures/starship/crew/kestrel-vance.md +15 -0
- package/dist/fixtures/starship/crew/quill-vasquez.md +14 -0
- package/dist/fixtures/starship/crew/soren-halloway.md +16 -0
- package/dist/fixtures/starship/encounters/the-drift-choir.md +14 -0
- package/dist/fixtures/starship/encounters/the-hollow-signal.md +16 -0
- package/dist/fixtures/starship/encounters/the-wandering-buoy.md +14 -0
- package/dist/fixtures/starship/equipment/coolant-loop-b.md +13 -0
- package/dist/fixtures/starship/equipment/emergency-ration-bars.md +12 -0
- package/dist/fixtures/starship/equipment/long-range-sensors.md +13 -0
- package/dist/fixtures/starship/equipment/plasma-manifold-3.md +15 -0
- package/dist/fixtures/starship/equipment/shuttle-corvid.md +13 -0
- package/dist/fixtures/starship/logs/marsh-4419-1.md +13 -0
- package/dist/fixtures/starship/logs/okonkwo-4415-1.md +13 -0
- package/dist/fixtures/starship/logs/orr-4413-1.md +13 -0
- package/dist/fixtures/starship/logs/orr-4420-1.md +12 -0
- package/dist/fixtures/starship/logs/thorne-4413-2.md +13 -0
- package/dist/fixtures/starship/logs/vance-4419-2.md +13 -0
- package/dist/fixtures/starship/misc/the-galley-ficus.md +11 -0
- package/dist/fixtures/starship/missions/the-cinder-run.md +17 -0
- package/dist/fixtures/starship/missions/the-drift-choir.md +17 -0
- package/dist/fixtures/starship/missions/the-hollow-signal.md +17 -0
- package/dist/fixtures/starship/missions/the-silent-beacon.md +17 -0
- package/dist/fixtures/starship/moc/crew.md +19 -0
- package/dist/fixtures/starship/moc/encounters.md +14 -0
- package/dist/fixtures/starship/moc/missions.md +15 -0
- package/dist/fixtures/starship/readme.md +60 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/auth/glob.d.ts +46 -0
- package/dist/kernel/auth/glob.js +112 -0
- package/dist/kernel/auth/glob.js.map +1 -0
- package/dist/kernel/auth/scope.d.ts +53 -0
- package/dist/kernel/auth/scope.js +83 -0
- package/dist/kernel/auth/scope.js.map +1 -0
- package/dist/kernel/casefold.d.ts +37 -0
- package/dist/kernel/casefold.js +44 -0
- package/dist/kernel/casefold.js.map +1 -0
- package/dist/kernel/constants.d.ts +21 -0
- package/dist/kernel/constants.js +22 -0
- package/dist/kernel/constants.js.map +1 -0
- package/dist/kernel/context.d.ts +70 -0
- package/dist/kernel/context.js +114 -0
- package/dist/kernel/context.js.map +1 -0
- package/dist/kernel/deletion.d.ts +50 -0
- package/dist/kernel/deletion.js +83 -0
- package/dist/kernel/deletion.js.map +1 -0
- package/dist/kernel/diff.d.ts +36 -0
- package/dist/kernel/diff.js +61 -0
- package/dist/kernel/diff.js.map +1 -0
- package/dist/kernel/errors.d.ts +37 -0
- package/dist/kernel/errors.js +55 -0
- package/dist/kernel/errors.js.map +1 -0
- package/dist/kernel/frontmatter-input.d.ts +32 -0
- package/dist/kernel/frontmatter-input.js +85 -0
- package/dist/kernel/frontmatter-input.js.map +1 -0
- package/dist/kernel/graph.d.ts +34 -0
- package/dist/kernel/graph.js +559 -0
- package/dist/kernel/graph.js.map +1 -0
- package/dist/kernel/history.d.ts +74 -0
- package/dist/kernel/history.js +143 -0
- package/dist/kernel/history.js.map +1 -0
- package/dist/kernel/kernel.d.ts +162 -0
- package/dist/kernel/kernel.js +675 -0
- package/dist/kernel/kernel.js.map +1 -0
- package/dist/kernel/path-config.d.ts +101 -0
- package/dist/kernel/path-config.js +171 -0
- package/dist/kernel/path-config.js.map +1 -0
- package/dist/kernel/query/ast.d.ts +24 -0
- package/dist/kernel/query/ast.js +52 -0
- package/dist/kernel/query/ast.js.map +1 -0
- package/dist/kernel/query/cel-parse.d.ts +37 -0
- package/dist/kernel/query/cel-parse.js +88 -0
- package/dist/kernel/query/cel-parse.js.map +1 -0
- package/dist/kernel/query/degrees.d.ts +31 -0
- package/dist/kernel/query/degrees.js +135 -0
- package/dist/kernel/query/degrees.js.map +1 -0
- package/dist/kernel/query/graph-ast.d.ts +51 -0
- package/dist/kernel/query/graph-ast.js +139 -0
- package/dist/kernel/query/graph-ast.js.map +1 -0
- package/dist/kernel/query/query.d.ts +56 -0
- package/dist/kernel/query/query.js +276 -0
- package/dist/kernel/query/query.js.map +1 -0
- package/dist/kernel/validation.d.ts +64 -0
- package/dist/kernel/validation.js +183 -0
- package/dist/kernel/validation.js.map +1 -0
- package/dist/kernel/version-id.d.ts +10 -0
- package/dist/kernel/version-id.js +21 -0
- package/dist/kernel/version-id.js.map +1 -0
- package/dist/kernel/wire.d.ts +148 -0
- package/dist/kernel/wire.js +6 -0
- package/dist/kernel/wire.js.map +1 -0
- package/dist/links/backfill.d.ts +28 -0
- package/dist/links/backfill.js +33 -0
- package/dist/links/backfill.js.map +1 -0
- package/dist/links/extract.d.ts +29 -0
- package/dist/links/extract.js +233 -0
- package/dist/links/extract.js.map +1 -0
- package/dist/links/link-config.d.ts +70 -0
- package/dist/links/link-config.js +107 -0
- package/dist/links/link-config.js.map +1 -0
- package/dist/links/maintain.d.ts +37 -0
- package/dist/links/maintain.js +91 -0
- package/dist/links/maintain.js.map +1 -0
- package/dist/links/repair.d.ts +46 -0
- package/dist/links/repair.js +96 -0
- package/dist/links/repair.js.map +1 -0
- package/dist/links/resolve.d.ts +41 -0
- package/dist/links/resolve.js +121 -0
- package/dist/links/resolve.js.map +1 -0
- package/dist/links/stale.d.ts +32 -0
- package/dist/links/stale.js +73 -0
- package/dist/links/stale.js.map +1 -0
- package/dist/markdown/content-hash.d.ts +34 -0
- package/dist/markdown/content-hash.js +46 -0
- package/dist/markdown/content-hash.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +69 -0
- package/dist/markdown/frontmatter.js +147 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/hash-backfill.d.ts +22 -0
- package/dist/markdown/hash-backfill.js +43 -0
- package/dist/markdown/hash-backfill.js.map +1 -0
- package/dist/mcp/query-syntax.d.ts +20 -0
- package/dist/mcp/query-syntax.js +194 -0
- package/dist/mcp/query-syntax.js.map +1 -0
- package/dist/mcp/render.d.ts +31 -0
- package/dist/mcp/render.js +110 -0
- package/dist/mcp/render.js.map +1 -0
- package/dist/mcp/server.d.ts +60 -0
- package/dist/mcp/server.js +170 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +93 -0
- package/dist/mcp/tools.js +1218 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/rest/conditional.d.ts +37 -0
- package/dist/rest/conditional.js +58 -0
- package/dist/rest/conditional.js.map +1 -0
- package/dist/rest/negotiate.d.ts +33 -0
- package/dist/rest/negotiate.js +54 -0
- package/dist/rest/negotiate.js.map +1 -0
- package/dist/rest/routes.d.ts +37 -0
- package/dist/rest/routes.js +744 -0
- package/dist/rest/routes.js.map +1 -0
- package/dist/seed/fixture-seed.d.ts +35 -0
- package/dist/seed/fixture-seed.js +75 -0
- package/dist/seed/fixture-seed.js.map +1 -0
- package/dist/seed/starship-config.d.ts +3 -0
- package/dist/seed/starship-config.js +5 -0
- package/dist/seed/starship-config.js.map +1 -0
- package/dist/server/headers.d.ts +36 -0
- package/dist/server/headers.js +30 -0
- package/dist/server/headers.js.map +1 -0
- package/dist/server/http-error.d.ts +53 -0
- package/dist/server/http-error.js +104 -0
- package/dist/server/http-error.js.map +1 -0
- package/dist/server/serve.d.ts +56 -0
- package/dist/server/serve.js +135 -0
- package/dist/server/serve.js.map +1 -0
- package/dist/shell/audit.d.ts +32 -0
- package/dist/shell/audit.js +28 -0
- package/dist/shell/audit.js.map +1 -0
- package/dist/shell/guard.d.ts +50 -0
- package/dist/shell/guard.js +176 -0
- package/dist/shell/guard.js.map +1 -0
- package/dist/shell/keys.d.ts +36 -0
- package/dist/shell/keys.js +64 -0
- package/dist/shell/keys.js.map +1 -0
- package/dist/shell/login.d.ts +56 -0
- package/dist/shell/login.js +130 -0
- package/dist/shell/login.js.map +1 -0
- package/dist/shell/oidc.d.ts +62 -0
- package/dist/shell/oidc.js +102 -0
- package/dist/shell/oidc.js.map +1 -0
- package/dist/shell/policy.d.ts +100 -0
- package/dist/shell/policy.js +232 -0
- package/dist/shell/policy.js.map +1 -0
- package/dist/shell/proxy-policy.d.ts +34 -0
- package/dist/shell/proxy-policy.js +120 -0
- package/dist/shell/proxy-policy.js.map +1 -0
- package/dist/shell/proxy.d.ts +47 -0
- package/dist/shell/proxy.js +253 -0
- package/dist/shell/proxy.js.map +1 -0
- package/dist/shell/serve.d.ts +86 -0
- package/dist/shell/serve.js +0 -0
- package/dist/shell/serve.js.map +1 -0
- package/dist/shell/stdio.d.ts +55 -0
- package/dist/shell/stdio.js +63 -0
- package/dist/shell/stdio.js.map +1 -0
- package/dist/storage/registry.d.ts +20 -0
- package/dist/storage/registry.js +38 -0
- package/dist/storage/registry.js.map +1 -0
- package/dist/storage/search-plan.d.ts +93 -0
- package/dist/storage/search-plan.js +15 -0
- package/dist/storage/search-plan.js.map +1 -0
- package/dist/storage/types.d.ts +448 -0
- package/dist/storage/types.js +14 -0
- package/dist/storage/types.js.map +1 -0
- package/dist/storage/versions-since.d.ts +72 -0
- package/dist/storage/versions-since.js +92 -0
- package/dist/storage/versions-since.js.map +1 -0
- package/dist/storage-postgres/adapter.d.ts +17 -0
- package/dist/storage-postgres/adapter.js +782 -0
- package/dist/storage-postgres/adapter.js.map +1 -0
- package/dist/storage-postgres/compile-postgres.d.ts +22 -0
- package/dist/storage-postgres/compile-postgres.js +830 -0
- package/dist/storage-postgres/compile-postgres.js.map +1 -0
- package/dist/storage-postgres/errors.d.ts +34 -0
- package/dist/storage-postgres/errors.js +50 -0
- package/dist/storage-postgres/errors.js.map +1 -0
- package/dist/storage-postgres/migrations/0001_init.sql +113 -0
- package/dist/storage-postgres/migrations/0002_content_hash.sql +7 -0
- package/dist/storage-postgres/migrations/index.d.ts +22 -0
- package/dist/storage-postgres/migrations/index.js +70 -0
- package/dist/storage-postgres/migrations/index.js.map +1 -0
- package/dist/storage-sqlite/adapter.d.ts +2 -0
- package/dist/storage-sqlite/adapter.js +688 -0
- package/dist/storage-sqlite/adapter.js.map +1 -0
- package/dist/storage-sqlite/compile-filter.d.ts +41 -0
- package/dist/storage-sqlite/compile-filter.js +881 -0
- package/dist/storage-sqlite/compile-filter.js.map +1 -0
- package/dist/storage-sqlite/compile-sqlite.d.ts +19 -0
- package/dist/storage-sqlite/compile-sqlite.js +176 -0
- package/dist/storage-sqlite/compile-sqlite.js.map +1 -0
- package/dist/storage-sqlite/migrations/0001_init.sql +123 -0
- package/dist/storage-sqlite/migrations/0002_content_hash.sql +7 -0
- package/dist/storage-sqlite/migrations/index.d.ts +6 -0
- package/dist/storage-sqlite/migrations/index.js +41 -0
- package/dist/storage-sqlite/migrations/index.js.map +1 -0
- package/dist/storage-sqlite/vec.d.ts +40 -0
- package/dist/storage-sqlite/vec.js +62 -0
- package/dist/storage-sqlite/vec.js.map +1 -0
- package/dist/sync/cursor.d.ts +36 -0
- package/dist/sync/cursor.js +66 -0
- package/dist/sync/cursor.js.map +1 -0
- package/dist/sync/daemon.d.ts +40 -0
- package/dist/sync/daemon.js +202 -0
- package/dist/sync/daemon.js.map +1 -0
- package/dist/sync/feed.d.ts +38 -0
- package/dist/sync/feed.js +125 -0
- package/dist/sync/feed.js.map +1 -0
- package/dist/sync/fs-store.d.ts +8 -0
- package/dist/sync/fs-store.js +76 -0
- package/dist/sync/fs-store.js.map +1 -0
- package/dist/sync/intrinsics.d.ts +52 -0
- package/dist/sync/intrinsics.js +76 -0
- package/dist/sync/intrinsics.js.map +1 -0
- package/dist/sync/paths.d.ts +38 -0
- package/dist/sync/paths.js +56 -0
- package/dist/sync/paths.js.map +1 -0
- package/dist/sync/push.d.ts +48 -0
- package/dist/sync/push.js +328 -0
- package/dist/sync/push.js.map +1 -0
- package/dist/sync/reconcile.d.ts +54 -0
- package/dist/sync/reconcile.js +252 -0
- package/dist/sync/reconcile.js.map +1 -0
- package/dist/sync/run.d.ts +27 -0
- package/dist/sync/run.js +46 -0
- package/dist/sync/run.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST route table — design §6.3.
|
|
3
|
+
*
|
|
4
|
+
* Small hand-rolled router over node:http (m3-plan §5 decision 1). The
|
|
5
|
+
* route table isn't complex enough to justify a framework; the two hard
|
|
6
|
+
* parts (multi-segment `{path}` params and header-driven dispatch) aren't
|
|
7
|
+
* framework strengths.
|
|
8
|
+
*
|
|
9
|
+
* Every non-2xx response body is `{ code, data }`. Kernel errors carry
|
|
10
|
+
* their catalog code (§6.3); HTTP-only dispatch outcomes use surface-only
|
|
11
|
+
* codes (`method_not_allowed`, `no_route`); uncaught throwables fall back
|
|
12
|
+
* to `{ code: "internal_error" }` on 500.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { validateScopeClaims } from "../kernel/context.js";
|
|
16
|
+
import { KernelError } from "../kernel/errors.js";
|
|
17
|
+
import { appendSystemProperty, extractSystemProperties } from "../markdown/frontmatter.js";
|
|
18
|
+
import { contextFromHeaders, } from "../server/headers.js";
|
|
19
|
+
import { httpErrorForThrowable } from "../server/http-error.js";
|
|
20
|
+
import { etagOf, parseIfMatch, parseIfNoneMatch } from "./conditional.js";
|
|
21
|
+
import { chooseDocReadAccept, chooseDocWriteContentType, parseMarkdown, renderMarkdown, } from "./negotiate.js";
|
|
22
|
+
function parseUrl(reqUrl) {
|
|
23
|
+
// A URL with no host is unparseable by `new URL(x)`, so prefix a dummy
|
|
24
|
+
// origin. The origin never appears in `.pathname` / `.searchParams`.
|
|
25
|
+
const u = new URL(reqUrl, "http://x");
|
|
26
|
+
const path = u.pathname;
|
|
27
|
+
// Strip leading slash, then split. Empty leading segment (from `/`) is
|
|
28
|
+
// dropped; trailing `/` becomes a trailing empty segment we also drop.
|
|
29
|
+
const raw = path.replace(/^\//, "").replace(/\/$/, "");
|
|
30
|
+
// decodeURIComponent throws URIError on malformed input (e.g. "/repos/%E0%A4%A");
|
|
31
|
+
// treat that as a client-side path_invalid rather than letting it bubble to 500.
|
|
32
|
+
const segments = raw === "" ? [] : raw.split("/").map(decodeSegment);
|
|
33
|
+
return { segments, query: u.searchParams };
|
|
34
|
+
}
|
|
35
|
+
function decodeSegment(s) {
|
|
36
|
+
try {
|
|
37
|
+
return decodeURIComponent(s);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new KernelError("path_invalid", {
|
|
41
|
+
reason: "malformed percent-encoding in URL segment",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// -----------------------------------------------------------------------------
|
|
46
|
+
// Body reading + JSON parse
|
|
47
|
+
// -----------------------------------------------------------------------------
|
|
48
|
+
const MAX_BODY_BYTES = 8 * 1024 * 1024; // 8 MB safety cap — well above any single markdown doc.
|
|
49
|
+
async function readBody(req) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let total = 0;
|
|
53
|
+
let capped = false;
|
|
54
|
+
req.on("data", (chunk) => {
|
|
55
|
+
if (capped)
|
|
56
|
+
return;
|
|
57
|
+
total += chunk.length;
|
|
58
|
+
if (total > MAX_BODY_BYTES) {
|
|
59
|
+
capped = true;
|
|
60
|
+
// Destroy the socket so we don't keep receiving bytes we've decided
|
|
61
|
+
// to reject — otherwise a hostile client could stream indefinitely.
|
|
62
|
+
req.destroy();
|
|
63
|
+
reject(new KernelError("payload_too_large", {
|
|
64
|
+
reason: `body exceeded ${MAX_BODY_BYTES} bytes`,
|
|
65
|
+
limit: MAX_BODY_BYTES,
|
|
66
|
+
}));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
chunks.push(chunk);
|
|
70
|
+
});
|
|
71
|
+
req.on("end", () => {
|
|
72
|
+
if (!capped)
|
|
73
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
74
|
+
});
|
|
75
|
+
req.on("error", (err) => {
|
|
76
|
+
if (!capped)
|
|
77
|
+
reject(err);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function parseJsonBody(raw) {
|
|
82
|
+
if (raw === "")
|
|
83
|
+
return {};
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(raw);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// filter_invalid is the general "malformed input" bucket at the wire
|
|
89
|
+
// layer; distinct from frontmatter_invalid (§4.3), which is YAML-only.
|
|
90
|
+
throw new KernelError("filter_invalid", { reason: "request body is not valid JSON" });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// -----------------------------------------------------------------------------
|
|
94
|
+
// Response helpers
|
|
95
|
+
// -----------------------------------------------------------------------------
|
|
96
|
+
function writeJson(res, status, body, extraHeaders) {
|
|
97
|
+
res.statusCode = status;
|
|
98
|
+
res.setHeader("Content-Type", "application/json");
|
|
99
|
+
if (extraHeaders) {
|
|
100
|
+
for (const [k, v] of Object.entries(extraHeaders))
|
|
101
|
+
res.setHeader(k, v);
|
|
102
|
+
}
|
|
103
|
+
res.end(`${JSON.stringify(body)}\n`);
|
|
104
|
+
}
|
|
105
|
+
function writeMarkdown(res, status, body, extraHeaders) {
|
|
106
|
+
res.statusCode = status;
|
|
107
|
+
res.setHeader("Content-Type", "text/markdown; charset=utf-8");
|
|
108
|
+
if (extraHeaders) {
|
|
109
|
+
for (const [k, v] of Object.entries(extraHeaders))
|
|
110
|
+
res.setHeader(k, v);
|
|
111
|
+
}
|
|
112
|
+
res.end(body);
|
|
113
|
+
}
|
|
114
|
+
function writeEmpty(res, status, extraHeaders) {
|
|
115
|
+
res.statusCode = status;
|
|
116
|
+
if (extraHeaders) {
|
|
117
|
+
for (const [k, v] of Object.entries(extraHeaders))
|
|
118
|
+
res.setHeader(k, v);
|
|
119
|
+
}
|
|
120
|
+
res.end();
|
|
121
|
+
}
|
|
122
|
+
function writeError(res, err) {
|
|
123
|
+
const httpErr = httpErrorForThrowable(err);
|
|
124
|
+
const headers = {};
|
|
125
|
+
if (httpErr.etag !== undefined)
|
|
126
|
+
headers.ETag = etagOf(httpErr.etag);
|
|
127
|
+
writeJson(res, httpErr.status, httpErr.body, headers);
|
|
128
|
+
}
|
|
129
|
+
/** Missing precondition per m3-plan decision 5 / design §6.3. */
|
|
130
|
+
function writePreconditionRequired(res, reason) {
|
|
131
|
+
writeJson(res, 428, {
|
|
132
|
+
code: "precondition_required",
|
|
133
|
+
data: { reason },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// -----------------------------------------------------------------------------
|
|
137
|
+
// Query-response ETag — hash of the projected result payload. `query` now
|
|
138
|
+
// returns lean `QueryHit` objects (docs/query-select-plan.md) whose exact
|
|
139
|
+
// shape depends on `select`, so the validator hashes the serialized hits
|
|
140
|
+
// rather than a version_id list (which may not be projected at all).
|
|
141
|
+
// -----------------------------------------------------------------------------
|
|
142
|
+
function queryEtag(hits) {
|
|
143
|
+
const h = createHash("sha256").update(JSON.stringify(hits)).digest("hex");
|
|
144
|
+
// Short ETag — first 16 hex chars are plenty for collision resistance
|
|
145
|
+
// within a single result set's lifespan (2^64 hashes).
|
|
146
|
+
return h.slice(0, 16);
|
|
147
|
+
}
|
|
148
|
+
// -----------------------------------------------------------------------------
|
|
149
|
+
// Route table
|
|
150
|
+
// -----------------------------------------------------------------------------
|
|
151
|
+
export function mountRestSurface(config) {
|
|
152
|
+
const { kernel, storage } = config;
|
|
153
|
+
const contextForRequest = config.contextForRequest ?? contextFromHeaders;
|
|
154
|
+
const kernelForRequest = config.kernelForRequest ?? (() => kernel);
|
|
155
|
+
async function handle(req, res) {
|
|
156
|
+
try {
|
|
157
|
+
// Resolve the per-request kernel FIRST — the shell authenticates here and
|
|
158
|
+
// may throw (e.g. 401) before any routing happens.
|
|
159
|
+
const reqKernel = await kernelForRequest(req);
|
|
160
|
+
await dispatch(req, res, reqKernel, storage, contextForRequest);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
writeError(res, err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { handle };
|
|
167
|
+
}
|
|
168
|
+
async function dispatch(req, res, kernel, storage, contextForRequest) {
|
|
169
|
+
const { segments, query } = parseUrl(req.url ?? "/");
|
|
170
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
171
|
+
// Root — trivial liveness ping.
|
|
172
|
+
if (segments.length === 0) {
|
|
173
|
+
writeJson(res, 200, { name: "mrplex", surfaces: ["rest", "mcp"] });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// /query — GET or POST.
|
|
177
|
+
if (segments[0] === "query" && segments.length === 1) {
|
|
178
|
+
return dispatchQuery(req, res, kernel, query, method, contextForRequest);
|
|
179
|
+
}
|
|
180
|
+
// /repos and everything under it (config, docs, versions, history).
|
|
181
|
+
if (segments[0] === "repos") {
|
|
182
|
+
return dispatchRepos(req, res, kernel, storage, segments, query, method, contextForRequest);
|
|
183
|
+
}
|
|
184
|
+
notFound(res);
|
|
185
|
+
}
|
|
186
|
+
// -----------------------------------------------------------------------------
|
|
187
|
+
// /repos*
|
|
188
|
+
// -----------------------------------------------------------------------------
|
|
189
|
+
async function dispatchRepos(req, res, kernel, storage, segments, query, method, contextForRequest) {
|
|
190
|
+
void storage;
|
|
191
|
+
const ctx = await contextForRequest(req);
|
|
192
|
+
// GET /repos, POST /repos
|
|
193
|
+
if (segments.length === 1) {
|
|
194
|
+
if (method === "GET") {
|
|
195
|
+
const includeSystem = query.get("include_system") === "true";
|
|
196
|
+
const result = await kernel.repos.list(ctx, { include_system: includeSystem });
|
|
197
|
+
writeJson(res, 200, result);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (method === "POST") {
|
|
201
|
+
const body = parseJsonBody(await readBody(req));
|
|
202
|
+
if (typeof body.slug !== "string") {
|
|
203
|
+
throw new KernelError("slug_invalid", { reason: "body.slug required (string)" });
|
|
204
|
+
}
|
|
205
|
+
const result = await kernel.repos.create(ctx, body.slug);
|
|
206
|
+
writeJson(res, 201, result);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
return methodNotAllowed(res, method, ["GET", "POST"]);
|
|
210
|
+
}
|
|
211
|
+
const repoSlug = segments[1];
|
|
212
|
+
if (repoSlug === undefined)
|
|
213
|
+
return notFound(res);
|
|
214
|
+
// /repos/{repo}
|
|
215
|
+
if (segments.length === 2) {
|
|
216
|
+
if (method === "GET") {
|
|
217
|
+
writeJson(res, 200, await kernel.repos.get(ctx, repoSlug));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (method === "MOVE") {
|
|
221
|
+
const dest = parseRepoDestination(req.headers.destination);
|
|
222
|
+
if (dest === null) {
|
|
223
|
+
throw new KernelError("slug_invalid", { reason: "Destination header required" });
|
|
224
|
+
}
|
|
225
|
+
writeJson(res, 200, await kernel.repos.rename(ctx, repoSlug, dest));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (method === "DELETE") {
|
|
229
|
+
writeJson(res, 200, await kernel.repos.delete(ctx, repoSlug));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
return methodNotAllowed(res, method, ["GET", "MOVE", "DELETE"]);
|
|
233
|
+
}
|
|
234
|
+
// /repos/{repo}/config
|
|
235
|
+
if (segments.length === 3 && segments[2] === "config") {
|
|
236
|
+
if (method !== "PUT")
|
|
237
|
+
return methodNotAllowed(res, method, ["PUT"]);
|
|
238
|
+
const body = parseJsonBody(await readBody(req));
|
|
239
|
+
// Accept both the wrapped `{path_config: X}` form (matches §6.3 route
|
|
240
|
+
// definition) and a bare object (either the override map or `null`).
|
|
241
|
+
const cfg = body && Object.prototype.hasOwnProperty.call(body, "path_config")
|
|
242
|
+
? body.path_config
|
|
243
|
+
: body;
|
|
244
|
+
writeJson(res, 200, await kernel.repos.set_path_config(ctx, repoSlug, cfg));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// /repos/{repo}/docs:get — batch current-version fetch (POST only).
|
|
248
|
+
if (segments.length === 3 && segments[2] === "docs:get") {
|
|
249
|
+
if (method !== "POST")
|
|
250
|
+
return methodNotAllowed(res, method, ["POST"]);
|
|
251
|
+
const body = parseJsonBody(await readBody(req));
|
|
252
|
+
const result = await kernel.docs.get_many(ctx, repoSlug, body.paths);
|
|
253
|
+
const mode = body.raw === true ? "raw" : systemPropsMode(query);
|
|
254
|
+
const items = mode === "raw"
|
|
255
|
+
? result.items
|
|
256
|
+
: result.items.map((v) => ({
|
|
257
|
+
...v,
|
|
258
|
+
frontmatter_raw: appendSystemProperty(appendSystemProperty(v.frontmatter_raw, "version", v.version_id), "content_hash", v.content_hash),
|
|
259
|
+
}));
|
|
260
|
+
writeJson(res, 200, { items, errors: result.errors });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// /repos/{repo}/docs/{path...}
|
|
264
|
+
if (segments[2] === "docs") {
|
|
265
|
+
const pathSegs = segments.slice(3);
|
|
266
|
+
if (pathSegs.length === 0)
|
|
267
|
+
return notFound(res);
|
|
268
|
+
const path = pathSegs.join("/");
|
|
269
|
+
return dispatchDocs(req, res, kernel, ctx, repoSlug, path, method, query);
|
|
270
|
+
}
|
|
271
|
+
// /repos/{repo}/versions/{version_id}
|
|
272
|
+
if (segments[2] === "versions") {
|
|
273
|
+
if (segments.length !== 4)
|
|
274
|
+
return notFound(res);
|
|
275
|
+
if (method !== "GET")
|
|
276
|
+
return methodNotAllowed(res, method, ["GET"]);
|
|
277
|
+
const versionId = segments[3];
|
|
278
|
+
const v = await kernel.docs.get_version(ctx, repoSlug, versionId);
|
|
279
|
+
return writeDocResponse(req, res, v, systemPropsMode(query));
|
|
280
|
+
}
|
|
281
|
+
// /repos/{repo}/history/{path...}
|
|
282
|
+
if (segments[2] === "history") {
|
|
283
|
+
if (method !== "GET")
|
|
284
|
+
return methodNotAllowed(res, method, ["GET"]);
|
|
285
|
+
const pathSegs = segments.slice(3);
|
|
286
|
+
if (pathSegs.length === 0)
|
|
287
|
+
return notFound(res);
|
|
288
|
+
const path = pathSegs.join("/");
|
|
289
|
+
const limit = readOptionalIntQueryParam(query, "limit");
|
|
290
|
+
const before = query.get("before") ?? undefined;
|
|
291
|
+
const rows = await kernel.docs.history(ctx, repoSlug, path, { limit, before });
|
|
292
|
+
writeJson(res, 200, rows);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// /repos/{repo}/diff/{path...}?from=&to= — M4 (§6.3)
|
|
296
|
+
if (segments[2] === "diff") {
|
|
297
|
+
if (method !== "GET")
|
|
298
|
+
return methodNotAllowed(res, method, ["GET"]);
|
|
299
|
+
const pathSegs = segments.slice(3);
|
|
300
|
+
if (pathSegs.length === 0)
|
|
301
|
+
return notFound(res);
|
|
302
|
+
const path = pathSegs.join("/");
|
|
303
|
+
const from = query.get("from");
|
|
304
|
+
const to = query.get("to");
|
|
305
|
+
if (from === null || to === null) {
|
|
306
|
+
// Missing required query params — 400 with a clear body.
|
|
307
|
+
writeJson(res, 400, {
|
|
308
|
+
code: "filter_invalid",
|
|
309
|
+
data: { reason: "diff requires `from` and `to` query parameters (version ids)" },
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const d = await kernel.docs.diff(ctx, repoSlug, path, from, to);
|
|
314
|
+
// Content negotiation: Accept: text/plain → raw patch, else JSON.
|
|
315
|
+
const accept = req.headers.accept ?? "application/json";
|
|
316
|
+
if (accept.includes("text/plain")) {
|
|
317
|
+
res.statusCode = 200;
|
|
318
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
319
|
+
res.end(d.patch);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
writeJson(res, 200, d);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// /repos/{repo}/graph — GET or POST (mirrors /query's duality).
|
|
326
|
+
if (segments[2] === "graph" && segments.length === 3) {
|
|
327
|
+
return dispatchGraph(req, res, kernel, ctx, repoSlug, query, method);
|
|
328
|
+
}
|
|
329
|
+
notFound(res);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Graph read surface (docs/graph-plan.md §WS4). GET encodes the simple cases in
|
|
333
|
+
* query params; POST takes a JSON GraphSpec body when filter/select/arrays get
|
|
334
|
+
* awkward to URL-encode. Repo comes from the path; the body's `repo` (if any)
|
|
335
|
+
* is ignored in favor of the route. Scope header wins over a body `scope`.
|
|
336
|
+
*/
|
|
337
|
+
async function dispatchGraph(req, res, kernel, ctx, repoSlug, query, method) {
|
|
338
|
+
let spec;
|
|
339
|
+
if (method === "GET") {
|
|
340
|
+
spec = graphSpecFromQueryString(query, repoSlug);
|
|
341
|
+
}
|
|
342
|
+
else if (method === "POST") {
|
|
343
|
+
const body = parseJsonBody(await readBody(req));
|
|
344
|
+
if (body.scope !== undefined) {
|
|
345
|
+
if (ctx.scope === undefined) {
|
|
346
|
+
ctx.scope = validateScopeClaims(body.scope);
|
|
347
|
+
}
|
|
348
|
+
const { scope: _scope, ...rest } = body;
|
|
349
|
+
spec = { ...rest, repo: repoSlug };
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
spec = { ...body, repo: repoSlug };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
return methodNotAllowed(res, method, ["GET", "POST"]);
|
|
357
|
+
}
|
|
358
|
+
const result = await kernel.graph(ctx, spec);
|
|
359
|
+
writeJson(res, 200, result);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Parse GraphSpec from query params. `roots`/`fields`/`select` accept repeated
|
|
363
|
+
* params or comma-joined values (matching /query's `repo` convention);
|
|
364
|
+
* `direction`/`degrees`/`filter`/`max_documents` are scalars.
|
|
365
|
+
*/
|
|
366
|
+
function graphSpecFromQueryString(query, repoSlug) {
|
|
367
|
+
const spec = { repo: repoSlug, roots: collectListParam(query, "roots") };
|
|
368
|
+
const direction = query.get("direction");
|
|
369
|
+
if (direction !== null)
|
|
370
|
+
spec.direction = direction;
|
|
371
|
+
const degrees = readOptionalIntQueryParam(query, "degrees");
|
|
372
|
+
if (degrees !== undefined)
|
|
373
|
+
spec.degrees = degrees;
|
|
374
|
+
const fields = collectListParam(query, "fields");
|
|
375
|
+
if (fields.length > 0)
|
|
376
|
+
spec.fields = fields;
|
|
377
|
+
const filter = query.get("filter");
|
|
378
|
+
if (filter !== null)
|
|
379
|
+
spec.filter = filter;
|
|
380
|
+
const select = collectListParam(query, "select");
|
|
381
|
+
if (select.length > 0)
|
|
382
|
+
spec.select = select;
|
|
383
|
+
const maxDocuments = readOptionalIntQueryParam(query, "max_documents");
|
|
384
|
+
if (maxDocuments !== undefined)
|
|
385
|
+
spec.max_documents = maxDocuments;
|
|
386
|
+
return spec;
|
|
387
|
+
}
|
|
388
|
+
/** Flatten repeated and/or comma-joined query params into a string[]. */
|
|
389
|
+
function collectListParam(query, key) {
|
|
390
|
+
const flat = [];
|
|
391
|
+
for (const raw of query.getAll(key)) {
|
|
392
|
+
for (const item of raw.split(","))
|
|
393
|
+
if (item.length > 0)
|
|
394
|
+
flat.push(item);
|
|
395
|
+
}
|
|
396
|
+
return flat;
|
|
397
|
+
}
|
|
398
|
+
async function dispatchDocs(req, res, kernel, ctx, repoSlug, path, method, query) {
|
|
399
|
+
if (method === "GET") {
|
|
400
|
+
const v = await kernel.docs.get(ctx, repoSlug, path);
|
|
401
|
+
return writeDocResponse(req, res, v, systemPropsMode(query));
|
|
402
|
+
}
|
|
403
|
+
if (method === "PUT") {
|
|
404
|
+
const ifMatchHeader = parseIfMatch(req.headers["if-match"]);
|
|
405
|
+
const ifNoneMatch = parseIfNoneMatch(req.headers["if-none-match"]);
|
|
406
|
+
const ct = chooseDocWriteContentType(req.headers["content-type"]);
|
|
407
|
+
const raw = await readBody(req);
|
|
408
|
+
// input.body distinguishes three states:
|
|
409
|
+
// • string ("" allowed) → caller explicitly set the body
|
|
410
|
+
// • undefined → caller omitted body (put: carry over from prev; create: empty)
|
|
411
|
+
// Non-string values (numbers, objects, null) are a client error — never silently coerced to ""
|
|
412
|
+
// (previously that could wipe a document's body).
|
|
413
|
+
let input;
|
|
414
|
+
if (ct === "markdown") {
|
|
415
|
+
const parsed = parseMarkdown(raw);
|
|
416
|
+
input = { frontmatter_raw: parsed.frontmatter_raw, body: parsed.body };
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
const parsed = parseJsonBody(raw);
|
|
420
|
+
if (parsed.body !== undefined && typeof parsed.body !== "string") {
|
|
421
|
+
throw new KernelError("filter_invalid", { reason: "body must be a string" });
|
|
422
|
+
}
|
|
423
|
+
input = {
|
|
424
|
+
frontmatter: parsed.frontmatter,
|
|
425
|
+
frontmatter_raw: typeof parsed.frontmatter_raw === "string" ? parsed.frontmatter_raw : undefined,
|
|
426
|
+
body: parsed.body,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
// Peel any system properties out of the raw frontmatter before it reaches
|
|
430
|
+
// the kernel. `$version` (if present, and no If-Match header) supplies the
|
|
431
|
+
// optimistic-concurrency prev — enabling the "GET → edit body → PUT" loop
|
|
432
|
+
// without tracking a separate version id.
|
|
433
|
+
let embeddedVersion;
|
|
434
|
+
if (input.frontmatter_raw !== undefined) {
|
|
435
|
+
const { raw: cleaned, props } = extractSystemProperties(input.frontmatter_raw);
|
|
436
|
+
input.frontmatter_raw = cleaned;
|
|
437
|
+
if (typeof props.version === "string" && props.version.length > 0) {
|
|
438
|
+
embeddedVersion = props.version;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const ifMatch = ifMatchHeader ??
|
|
442
|
+
(embeddedVersion !== undefined
|
|
443
|
+
? { kind: "version", version_id: embeddedVersion }
|
|
444
|
+
: null);
|
|
445
|
+
if (ifNoneMatch === null && ifMatch === null) {
|
|
446
|
+
writePreconditionRequired(res, "PUT requires If-Match: <version_id> (update) OR If-None-Match: * (create)");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (ifNoneMatch !== null && ifMatch !== null) {
|
|
450
|
+
writePreconditionRequired(res, "supply either If-Match or If-None-Match, not both");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (ifNoneMatch !== null) {
|
|
454
|
+
if (ifNoneMatch.kind !== "any") {
|
|
455
|
+
writePreconditionRequired(res, "If-None-Match must be '*' for create");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// Create: an omitted body means "empty document" — no prev to carry from.
|
|
459
|
+
const v = await kernel.docs.create(ctx, repoSlug, path, {
|
|
460
|
+
frontmatter: input.frontmatter,
|
|
461
|
+
frontmatter_raw: input.frontmatter_raw,
|
|
462
|
+
body: input.body ?? "",
|
|
463
|
+
});
|
|
464
|
+
writeJson(res, 201, v, { ETag: etagOf(v.version_id) });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// ifMatch !== null — update or move.
|
|
468
|
+
if (ifMatch === null || ifMatch.kind === "any") {
|
|
469
|
+
writePreconditionRequired(res, "the strict surface requires a specific version_id; If-Match: * is reserved for the WebDAV gateway (§11.1)");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// PUT: omitted body → kernel carries over from prev.
|
|
473
|
+
const putInput = {};
|
|
474
|
+
if (input.frontmatter !== undefined)
|
|
475
|
+
putInput.frontmatter = input.frontmatter;
|
|
476
|
+
if (input.frontmatter_raw !== undefined)
|
|
477
|
+
putInput.frontmatter_raw = input.frontmatter_raw;
|
|
478
|
+
if (input.body !== undefined)
|
|
479
|
+
putInput.body = input.body;
|
|
480
|
+
const v = await kernel.docs.put(ctx, repoSlug, ifMatch.version_id, path, putInput);
|
|
481
|
+
writeJson(res, 200, v, { ETag: etagOf(v.version_id) });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (method === "DELETE") {
|
|
485
|
+
const ifMatch = parseIfMatch(req.headers["if-match"]);
|
|
486
|
+
if (ifMatch === null || ifMatch.kind !== "version") {
|
|
487
|
+
writePreconditionRequired(res, "DELETE requires If-Match: <version_id>");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// The URL path is authoritative for DELETE — verify the If-Match version
|
|
491
|
+
// currently lives at (repo, path). If the doc has moved or been deleted
|
|
492
|
+
// since If-Match was observed, stale_prev with the actual current pointer.
|
|
493
|
+
// If nothing lives at path at all, kernel.docs.get raises doc_not_found (404).
|
|
494
|
+
const current = await kernel.docs.get(ctx, repoSlug, path);
|
|
495
|
+
if (current.version_id !== ifMatch.version_id) {
|
|
496
|
+
throw new KernelError("stale_prev", {
|
|
497
|
+
current_version_id: current.version_id,
|
|
498
|
+
current_path: current.path,
|
|
499
|
+
submitted_prev_version_id: ifMatch.version_id,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const v = await kernel.docs.delete(ctx, repoSlug, ifMatch.version_id);
|
|
503
|
+
writeJson(res, 200, v, { ETag: etagOf(v.version_id) });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (method === "MOVE") {
|
|
507
|
+
const ifMatch = parseIfMatch(req.headers["if-match"]);
|
|
508
|
+
if (ifMatch === null || ifMatch.kind !== "version") {
|
|
509
|
+
writePreconditionRequired(res, "MOVE requires If-Match: <version_id>");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const dest = parseDocDestination(req.headers.destination, repoSlug);
|
|
513
|
+
if (dest === null) {
|
|
514
|
+
throw new KernelError("path_invalid", {
|
|
515
|
+
reason: "Destination header required (same-repo)",
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
const v = await kernel.docs.put(ctx, repoSlug, ifMatch.version_id, dest, {});
|
|
519
|
+
writeJson(res, 200, v, { ETag: etagOf(v.version_id) });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
methodNotAllowed(res, method, ["GET", "PUT", "DELETE", "MOVE"]);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Common Version response: JSON envelope or markdown per `Accept`. Honors
|
|
526
|
+
* `If-None-Match` on the current version_id → 304 (§6.3 [OPEN] resolved
|
|
527
|
+
* by m3-plan decision 8).
|
|
528
|
+
*
|
|
529
|
+
* `mode` controls system-property injection into `frontmatter_raw` (and the
|
|
530
|
+
* corresponding markdown rendering). Default = inject `$version`; `raw`
|
|
531
|
+
* suppresses everything so callers see the exact stored bytes.
|
|
532
|
+
*/
|
|
533
|
+
function writeDocResponse(req, res, v, mode) {
|
|
534
|
+
const accept = chooseDocReadAccept(req.headers.accept);
|
|
535
|
+
const inm = parseIfNoneMatch(req.headers["if-none-match"]);
|
|
536
|
+
if (inm !== null && inm.kind === "version" && inm.version_id === v.version_id) {
|
|
537
|
+
writeEmpty(res, 304, { ETag: etagOf(v.version_id) });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// Inject $version then $content_hash in fixed order (sync/history plan §2.4).
|
|
541
|
+
const fmRaw = mode === "raw"
|
|
542
|
+
? v.frontmatter_raw
|
|
543
|
+
: appendSystemProperty(appendSystemProperty(v.frontmatter_raw, "version", v.version_id), "content_hash", v.content_hash);
|
|
544
|
+
if (accept === "markdown") {
|
|
545
|
+
writeMarkdown(res, 200, renderMarkdown(fmRaw, v.body), {
|
|
546
|
+
ETag: etagOf(v.version_id),
|
|
547
|
+
});
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
writeJson(res, 200, { ...v, frontmatter_raw: fmRaw }, { ETag: etagOf(v.version_id) });
|
|
551
|
+
}
|
|
552
|
+
function systemPropsMode(query) {
|
|
553
|
+
// `?raw` (bare) or `?raw=<anything except "0"/"false">` opts out of injection.
|
|
554
|
+
if (!query.has("raw"))
|
|
555
|
+
return "default";
|
|
556
|
+
const v = query.get("raw");
|
|
557
|
+
if (v === null || v === "")
|
|
558
|
+
return "raw";
|
|
559
|
+
if (v === "0" || v === "false")
|
|
560
|
+
return "default";
|
|
561
|
+
return "raw";
|
|
562
|
+
}
|
|
563
|
+
// -----------------------------------------------------------------------------
|
|
564
|
+
// /query
|
|
565
|
+
// -----------------------------------------------------------------------------
|
|
566
|
+
async function dispatchQuery(req, res, kernel, query, method, contextForRequest) {
|
|
567
|
+
const ctx = await contextForRequest(req);
|
|
568
|
+
let spec;
|
|
569
|
+
if (method === "GET") {
|
|
570
|
+
spec = specFromQueryString(query);
|
|
571
|
+
}
|
|
572
|
+
else if (method === "POST") {
|
|
573
|
+
const body = parseJsonBody(await readBody(req));
|
|
574
|
+
// POST /query accepts a `scope` field in the body — but the header wins
|
|
575
|
+
// when both are present (the shell sits closer to the credential than the
|
|
576
|
+
// body author does; noauth plan §4, decision 8). Validate the body claim
|
|
577
|
+
// structurally (same path as the header) so a repo-less claim is a loud
|
|
578
|
+
// filter_invalid, not a silent deny_all empty result.
|
|
579
|
+
if (body.scope !== undefined) {
|
|
580
|
+
if (ctx.scope === undefined) {
|
|
581
|
+
ctx.scope = validateScopeClaims(body.scope);
|
|
582
|
+
}
|
|
583
|
+
const { scope: _scope, ...rest } = body;
|
|
584
|
+
spec = coerceQuerySpec(rest);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
spec = coerceQuerySpec(body);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
return methodNotAllowed(res, method, ["GET", "POST"]);
|
|
592
|
+
}
|
|
593
|
+
const results = await kernel.query(ctx, spec);
|
|
594
|
+
const etag = queryEtag(results);
|
|
595
|
+
const inm = parseQueryIfNoneMatch(req.headers["if-none-match"]);
|
|
596
|
+
if (inm !== null && (inm.kind === "any" || inm.hash === etag)) {
|
|
597
|
+
writeEmpty(res, 304, { ETag: `"${etag}"` });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
writeJson(res, 200, results, { ETag: `"${etag}"` });
|
|
601
|
+
}
|
|
602
|
+
function parseQueryIfNoneMatch(headerValue) {
|
|
603
|
+
if (!headerValue)
|
|
604
|
+
return null;
|
|
605
|
+
const t = headerValue.trim();
|
|
606
|
+
if (t === "")
|
|
607
|
+
return null;
|
|
608
|
+
if (t.startsWith("W/"))
|
|
609
|
+
return null;
|
|
610
|
+
if (t === "*")
|
|
611
|
+
return { kind: "any" };
|
|
612
|
+
if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) {
|
|
613
|
+
return { kind: "hash", hash: t.slice(1, -1) };
|
|
614
|
+
}
|
|
615
|
+
return { kind: "hash", hash: t };
|
|
616
|
+
}
|
|
617
|
+
function readOptionalIntQueryParam(query, key) {
|
|
618
|
+
const raw = query.get(key);
|
|
619
|
+
if (raw === null)
|
|
620
|
+
return undefined;
|
|
621
|
+
if (!/^\d+$/.test(raw)) {
|
|
622
|
+
throw new KernelError("filter_invalid", {
|
|
623
|
+
reason: `query param ${key} must be a positive integer`,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
const n = Number.parseInt(raw, 10);
|
|
627
|
+
if (!Number.isSafeInteger(n)) {
|
|
628
|
+
throw new KernelError("filter_invalid", { reason: `query param ${key} out of range` });
|
|
629
|
+
}
|
|
630
|
+
return n;
|
|
631
|
+
}
|
|
632
|
+
function specFromQueryString(query) {
|
|
633
|
+
const spec = {};
|
|
634
|
+
const repos = query.getAll("repo");
|
|
635
|
+
if (repos.length > 0) {
|
|
636
|
+
// A single `repo=notes,team-*` is a comma-separated list; a repeated
|
|
637
|
+
// `repo=` param is also a list. Both flatten to string[].
|
|
638
|
+
const flat = [];
|
|
639
|
+
for (const r of repos)
|
|
640
|
+
for (const item of r.split(","))
|
|
641
|
+
if (item.length > 0)
|
|
642
|
+
flat.push(item);
|
|
643
|
+
spec.repo = flat.length === 1 ? flat[0] : flat;
|
|
644
|
+
}
|
|
645
|
+
const filter = query.get("filter");
|
|
646
|
+
if (filter !== null)
|
|
647
|
+
spec.filter = filter;
|
|
648
|
+
const text = query.get("text");
|
|
649
|
+
if (text !== null)
|
|
650
|
+
spec.text = text;
|
|
651
|
+
const semantic = query.get("semantic");
|
|
652
|
+
if (semantic !== null)
|
|
653
|
+
spec.semantic = semantic;
|
|
654
|
+
const limit = readOptionalIntQueryParam(query, "limit");
|
|
655
|
+
if (limit !== undefined)
|
|
656
|
+
spec.limit = limit;
|
|
657
|
+
if (query.get("include_hidden") === "true")
|
|
658
|
+
spec.include_hidden = true;
|
|
659
|
+
if (query.get("include_system") === "true")
|
|
660
|
+
spec.include_system = true;
|
|
661
|
+
// `select` is a repeated GET param (`?select=$path&select=title`); a single
|
|
662
|
+
// comma-separated value is also accepted, mirroring `repo`.
|
|
663
|
+
const selects = query.getAll("select");
|
|
664
|
+
if (selects.length > 0) {
|
|
665
|
+
const flat = [];
|
|
666
|
+
for (const s of selects)
|
|
667
|
+
for (const item of s.split(","))
|
|
668
|
+
if (item.length > 0)
|
|
669
|
+
flat.push(item);
|
|
670
|
+
spec.select = flat;
|
|
671
|
+
}
|
|
672
|
+
return spec;
|
|
673
|
+
}
|
|
674
|
+
function coerceQuerySpec(body) {
|
|
675
|
+
// Pass the body through verbatim — the kernel's runQuery does the shape
|
|
676
|
+
// validation. This keeps `filter_invalid` on unknown fields as the single
|
|
677
|
+
// source of truth (design §5.1).
|
|
678
|
+
return body;
|
|
679
|
+
}
|
|
680
|
+
// -----------------------------------------------------------------------------
|
|
681
|
+
// Destination header parsers (WebDAV RFC 4918 §10.3 shape)
|
|
682
|
+
// -----------------------------------------------------------------------------
|
|
683
|
+
function parseDestinationHeader(headerValue) {
|
|
684
|
+
if (headerValue === undefined)
|
|
685
|
+
return null;
|
|
686
|
+
const v = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
687
|
+
if (v === undefined)
|
|
688
|
+
return null;
|
|
689
|
+
// Absolute URL or absolute path — accept either.
|
|
690
|
+
try {
|
|
691
|
+
// If it parses as a URL, take the pathname.
|
|
692
|
+
if (v.startsWith("http://") || v.startsWith("https://")) {
|
|
693
|
+
const u = new URL(v);
|
|
694
|
+
return u.pathname;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
// fall through
|
|
699
|
+
}
|
|
700
|
+
if (v.startsWith("/"))
|
|
701
|
+
return v;
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
function parseDocDestination(headerValue, repoSlug) {
|
|
705
|
+
const path = parseDestinationHeader(headerValue);
|
|
706
|
+
if (path === null)
|
|
707
|
+
return null;
|
|
708
|
+
// Expect the form /repos/{repoSlug}/docs/{...path}
|
|
709
|
+
const decoded = path.replace(/^\//, "").split("/").map(decodeSegment);
|
|
710
|
+
if (decoded.length < 4)
|
|
711
|
+
return null;
|
|
712
|
+
if (decoded[0] !== "repos" || decoded[2] !== "docs")
|
|
713
|
+
return null;
|
|
714
|
+
if (decoded[1] !== repoSlug) {
|
|
715
|
+
// Cross-repo MOVE is rejected per §6.3.
|
|
716
|
+
throw new KernelError("path_invalid", {
|
|
717
|
+
reason: "cross-repo MOVE not supported (Destination must stay within source repo)",
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
return decoded.slice(3).join("/");
|
|
721
|
+
}
|
|
722
|
+
function parseRepoDestination(headerValue) {
|
|
723
|
+
const path = parseDestinationHeader(headerValue);
|
|
724
|
+
if (path === null)
|
|
725
|
+
return null;
|
|
726
|
+
const decoded = path.replace(/^\//, "").split("/").map(decodeSegment);
|
|
727
|
+
if (decoded.length !== 2 || decoded[0] !== "repos")
|
|
728
|
+
return null;
|
|
729
|
+
return decoded[1];
|
|
730
|
+
}
|
|
731
|
+
// -----------------------------------------------------------------------------
|
|
732
|
+
// Small dispatch helpers
|
|
733
|
+
// -----------------------------------------------------------------------------
|
|
734
|
+
// method_not_allowed and no_route are surface-only codes (not KernelErrorCodes)
|
|
735
|
+
// — they identify legitimate HTTP dispatch outcomes, not kernel or server
|
|
736
|
+
// bugs. Distinct from `internal_error` (500 fallback for uncaught throwables)
|
|
737
|
+
// so log aggregators can discriminate.
|
|
738
|
+
function methodNotAllowed(res, method, allowed) {
|
|
739
|
+
writeJson(res, 405, { code: "method_not_allowed", data: { method, allowed } }, { Allow: allowed.join(", ") });
|
|
740
|
+
}
|
|
741
|
+
function notFound(res) {
|
|
742
|
+
writeJson(res, 404, { code: "no_route", data: {} });
|
|
743
|
+
}
|
|
744
|
+
//# sourceMappingURL=routes.js.map
|