pi-weave 0.1.7 → 0.1.9

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.
Files changed (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,987 @@
1
+ /**
2
+ * Route handling for the workspace server (weave-workspace §5.3).
3
+ *
4
+ * | Method | Path | Response |
5
+ * | ------ | ------------------------ | -------------------------------------------- |
6
+ * | GET | `/` | the HTML shell, with its per-response nonce |
7
+ * | GET | `/app.js` | the committed bundle, `Cache-Control: no-store` |
8
+ * | GET | `/api/graph` | {@link GraphPayload}, ETag'd on `stamp` |
9
+ * | GET | `/api/note/:slug` | {@link NotePayload} — the note plus its revision |
10
+ * | POST | `/api/note/:slug` | update; {@link NotePayload} or a `409` (§5.3, P5) |
11
+ * | POST | `/api/note/:slug/rename` | rename; {@link NotePayload} or a `409` |
12
+ * | DELETE | `/api/note/:slug` | {@link DeleteNoteResult} — hard delete |
13
+ * | GET | `/api/okf/:rel` | {@link OkfFilePayload} |
14
+ * | GET | `/api/search?q=` | {@link SearchPayload} |
15
+ * | POST | `/api/open` | {@link OpenResult} — hand the note to `$EDITOR` |
16
+ * | GET | `/events` | the SSE stream (delegated to an injected hub) |
17
+ *
18
+ * ## The write routes (P5)
19
+ *
20
+ * Three of them, and every one goes through the same security gate as every
21
+ * read: {@link handleRequest} calls `security.authorize` **before** it routes,
22
+ * so there is no way to add a route that skips it. That matters more for
23
+ * writes than for reads, because §5.1's Origin rule is asymmetric — absent on
24
+ * a `GET` is fine (a browser omits it on same-origin navigation), absent on
25
+ * anything else is a `403`, and that asymmetry is the CSRF defence. It lives
26
+ * in `checkOrigin` rather than here precisely so a new write route inherits
27
+ * it rather than remembering it.
28
+ *
29
+ * `MutationResult` → status is the one mapping this file owns:
30
+ *
31
+ * | Core result | Status | Body |
32
+ * | --------------------- | -----: | ---- |
33
+ * | `ok` | `200` | {@link NotePayload}, re-read so the revision is the one just written |
34
+ * | `reason: "missing"` | `404` | {@link ErrorPayload} |
35
+ * | `reason: "conflict"` | `409` | {@link ConflictPayload} with the **current note and revision** |
36
+ * | `reason: "collision"` | `409` | {@link ConflictPayload} with the taken slug |
37
+ *
38
+ * `409` for both failures, with `reason` telling them apart in the body. They
39
+ * are the same *kind* of answer — "the vault is not in the state you thought
40
+ * it was" — and the client's response to each is a question for the user, so
41
+ * splitting them across two status codes would buy a distinction the HTTP
42
+ * layer has no use for while making the client branch twice.
43
+ *
44
+ * ## Self-write suppression (§6)
45
+ *
46
+ * Every one of these writes lands in the vault the watcher is watching, so
47
+ * without suppression a save is a change event, which is a broadcast, which
48
+ * makes the client refetch the note it just saved — and, worse, arrive
49
+ * mid-typing with a "the file changed" prompt about its own keystroke. The
50
+ * watcher exposes `suppress(absPath, ms)` for exactly this;
51
+ * {@link RouteDeps.suppress} is the injected form, called with the note's
52
+ * absolute path **before** the mutation runs, so the window is already open
53
+ * when the write hits the filesystem.
54
+ *
55
+ * ## Shape
56
+ *
57
+ * {@link handleRequest} takes {@link RouteDeps} — every capability it needs,
58
+ * injected — and a `ServerResponse`. It never constructs a cache, reads an
59
+ * environment variable, or knows what port it is on. That is what lets
60
+ * `tests/web/routes.test.ts` drive the real thing over a real socket with a
61
+ * temp vault, and what lets P1b's SSE hub arrive as a constructor argument
62
+ * rather than an import.
63
+ *
64
+ * ## Two things this file deliberately never does
65
+ *
66
+ * **No CORS headers, ever.** Not `Access-Control-Allow-Origin`, not even
67
+ * echoing our own origin, and no `OPTIONS` preflight handler. There is no
68
+ * legitimate cross-origin consumer of this server, and an
69
+ * `Access-Control-Allow-Origin` header is precisely the instruction that
70
+ * would tell a browser to hand a rebinding attacker's JavaScript the
71
+ * response body it otherwise could not read. A test asserts the absence.
72
+ *
73
+ * **No path resolution of its own.** `/api/okf/:rel` and `/api/note/:slug`
74
+ * carry untrusted path fragments straight from the URL. Both are handed to
75
+ * the existing core guards — `readOkfFileForView` anchors under `<cwd>/.okf`
76
+ * and `resolveNotePath` (via `getNoteWithRevision`) rejects anything that is not
77
+ * a flat slug. Re-implementing either check here would be a second
78
+ * implementation to keep in sync, which is how traversal bugs are actually
79
+ * born.
80
+ */
81
+
82
+ import { createHash } from "node:crypto";
83
+ import { readFile } from "node:fs/promises";
84
+ import type { IncomingMessage, ServerResponse } from "node:http";
85
+ import type { WorkspaceSnapshot } from "../../core/cache/workspace";
86
+ import { WorkspaceCache } from "../../core/cache/workspace";
87
+ import { readOkfFileForView } from "../../core/graph/current";
88
+ import type { GraphModel as CoreGraphModel } from "../../core/graph/model";
89
+ import { openNoteInEditor } from "../../core/openInEditor";
90
+ import type { MutationResult, RevisionedNote } from "../../core/vault";
91
+ import { slugify } from "../../core/slug";
92
+ import { deleteNote, getNoteWithRevision, renameNote, resolveNotePath, searchNotes, updateNote } from "../../core/vault";
93
+ import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
94
+ import type {
95
+ ChangeEvent,
96
+ ConflictPayload,
97
+ DeleteNoteResult,
98
+ GraphPayload,
99
+ NotePayload,
100
+ OkfFilePayload,
101
+ OpenResult,
102
+ RenameNoteRequest,
103
+ SaveNoteRequest,
104
+ SearchPayload,
105
+ ViewNote,
106
+ } from "../shared/wire";
107
+ import { WIRE_MODEL_OMITTED_KEYS } from "../shared/wire";
108
+ import { renderPage } from "./page";
109
+ import type { RequestFacts, SecurityPolicy } from "./security";
110
+ import { requestFacts } from "./security";
111
+
112
+ /**
113
+ * The SSE hub contract.
114
+ *
115
+ * Declared here, in the consumer, rather than in the implementation: this
116
+ * file is what needs the capability, and stating it here means the hub can
117
+ * be written, replaced or omitted without `routes.ts` changing. It is also
118
+ * the seam that lets the route tests boot a server with **no** hub and
119
+ * assert the `503`.
120
+ *
121
+ * Implemented by `src/web/server/sse.ts`.
122
+ */
123
+ export interface SseHub {
124
+ /** Adopt a request/response pair as a long-lived event stream. */
125
+ attach(req: IncomingMessage, res: ServerResponse): void;
126
+ /** Fan a change out to every attached client. */
127
+ broadcast(event: ChangeEvent): void;
128
+ /** Currently attached clients. Drives the idle-shutdown timer (§5.4). */
129
+ clientCount(): number;
130
+ /** End every stream and stop the heartbeat. Idempotent. */
131
+ close(): void;
132
+ }
133
+
134
+ /**
135
+ * The file watcher contract.
136
+ *
137
+ * Same reasoning as {@link SseHub} — the server owns the lifecycle, so it
138
+ * declares the shape it will start and stop, and P1b's `watcher.ts` supplies
139
+ * it. Deliberately minimal: the watcher's *output* reaches the world through
140
+ * the hub it was constructed with, not through a return value here.
141
+ *
142
+ * Implemented by `src/web/server/watcher.ts`.
143
+ */
144
+ export interface Watcher {
145
+ /** Begin watching. Resolves once the watches are established. */
146
+ start(): Promise<void>;
147
+ /** Stop watching and release every handle. Idempotent. */
148
+ close(): Promise<void>;
149
+ /**
150
+ * Ignore events for `absPath` briefly — the §6 self-write window.
151
+ *
152
+ * **Optional**, and that is a deliberate contract choice rather than
153
+ * timidity. This interface is the *lifecycle* one: start, close. A future
154
+ * watcher over a remote filesystem or a stamp poller may have no concept
155
+ * of "a path I am about to write", and making suppression mandatory would
156
+ * force it to implement a no-op to satisfy a contract it does not
157
+ * participate in. `server.ts` bridges it to {@link RouteDeps.suppress}
158
+ * when it is present, and writes simply happen unsuppressed when it is
159
+ * not — which is the correct degradation: one spurious refetch, never a
160
+ * lost edit.
161
+ */
162
+ suppress?(absPath: string): void;
163
+ }
164
+
165
+ /** Everything a route needs, injected. */
166
+ export interface RouteDeps {
167
+ cwd: string;
168
+ vaultRoot: string;
169
+ /** Random per-boot id, echoed into the page bootstrap. */
170
+ session: string;
171
+ cache: WorkspaceCache;
172
+ security: SecurityPolicy;
173
+ /** Absent → `/events` answers `503`. Wired by P1b. */
174
+ sse?: SseHub | undefined;
175
+ /** Absolute path of the committed bundle. Injectable for tests. */
176
+ bundlePath: string;
177
+ /** Test seam for `POST /api/open`; defaults to the real editor shell-out. */
178
+ openNote?: ((slug: string) => Promise<boolean>) | undefined;
179
+ /**
180
+ * Read a note plus its revision. Defaults to core's `getNoteWithRevision`.
181
+ *
182
+ * A seam for the same reason {@link RouteDeps.openNote} is one, and it
183
+ * earns its place on a specific branch: after a successful write this is
184
+ * called again to obtain the revision of the bytes now on disk, and it can
185
+ * legitimately return `null` — a `weave_note` delete or an `rm` in another
186
+ * terminal, landing in the window between the write and the re-read. That
187
+ * is a genuine race with a correct answer (`404`: the write happened, and
188
+ * the note is gone anyway), and it is unreachable from a test without
189
+ * being able to make the read fail on demand. The alternative was a
190
+ * coverage-ignore comment over a branch that really can fire in
191
+ * production, which is the wrong trade.
192
+ */
193
+ readNote?: ((slug: string) => Promise<RevisionedNote | null>) | undefined;
194
+ /** Called when an SSE client attaches or detaches — resets the idle timer. */
195
+ onActivity?: (() => void) | undefined;
196
+ /**
197
+ * Ignore filesystem events for `absPath` for a moment (§6).
198
+ *
199
+ * The watcher's `suppress`, injected. Absent in the route tests, which
200
+ * boot without a watcher — a write with nothing to suppress is a write,
201
+ * not an error, so this is optional rather than a required no-op the
202
+ * caller has to supply.
203
+ */
204
+ suppress?: ((absPath: string) => void) | undefined;
205
+ }
206
+
207
+ const JSON_TYPE = "application/json; charset=utf-8";
208
+ const TEXT_TYPE = "text/plain; charset=utf-8";
209
+
210
+ /** Headers every response carries, regardless of route. */
211
+ function baseHeaders(): Record<string, string> {
212
+ return {
213
+ // Belt to the CSP's braces: the shell declares `default-src 'none'`, but
214
+ // JSON responses have no CSP and a content-sniffing browser that decides
215
+ // an `/api/note` body is HTML would render attacker-controlled note text
216
+ // as markup on our origin.
217
+ "x-content-type-options": "nosniff",
218
+ "referrer-policy": "no-referrer",
219
+ };
220
+ }
221
+
222
+ function sendJson(res: ServerResponse, status: number, body: unknown, extra: Record<string, string> = {}): void {
223
+ res.writeHead(status, { ...baseHeaders(), "content-type": JSON_TYPE, ...extra });
224
+ res.end(JSON.stringify(body));
225
+ }
226
+
227
+ function sendText(res: ServerResponse, status: number, body: string): void {
228
+ res.writeHead(status, { ...baseHeaders(), "content-type": TEXT_TYPE });
229
+ res.end(body);
230
+ }
231
+
232
+ /**
233
+ * `403`, with a body that says nothing.
234
+ *
235
+ * The {@link SecurityPolicy} knows *which* layer refused, and that
236
+ * information stays in the server's own logs. Returning it would tell a
237
+ * prober whether they had cleared the Host allowlist and were now merely
238
+ * missing a token, which is the difference between a dead end and a
239
+ * roadmap.
240
+ */
241
+ function sendForbidden(res: ServerResponse): void {
242
+ sendText(res, 403, "forbidden\n");
243
+ }
244
+
245
+ /** Parsed request target: path plus query, both already decoded where safe. */
246
+ interface Target {
247
+ /** Percent-decoded pathname, or `null` when the encoding was malformed. */
248
+ path: string | null;
249
+ query: URLSearchParams;
250
+ }
251
+
252
+ /**
253
+ * Split a request target.
254
+ *
255
+ * `decodeURIComponent` throws on a lone `%` or an invalid escape — both
256
+ * trivially reachable from a hand-crafted request — so a malformed target
257
+ * becomes `path: null` and then a `404`, not a `500`.
258
+ *
259
+ * Decoding happens **before** any route match, which is the correct order:
260
+ * matching on the raw string and decoding afterwards is how `%2e%2e%2f`
261
+ * slips past a prefix check. The decoded fragment is then handed to a core
262
+ * guard that resolves and re-checks it, so there is no second decode later.
263
+ */
264
+ export function parseTarget(url: string): Target {
265
+ const raw = url.length > 0 ? url : "/";
266
+ const qmark = raw.indexOf("?");
267
+ const rawPath = qmark === -1 ? raw : raw.slice(0, qmark);
268
+ const query = new URLSearchParams(qmark === -1 ? "" : raw.slice(qmark + 1));
269
+ let path: string | null;
270
+ try {
271
+ path = decodeURIComponent(rawPath);
272
+ } catch {
273
+ path = null;
274
+ }
275
+ return { path, query };
276
+ }
277
+
278
+ /**
279
+ * Read a JSON request body, bounded.
280
+ *
281
+ * The cap is not about our own client — it is about a local process that can
282
+ * reach the port and, without one, could stream gigabytes into a pi session's
283
+ * heap. `null` on anything that is not a small, well-formed JSON object, so
284
+ * the caller has exactly one failure branch.
285
+ */
286
+ export const MAX_BODY_BYTES = 64 * 1024;
287
+
288
+ export async function readJsonBody(req: IncomingMessage): Promise<unknown | null> {
289
+ const chunks: Buffer[] = [];
290
+ let size = 0;
291
+ // `IncomingMessage` yields Buffers unless `setEncoding` was called, and we
292
+ // never call it — so no string branch, which would be untestable dead code
293
+ // pretending to be defensive.
294
+ for await (const chunk of req as AsyncIterable<Buffer>) {
295
+ size += chunk.length;
296
+ if (size > MAX_BODY_BYTES) return null;
297
+ chunks.push(chunk);
298
+ }
299
+ if (chunks.length === 0) return null;
300
+ try {
301
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Build the wire payload from a freshly-built model.
309
+ *
310
+ * Takes the **core** `GraphModel`, not the wire one: the derived indexes it
311
+ * hoists to the payload's top level (`tags`, `dangling`) are read off the
312
+ * core shape, and the wire model is what comes *out* of here.
313
+ *
314
+ * `notes` is what the graph was built from. It is a separate argument because
315
+ * the graph deliberately does not carry structured tags — `detail.tags` is a
316
+ * comma-joined display string and re-parsing it here would be exactly the
317
+ * "grow structure inside `detail`" move §4.2/§4.3 rule out. The caller
318
+ * already holds the notes (the cache read them to build the model), so this
319
+ * costs nothing. Omitted → `tags: {}`, which is what a caller that genuinely
320
+ * has no note list should ship.
321
+ */
322
+ export function toGraphPayload(model: CoreGraphModel, notes: readonly TaggedNote[] = []): GraphPayload {
323
+ const payload: GraphPayload = {
324
+ model: toWireModel(model),
325
+ // tag → slugs (§4.3). `deriveTagIndex` returns an ordered array because
326
+ // order is meaningful (count desc, tag asc); the wire field is a record,
327
+ // so that ordering survives only as JSON key insertion order. Good enough
328
+ // deliberately: a client that wants the ranking re-derives it from the
329
+ // array lengths, and the alternative — putting an array on the wire under
330
+ // a field the contract calls `Record<string, string[]>` — would be a
331
+ // breaking change to a settled shape for a fact the client can compute.
332
+ tags: Object.fromEntries(deriveTagIndex(notes).map((t) => [t.tag, t.slugs])),
333
+ // slug → unresolved wikilink targets (§4.2), carried on the model since
334
+ // the builder stopped discarding the names.
335
+ dangling: model.danglingLinks,
336
+ // Still `null`, and deliberately: server-side layout needs
337
+ // `src/web/shared/layout`, which imports d3-force, and the server tier's
338
+ // npm allowlist is empty (§9: the published package has zero runtime
339
+ // dependencies). The client runs the identical `shared/layout` code
340
+ // itself, so this is a division of labour rather than a gap.
341
+ positions: null,
342
+ // Filled in below: the digest covers every field above, so it cannot be
343
+ // computed until they exist. `""` is never observable — no caller sees
344
+ // the payload before {@link stampPayload} replaces it.
345
+ stamp: "",
346
+ };
347
+ return stampPayload(payload);
348
+ }
349
+
350
+ /**
351
+ * Serialize a payload and stamp it with the digest of its own bytes
352
+ * (weave-workspace §5.3, §15.6).
353
+ *
354
+ * ## Why a digest and not `generatedAt`
355
+ *
356
+ * `stamp` used to be `model.generatedAt`, the **max of the input
357
+ * timestamps**. That made it blind to any change that does not move the
358
+ * maximum, and three of those are reachable: editing a note's body or its
359
+ * front-matter tags without bumping `updated`, and deleting a note that is
360
+ * not the newest. In each the payload differs and the old stamp did not, so a
361
+ * conditional GET answered `304` and the client kept stale data — and, worse,
362
+ * the SSE dedupe (which shares this key) discarded the frame that would have
363
+ * prompted a refetch. A digest changes if and only if the bytes change, which
364
+ * is the property both consumers actually need.
365
+ *
366
+ * The digest is exact, but the payload is only an *excerpt* of the workspace:
367
+ * a note body below `PREVIEW_LEN` does not appear in the payload at all, so
368
+ * a body-only edit used to reproduce byte-identical payload and re-open the
369
+ * same hole through both dedupe layers. `GraphModel.contentDigest` — hashed
370
+ * over the note bodies themselves — closes that; see `core/graph/model.ts`.
371
+ *
372
+ * `generatedAt` keeps its own, different job: it is the human-facing
373
+ * "data as of" marker on the model, and the status bar still reads it.
374
+ *
375
+ * ## Why hash the serialized form rather than the object
376
+ *
377
+ * The digest is taken over the exact `JSON.stringify` output that is then
378
+ * written to the socket, so "the digest changes iff the served bytes change"
379
+ * is true by construction rather than by an argument about which fields were
380
+ * fed to the hash. It also makes the ETag **strong**: a strong validator
381
+ * asserts byte-for-byte equality of the representation, and that is precisely
382
+ * what was compared.
383
+ *
384
+ * ## Determinism
385
+ *
386
+ * No canonicalisation step, because the inputs are already canonical and a
387
+ * second ordering pass would be a second thing to keep correct.
388
+ * `buildGraph` is documented byte-deterministic (ids derive from slugs and
389
+ * paths; nothing reads the wall clock), `deriveTagIndex` emits count-desc /
390
+ * tag-asc with **codepoint** tiebreaks precisely so it does not vary with the
391
+ * host locale, and `danglingLinks` is populated in the builder's note order,
392
+ * which is `updated` descending with a slug tiebreak — itself a function of
393
+ * payload content. Two independent builds of identical input therefore
394
+ * produce identical bytes; `tests/web/routes.test.ts` pins that directly.
395
+ */
396
+ export function stampPayload(payload: GraphPayload): GraphPayload {
397
+ // Hashed with `stamp: ""` in place, so the digest is a function of the
398
+ // *content* fields only. Hashing a payload that already carried a stamp
399
+ // would fold the previous digest into the new one and make the value
400
+ // depend on how many times it had been stamped.
401
+ const stamp = digestOf(JSON.stringify({ ...payload, stamp: "" }));
402
+ return { ...payload, stamp };
403
+ }
404
+
405
+ /**
406
+ * The digest function. SHA-256, truncated to 128 bits and hex-encoded.
407
+ *
408
+ * Truncation is safe here and worth the 32 bytes it saves on every ETag
409
+ * header and every SSE frame: at 128 bits an accidental collision between two
410
+ * payloads is far below the probability of the cache being wrong for any
411
+ * other reason. This is a cache validator, not a security boundary — nobody
412
+ * is choosing our note contents to force a collision, and if they could, they
413
+ * could simply edit the note.
414
+ */
415
+ function digestOf(serialized: string): string {
416
+ return createHash("sha256").update(serialized).digest("hex").slice(0, 32);
417
+ }
418
+
419
+ /**
420
+ * Drop the core-only fields from the model before it goes on the wire.
421
+ *
422
+ * `WireGraphModel` is deliberately narrower than core's `GraphModel`
423
+ * (`src/web/shared/graph.ts`): `danglingLinks` is republished as
424
+ * `GraphPayload.dangling` and must not also ride along inside `model`.
425
+ * TypeScript's structural typing will happily assign the wider object to the
426
+ * narrower type and then serialize every key it actually has, so the
427
+ * narrowing has to be enforced at runtime — here, once, at the only place
428
+ * that builds a payload.
429
+ *
430
+ * The key list comes from `WIRE_MODEL_OMITTED_KEYS` rather than being
431
+ * repeated inline, so a second omission cannot be declared in `shared/` and
432
+ * forgotten in `server/`.
433
+ */
434
+ function toWireModel(model: CoreGraphModel): GraphPayload["model"] {
435
+ const copy: Record<string, unknown> = { ...model };
436
+ for (const key of WIRE_MODEL_OMITTED_KEYS) delete copy[key];
437
+ return copy as unknown as GraphPayload["model"];
438
+ }
439
+
440
+
441
+ /**
442
+ * The security gate, then the route.
443
+ *
444
+ * Split from {@link route} so the ordering is visible in one screen: no
445
+ * handler below runs until the request has cleared all four layers. The one
446
+ * exception is the handoff, which is answered here because it is a
447
+ * *security* response — a `302` that exists only to move a token out of the
448
+ * URL — and not a route anyone can address.
449
+ */
450
+ export async function handleRequest(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
451
+ const facts = requestFacts(req);
452
+ const decision = deps.security.authorize(facts);
453
+
454
+ if (decision.kind === "deny") {
455
+ sendForbidden(res);
456
+ return;
457
+ }
458
+ if (decision.kind === "handoff") {
459
+ res.writeHead(302, {
460
+ ...baseHeaders(),
461
+ "set-cookie": decision.setCookie,
462
+ location: decision.location,
463
+ // The handoff URL contains the token. Caching it anywhere — including
464
+ // the browser's own back/forward cache — re-materialises the thing the
465
+ // redirect exists to erase.
466
+ "cache-control": "no-store",
467
+ });
468
+ res.end();
469
+ return;
470
+ }
471
+
472
+ try {
473
+ await route(deps, facts, req, res);
474
+ } catch (err) {
475
+ // A handler that threw after writing headers cannot be rescued; ending
476
+ // the socket is all that is left, and leaving it open would hang the
477
+ // browser tab on a request that is never coming back.
478
+ if (res.headersSent) {
479
+ res.end();
480
+ return;
481
+ }
482
+ sendText(res, 500, `pi-weave: ${err instanceof Error ? err.message : String(err)}\n`);
483
+ }
484
+ }
485
+
486
+ async function route(
487
+ deps: RouteDeps,
488
+ facts: RequestFacts,
489
+ req: IncomingMessage,
490
+ res: ServerResponse,
491
+ ): Promise<void> {
492
+ const { path, query } = parseTarget(facts.url);
493
+ if (path === null) {
494
+ sendText(res, 404, "not found\n");
495
+ return;
496
+ }
497
+ const method = facts.method;
498
+
499
+ if (method === "GET" && path === "/") return sendShell(deps, res);
500
+ if (method === "GET" && path === "/app.js") return sendBundle(deps, res);
501
+ if (method === "GET" && path === "/api/graph") return sendGraph(deps, req, res);
502
+ if (path.startsWith("/api/note/")) {
503
+ const handled = await routeNote(deps, method, path.slice("/api/note/".length), req, res);
504
+ if (handled) return;
505
+ }
506
+ if (method === "GET" && path.startsWith("/api/okf/")) {
507
+ return sendOkf(deps, path.slice("/api/okf/".length), res);
508
+ }
509
+ if (method === "GET" && path === "/api/search") return sendSearch(deps, query, res);
510
+ if (method === "POST" && path === "/api/open") return openNote(deps, req, res);
511
+ if (method === "GET" && path === "/events") return attachSse(deps, req, res);
512
+
513
+ sendText(res, 404, "not found\n");
514
+ }
515
+
516
+ // --- handlers ----------------------------------------------------------------
517
+
518
+ function sendShell(deps: RouteDeps, res: ServerResponse): void {
519
+ const page = renderPage({
520
+ bootstrap: { cwd: deps.cwd, vaultRoot: deps.vaultRoot, session: deps.session },
521
+ });
522
+ res.writeHead(200, {
523
+ ...baseHeaders(),
524
+ "content-type": "text/html; charset=utf-8",
525
+ "content-security-policy": page.csp,
526
+ // The nonce is per-response, so a cached copy would be served with a
527
+ // nonce the CSP header no longer matches and the page would silently
528
+ // refuse to run its own script.
529
+ "cache-control": "no-store",
530
+ });
531
+ res.end(page.html);
532
+ }
533
+
534
+ async function sendBundle(deps: RouteDeps, res: ServerResponse): Promise<void> {
535
+ let source: Buffer;
536
+ try {
537
+ source = await readFile(deps.bundlePath);
538
+ } catch {
539
+ // A missing bundle means someone is running from a checkout that has not
540
+ // been built. Say so in the body — this one is a developer error, not a
541
+ // prober, and it is reachable only after authentication.
542
+ sendText(res, 404, "pi-weave: web bundle missing; run `npm run build:web`\n");
543
+ return;
544
+ }
545
+ res.writeHead(200, {
546
+ ...baseHeaders(),
547
+ "content-type": "text/javascript; charset=utf-8",
548
+ // §5.3. The artifact changes on rebuild and the server may outlive one.
549
+ "cache-control": "no-store",
550
+ });
551
+ res.end(source);
552
+ }
553
+
554
+ /**
555
+ * Serialized payload + ETag, memoized per snapshot **identity**
556
+ * (weave-workspace §4.1).
557
+ *
558
+ * `WorkspaceCache` returns the *identical* snapshot object while nothing on
559
+ * disk has moved, so this map turns a warm `/api/graph` into a pure lookup:
560
+ * no `toGraphPayload`, no `JSON.stringify`, and — the point of §15.6's cost
561
+ * requirement — **no hashing at all**. The first request after a real change
562
+ * gets a new snapshot object, misses, and pays once for the whole build.
563
+ *
564
+ * A `WeakMap` rather than a one-slot cache so that a request racing a rebuild
565
+ * cannot evict the entry the other request is about to read, and so entries
566
+ * for superseded snapshots are collected with them. The key is the snapshot
567
+ * rather than the model because the payload depends on the notes too (§4.3).
568
+ */
569
+ const renderedGraphs = new WeakMap<WorkspaceSnapshot, { body: string; etag: string }>();
570
+
571
+ /**
572
+ * The stamp `/api/graph` would serve for this snapshot.
573
+ *
574
+ * Exported so the SSE liveness bridge broadcasts the **same** key the ETag
575
+ * carries (§6). Two derivations of "the current stamp" would be two things to
576
+ * keep in sync, and the failure mode is silent: frames that never match the
577
+ * validator the client then sends. It shares {@link renderGraph}'s memo, so
578
+ * asking for the stamp after the route has rendered the same snapshot — the
579
+ * common case, since a change triggers both — costs nothing.
580
+ */
581
+ export function graphStamp(snapshot: WorkspaceSnapshot): string {
582
+ // The memo stores the quoted ETag; the frame wants the bare digest.
583
+ return renderGraph(snapshot).etag.slice(1, -1);
584
+ }
585
+
586
+ /** Serialize + digest a snapshot, or return the memoized rendering. */
587
+ function renderGraph(snapshot: WorkspaceSnapshot): { body: string; etag: string } {
588
+ const hit = renderedGraphs.get(snapshot);
589
+ if (hit !== undefined) return hit;
590
+ const payload = toGraphPayload(snapshot.model, snapshot.notes);
591
+ // Serialized once and kept: this exact string is what the digest was taken
592
+ // over, so writing anything else would make the strong ETag a lie.
593
+ const rendered = { body: JSON.stringify(payload), etag: `"${payload.stamp}"` };
594
+ renderedGraphs.set(snapshot, rendered);
595
+ return rendered;
596
+ }
597
+
598
+ async function sendGraph(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
599
+ // `snapshot()`, not `graph()`: the tag index has to be derived from the
600
+ // same (already capped) note list the model was built from, or a tag could
601
+ // name a slug this graph has no node for (§4.3).
602
+ const snapshot = await deps.cache.snapshot();
603
+ const { body, etag } = renderGraph(snapshot);
604
+
605
+ // The ETag is a digest of the serialized payload (§15.6, resolved), so it
606
+ // moves if and only if the bytes the client would receive have moved. The
607
+ // three cases the old timestamp stamp missed — a body edit, a tag edit, and
608
+ // the deletion of a non-newest note, none of which advance `generatedAt` —
609
+ // all change it.
610
+ const conditional = req.headers["if-none-match"];
611
+ if (matchesEtag(etag, typeof conditional === "string" ? conditional : undefined)) {
612
+ res.writeHead(304, { ...baseHeaders(), etag, "cache-control": "no-cache" });
613
+ res.end();
614
+ return;
615
+ }
616
+ // The memoized string, not a re-serialization: the digest was taken over
617
+ // these exact bytes, which is what entitles the ETag to be strong.
618
+ //
619
+ // `no-cache`, not `no-store`: the client *should* keep the body and
620
+ // revalidate, which is the entire point of the ETag.
621
+ res.writeHead(200, {
622
+ ...baseHeaders(),
623
+ "content-type": JSON_TYPE,
624
+ etag,
625
+ "cache-control": "no-cache",
626
+ });
627
+ res.end(body);
628
+ }
629
+
630
+ /**
631
+ * `If-None-Match` matching, weak-comparison style.
632
+ *
633
+ * A `*` matches anything with a representation, and a list is comma-
634
+ * separated.
635
+ *
636
+ * The validator we *emit* is strong (no `W/` prefix), and honestly so: it is
637
+ * a digest of the exact bytes sent, so equal ETag really does mean equal
638
+ * representation rather than merely equivalent. What we *accept* is
639
+ * deliberately more forgiving — `W/` is stripped from incoming candidates
640
+ * because RFC 9110 requires weak comparison for `If-None-Match` anyway, and
641
+ * an intermediary that weakened our tag in transit should still get its
642
+ * `304` rather than a pointless full body.
643
+ */
644
+ function matchesEtag(etag: string, header: string | undefined): boolean {
645
+ if (header === undefined) return false;
646
+ const want = normalizeEtag(etag);
647
+ return header.split(",").some((candidate) => {
648
+ const trimmed = candidate.trim();
649
+ return trimmed === "*" || normalizeEtag(trimmed) === want;
650
+ });
651
+ }
652
+
653
+ function normalizeEtag(value: string): string {
654
+ return value.startsWith("W/") ? value.slice(2) : value;
655
+ }
656
+
657
+ /**
658
+ * Everything under `/api/note/`, in one place.
659
+ *
660
+ * Returns `false` for a method/shape this family does not serve, so the
661
+ * caller falls through to its own `404` rather than this function owning a
662
+ * second copy of the not-found response. That is also what keeps
663
+ * `DELETE /api/graph` and `PUT /api/note/x` answering the same `404` as any
664
+ * other unrouted request: the family claims a request or it does not.
665
+ *
666
+ * The `rest` after the slug is matched **exactly**, not by prefix. Slugs may
667
+ * nest (`sessions/foo` — session memory lives in a vault subdirectory), so
668
+ * the family has exactly one sub-resource path (`/rename`) and everything
669
+ * else after a `/` belongs to the slug itself; anything else a caller
670
+ * appends is a request for nothing and gets the `404` it deserves. Core's
671
+ * `resolveNotePath` remains the traversal guard for whatever slug arrives.
672
+ */
673
+ const renameSuffix = "/rename";
674
+
675
+ async function routeNote(
676
+ deps: RouteDeps,
677
+ method: string,
678
+ target: string,
679
+ req: IncomingMessage,
680
+ res: ServerResponse,
681
+ ): Promise<boolean> {
682
+ // One sub-resource, matched at the end: a nested slug can itself contain
683
+ // slashes, so the split is anchored at the end, not the first slash.
684
+ const isRename = target.endsWith(renameSuffix);
685
+ const slug = isRename ? target.slice(0, target.length - renameSuffix.length) : target;
686
+ const rest = isRename ? renameSuffix : "";
687
+
688
+ if (rest === "" && method === "GET") {
689
+ await sendNote(deps, slug, res);
690
+ return true;
691
+ }
692
+ if (rest === "" && method === "POST") {
693
+ await saveNote(deps, slug, req, res);
694
+ return true;
695
+ }
696
+ if (rest === "" && method === "DELETE") {
697
+ await removeNote(deps, slug, res);
698
+ return true;
699
+ }
700
+ if (rest === "/rename" && method === "POST") {
701
+ await moveNote(deps, slug, req, res);
702
+ return true;
703
+ }
704
+ return false;
705
+ }
706
+
707
+ async function sendNote(deps: RouteDeps, rawSlug: string, res: ServerResponse): Promise<void> {
708
+ // Traversal is `resolveNotePath`'s job, inside `getNoteWithRevision`: an
709
+ // unsafe slug returns null before anything touches the disk. `%2e%2e%2f`
710
+ // was already decoded by `parseTarget`, so what arrives here is the literal
711
+ // `../` the guard is written to reject.
712
+ //
713
+ // `getNoteWithRevision` rather than `readNoteForView`, because the editor
714
+ // cannot save safely without the revision it read at load, and fetching it
715
+ // separately would leave a window in which the two disagree. It stats
716
+ // before it reads, so a writer landing between the two calls yields a
717
+ // revision *older* than the content — the save that follows is refused
718
+ // rather than silently accepted (see core's own note on the ordering).
719
+ const current = await readNote(deps, rawSlug);
720
+ if (current === null) {
721
+ sendJson(res, 404, { error: "no such note" });
722
+ return;
723
+ }
724
+ sendJson(res, 200, notePayload(current), { "cache-control": "no-store" });
725
+ }
726
+
727
+ /** {@link RouteDeps.readNote}, or core's. One resolution, used by both routes. */
728
+ function readNote(deps: RouteDeps, slug: string): Promise<RevisionedNote | null> {
729
+ const read = deps.readNote ?? ((s: string) => getNoteWithRevision(deps.vaultRoot, s));
730
+ return read(slug);
731
+ }
732
+
733
+ /**
734
+ * Core's `RevisionedNote` → the wire's {@link NotePayload}.
735
+ *
736
+ * The projection is `readNoteForView`'s, restated: a `Note` carries `body`,
737
+ * the five managed fields **and** `frontMatter`, the verbatim block P5a added
738
+ * so unknown keys survive a write. `frontMatter` must not cross the wire.
739
+ * Not because it is secret, but because shipping it would invite a client to
740
+ * send it back, and the moment a browser round-trips a user's raw metadata
741
+ * through JSON the preservation guarantee stops being "the write path re-reads
742
+ * the file" and becomes "the client remembered to return the block unedited".
743
+ * The first is enforced by core; the second is a hope.
744
+ *
745
+ * So the field is dropped **explicitly**, by naming what is kept. A spread
746
+ * with a `delete` would be exempt from the excess-property check and would
747
+ * ship the block the day someone adds a sixth field — the same reasoning
748
+ * `summarizeNote` gives for dropping it there.
749
+ */
750
+ function notePayload(current: RevisionedNote): NotePayload {
751
+ const { note } = current;
752
+ const view: ViewNote = {
753
+ slug: note.slug,
754
+ title: note.title,
755
+ body: note.body,
756
+ created: note.created,
757
+ updated: note.updated,
758
+ tags: note.tags,
759
+ source: note.source,
760
+ };
761
+ return { note: view, revision: current.revision };
762
+ }
763
+
764
+ /**
765
+ * Answer a core {@link MutationResult}.
766
+ *
767
+ * The whole status mapping, in one function shared by both write routes, so
768
+ * "a conflict is a 409" is a fact about this server rather than about
769
+ * whichever handler was written most recently.
770
+ *
771
+ * A success re-reads the note through {@link getNoteWithRevision} rather than
772
+ * returning the `Note` core handed back. Core's value is correct about
773
+ * *content* and says nothing about *revision*, and the client's next save
774
+ * needs a revision that matches the bytes now on disk — deriving one from the
775
+ * write we just performed would mean re-implementing `revisionOf` out here,
776
+ * against a stat this function does not have. The re-read costs one `stat`
777
+ * plus one `readFile` on a file that is certainly in the page cache, and it
778
+ * is the only way to hand back a revision that is true rather than inferred.
779
+ *
780
+ * The `null` branch is not dead code being defensive: between the write and
781
+ * the re-read, a `weave_note` delete or an `rm` in another terminal can
782
+ * genuinely remove the file. `404` is then the honest answer — the write did
783
+ * happen, and the note is gone anyway.
784
+ */
785
+ async function sendMutation(deps: RouteDeps, result: MutationResult, res: ServerResponse): Promise<void> {
786
+ if (!result.ok) {
787
+ sendMutationFailure(result, res);
788
+ return;
789
+ }
790
+ const written = await readNote(deps, result.note.slug);
791
+ if (written === null) {
792
+ sendJson(res, 404, { error: "no such note" });
793
+ return;
794
+ }
795
+ sendJson(res, 200, notePayload(written), { "cache-control": "no-store" });
796
+ }
797
+
798
+ function sendMutationFailure(failure: Extract<MutationResult, { ok: false }>, res: ServerResponse): void {
799
+ if (failure.reason === "missing") {
800
+ sendJson(res, 404, { error: "no such note" });
801
+ return;
802
+ }
803
+ const payload: ConflictPayload =
804
+ failure.reason === "conflict"
805
+ ? {
806
+ error: "the note changed on disk since it was read",
807
+ reason: "conflict",
808
+ // The whole note, not just its revision (§11 P5.3). This is what
809
+ // lets the client offer reload-or-overwrite without a second round
810
+ // trip — and the second round trip is another window in which the
811
+ // file moves again, which would make the prompt itself stale.
812
+ current: notePayload(failure.current),
813
+ }
814
+ : { error: "a note with that slug already exists", reason: "collision", slug: failure.slug };
815
+ sendJson(res, 409, payload, { "cache-control": "no-store" });
816
+ }
817
+
818
+ /**
819
+ * Open the watcher's self-write window for a slug, if there is a watcher.
820
+ *
821
+ * Called **before** the mutation, never after: `fs.watch` can deliver an
822
+ * event while the write syscall is still returning, and a window opened
823
+ * afterwards is a window that opens second. Suppressing a path the write then
824
+ * fails to touch costs nothing — the entry expires on its own.
825
+ *
826
+ * An unsafe slug resolves to `null` and is skipped rather than suppressed;
827
+ * the mutation is about to refuse it anyway, and suppressing a path we could
828
+ * not resolve would mean either fabricating one or passing `null` down to a
829
+ * watcher that would `resolve()` it into the process's cwd.
830
+ */
831
+ function suppressSlug(deps: RouteDeps, slug: string): void {
832
+ const path = resolveNotePath(deps.vaultRoot, slug);
833
+ if (path !== null) deps.suppress?.(path);
834
+ }
835
+
836
+ async function saveNote(deps: RouteDeps, slug: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
837
+ const body = await readJsonBody(req);
838
+ const input = parseSaveRequest(body);
839
+ if (input === null) {
840
+ sendJson(res, 400, { error: "expected { body?: string, meta?: object, expectedRevision?: string }" });
841
+ return;
842
+ }
843
+ suppressSlug(deps, slug);
844
+ await sendMutation(deps, await updateNote(deps.vaultRoot, slug, input), res);
845
+ }
846
+
847
+ async function moveNote(deps: RouteDeps, slug: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
848
+ const body = await readJsonBody(req);
849
+ const target = typeof body === "object" && body !== null ? (body as Partial<RenameNoteRequest>).slug : undefined;
850
+ if (typeof target !== "string" || target.length === 0) {
851
+ sendJson(res, 400, { error: "expected { slug: string }" });
852
+ return;
853
+ }
854
+ // Both ends: the file disappears from one path and appears at another, and
855
+ // the watcher sees two events. Suppressing only the source would broadcast
856
+ // the arrival, which is the same feedback loop with an extra step.
857
+ //
858
+ // `slugify` on the destination, because that is what `renameNote` will
859
+ // apply before it touches the disk. Suppressing the *requested* string
860
+ // would open the window over `notes/Alpha Renamed.md` while the write went
861
+ // to `notes/alpha-renamed.md` — a suppression that is present, plausible
862
+ // and useless, which is worse than an absent one.
863
+ suppressSlug(deps, slug);
864
+ suppressSlug(deps, slugify(target));
865
+ await sendMutation(deps, await renameNote(deps.vaultRoot, slug, target), res);
866
+ }
867
+
868
+ async function removeNote(deps: RouteDeps, slug: string, res: ServerResponse): Promise<void> {
869
+ suppressSlug(deps, slug);
870
+ const result = await deleteNote(deps.vaultRoot, slug);
871
+ if (!result.ok) {
872
+ sendJson(res, 404, { error: "no such note" });
873
+ return;
874
+ }
875
+ const payload: DeleteNoteResult = { deleted: true };
876
+ sendJson(res, 200, payload, { "cache-control": "no-store" });
877
+ }
878
+
879
+ /**
880
+ * Narrow a decoded request body to core's `UpdateNoteInput`, or `null`.
881
+ *
882
+ * An **allowlist**, field by field, and that is the point rather than
883
+ * ceremony. `updateNote` spreads `input.meta` over the note's metadata, so
884
+ * anything that reaches it reaches the front matter: passing the parsed body
885
+ * straight through would let a local process `POST {"meta":{"created":"…"}}`
886
+ * and rewrite a field the API deliberately does not expose, or
887
+ * `{"meta":{"updated":"1970-…"}}` and make an edit look older than the state
888
+ * it overwrote. Only `title`, `tags` and `source` are copied, and `source`
889
+ * only when it is one of the three legal values — a note claiming
890
+ * `source: "verified"` would render with a provenance badge nothing in the
891
+ * palette matches.
892
+ *
893
+ * `null` for a body that is not an object, so the caller has exactly one
894
+ * failure branch. An **empty** object is valid: a save with no fields bumps
895
+ * `updated`, which is a meaningful (if unusual) request and not worth a
896
+ * special case.
897
+ */
898
+ export function parseSaveRequest(value: unknown): SaveNoteRequest | null {
899
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
900
+ const raw = value as Record<string, unknown>;
901
+ const out: SaveNoteRequest = {};
902
+
903
+ if (raw["body"] !== undefined) {
904
+ if (typeof raw["body"] !== "string") return null;
905
+ out.body = raw["body"];
906
+ }
907
+ if (raw["expectedRevision"] !== undefined) {
908
+ if (typeof raw["expectedRevision"] !== "string") return null;
909
+ out.expectedRevision = raw["expectedRevision"];
910
+ }
911
+ if (raw["meta"] !== undefined) {
912
+ const meta = parseSaveMeta(raw["meta"]);
913
+ if (meta === null) return null;
914
+ out.meta = meta;
915
+ }
916
+ return out;
917
+ }
918
+
919
+ /** The three metadata fields a client may set. See {@link parseSaveRequest}. */
920
+ function parseSaveMeta(value: unknown): NonNullable<SaveNoteRequest["meta"]> | null {
921
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
922
+ const raw = value as Record<string, unknown>;
923
+ const out: NonNullable<SaveNoteRequest["meta"]> = {};
924
+
925
+ if (raw["title"] !== undefined) {
926
+ if (typeof raw["title"] !== "string") return null;
927
+ out.title = raw["title"];
928
+ }
929
+ if (raw["tags"] !== undefined) {
930
+ const tags = raw["tags"];
931
+ if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === "string")) return null;
932
+ out.tags = tags as string[];
933
+ }
934
+ if (raw["source"] !== undefined) {
935
+ const source = raw["source"];
936
+ if (source !== "human" && source !== "agent" && source !== "generated") return null;
937
+ out.source = source;
938
+ }
939
+ return out;
940
+ }
941
+
942
+ async function sendOkf(deps: RouteDeps, rel: string, res: ServerResponse): Promise<void> {
943
+ // Anchored under `<cwd>/.okf` by `readOkfFileForView`, which resolves and
944
+ // then re-checks the prefix — the only correct order.
945
+ const file = await readOkfFileForView(deps.cwd, rel);
946
+ if (file === null) {
947
+ sendJson(res, 404, { error: "no such okf file" });
948
+ return;
949
+ }
950
+ const payload: OkfFilePayload = file;
951
+ sendJson(res, 200, payload, { "cache-control": "no-store" });
952
+ }
953
+
954
+ async function sendSearch(deps: RouteDeps, query: URLSearchParams, res: ServerResponse): Promise<void> {
955
+ const q = query.get("q") ?? "";
956
+ // `searchNotes` already returns `[]` for an empty query, so a missing `q`
957
+ // is an empty result rather than a `400`. The search box sends one on
958
+ // every keystroke, including the one that clears it.
959
+ const payload: SearchPayload = { query: q, hits: await searchNotes(deps.vaultRoot, q) };
960
+ sendJson(res, 200, payload, { "cache-control": "no-store" });
961
+ }
962
+
963
+ async function openNote(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
964
+ const body = await readJsonBody(req);
965
+ const slug = typeof body === "object" && body !== null ? (body as { slug?: unknown }).slug : undefined;
966
+ if (typeof slug !== "string") {
967
+ sendJson(res, 400, { error: "expected { slug: string }" });
968
+ return;
969
+ }
970
+ const open = deps.openNote ?? ((s: string) => openNoteInEditor(deps.vaultRoot, s));
971
+ const opened = await open(slug);
972
+ const payload: OpenResult = { opened };
973
+ // `404` rather than `200 {opened:false}` for a missing note: the client
974
+ // shows an error either way, and a status code keeps the failure visible
975
+ // in a network panel.
976
+ sendJson(res, opened ? 200 : 404, payload, { "cache-control": "no-store" });
977
+ }
978
+
979
+ function attachSse(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): void {
980
+ const hub = deps.sse;
981
+ if (hub === undefined) {
982
+ sendText(res, 503, "pi-weave: live updates unavailable\n");
983
+ return;
984
+ }
985
+ hub.attach(req, res);
986
+ deps.onActivity?.();
987
+ }