pi-weave 0.1.12 → 0.1.13

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 (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -1,56 +1,15 @@
1
1
  /**
2
- * Route handling for the workspace server (weave-workspace §5.3).
2
+ * Route handling for the workspace server.
3
3
  *
4
4
  * | Method | Path | Response |
5
5
  * | ------ | ------------------------ | -------------------------------------------- |
6
6
  * | GET | `/` | the HTML shell, with its per-response nonce |
7
7
  * | GET | `/app.js` | the committed bundle, `Cache-Control: no-store` |
8
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 |
9
+ * | GET | `/api/note/:slug` | {@link NotePayload} |
13
10
  * | GET | `/api/okf/:rel` | {@link OkfFilePayload} |
14
11
  * | GET | `/api/search?q=` | {@link SearchPayload} |
15
12
  * | 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
13
  *
55
14
  * ## Shape
56
15
  *
@@ -58,8 +17,7 @@
58
17
  * injected — and a `ServerResponse`. It never constructs a cache, reads an
59
18
  * environment variable, or knows what port it is on. That is what lets
60
19
  * `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.
20
+ * temp vault without browser-specific state.
63
21
  *
64
22
  * ## Two things this file deliberately never does
65
23
  *
@@ -73,50 +31,25 @@
73
31
  * **No path resolution of its own.** `/api/okf/:rel` and `/api/note/:slug`
74
32
  * carry untrusted path fragments straight from the URL. Both are handed to
75
33
  * 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.
34
+ * and `resolveNotePath` (via `getNote`) rejects unsafe slugs.
80
35
  */
81
36
 
82
37
  import { createHash } from "node:crypto";
83
38
  import { readFile } from "node:fs/promises";
84
39
  import type { IncomingMessage, ServerResponse } from "node:http";
85
- import { join } from "node:path";
86
- import { NOTES_DIR } from "../../core/paths";
87
40
  import type { WorkspaceSnapshot } from "../../core/cache/workspace";
88
41
  import { WorkspaceCache } from "../../core/cache/workspace";
89
42
  import { readOkfFileForView } from "../../core/graph/current";
90
43
  import type { GraphModel as CoreGraphModel } from "../../core/graph/model";
91
44
  import { openNoteInEditor } from "../../core/openInEditor";
92
- import type { MutationResult, RevisionedNote } from "../../core/vault";
93
- import { slugify, slugifyPath } from "../../core/slug";
94
- import {
95
- createFolder,
96
- deleteFolder,
97
- deleteNote,
98
- getNoteWithRevision,
99
- moveNoteToFolder,
100
- renameFolder,
101
- renameNote,
102
- resolveNotePath,
103
- searchNotes,
104
- updateNote,
105
- } from "../../core/vault";
45
+ import type { Note } from "../../core/types";
46
+ import { getNote, searchNotes } from "../../core/vault";
106
47
  import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
107
48
  import type {
108
- ChangeEvent,
109
- ConflictPayload,
110
- CreateFolderRequest,
111
- CreateFolderResult,
112
- DeleteNoteResult,
113
49
  GraphPayload,
114
- MoveNoteRequest,
115
50
  NotePayload,
116
51
  OkfFilePayload,
117
52
  OpenResult,
118
- RenameNoteRequest,
119
- SaveNoteRequest,
120
53
  SearchPayload,
121
54
  ViewNote,
122
55
  } from "../shared/wire";
@@ -125,99 +58,16 @@ import { renderPage } from "./page";
125
58
  import type { RequestFacts, SecurityPolicy } from "./security";
126
59
  import { requestFacts } from "./security";
127
60
 
128
- /**
129
- * The SSE hub contract.
130
- *
131
- * Declared here, in the consumer, rather than in the implementation: this
132
- * file is what needs the capability, and stating it here means the hub can
133
- * be written, replaced or omitted without `routes.ts` changing. It is also
134
- * the seam that lets the route tests boot a server with **no** hub and
135
- * assert the `503`.
136
- *
137
- * Implemented by `src/web/server/sse.ts`.
138
- */
139
- export interface SseHub {
140
- /** Adopt a request/response pair as a long-lived event stream. */
141
- attach(req: IncomingMessage, res: ServerResponse): void;
142
- /** Fan a change out to every attached client. */
143
- broadcast(event: ChangeEvent): void;
144
- /** Currently attached clients. Drives the idle-shutdown timer (§5.4). */
145
- clientCount(): number;
146
- /** End every stream and stop the heartbeat. Idempotent. */
147
- close(): void;
148
- }
149
-
150
- /**
151
- * The file watcher contract.
152
- *
153
- * Same reasoning as {@link SseHub} — the server owns the lifecycle, so it
154
- * declares the shape it will start and stop, and P1b's `watcher.ts` supplies
155
- * it. Deliberately minimal: the watcher's *output* reaches the world through
156
- * the hub it was constructed with, not through a return value here.
157
- *
158
- * Implemented by `src/web/server/watcher.ts`.
159
- */
160
- export interface Watcher {
161
- /** Begin watching. Resolves once the watches are established. */
162
- start(): Promise<void>;
163
- /** Stop watching and release every handle. Idempotent. */
164
- close(): Promise<void>;
165
- /**
166
- * Ignore events for `absPath` briefly — the §6 self-write window.
167
- *
168
- * **Optional**, and that is a deliberate contract choice rather than
169
- * timidity. This interface is the *lifecycle* one: start, close. A future
170
- * watcher over a remote filesystem or a stamp poller may have no concept
171
- * of "a path I am about to write", and making suppression mandatory would
172
- * force it to implement a no-op to satisfy a contract it does not
173
- * participate in. `server.ts` bridges it to {@link RouteDeps.suppress}
174
- * when it is present, and writes simply happen unsuppressed when it is
175
- * not — which is the correct degradation: one spurious refetch, never a
176
- * lost edit.
177
- */
178
- suppress?(absPath: string): void;
179
- }
180
-
181
61
  /** Everything a route needs, injected. */
182
62
  export interface RouteDeps {
183
63
  cwd: string;
184
64
  vaultRoot: string;
185
- /** Random per-boot id, echoed into the page bootstrap. */
186
- session: string;
187
65
  cache: WorkspaceCache;
188
66
  security: SecurityPolicy;
189
- /** Absent → `/events` answers `503`. Wired by P1b. */
190
- sse?: SseHub | undefined;
191
67
  /** Absolute path of the committed bundle. Injectable for tests. */
192
68
  bundlePath: string;
193
69
  /** Test seam for `POST /api/open`; defaults to the real editor shell-out. */
194
70
  openNote?: ((slug: string) => Promise<boolean>) | undefined;
195
- /**
196
- * Read a note plus its revision. Defaults to core's `getNoteWithRevision`.
197
- *
198
- * A seam for the same reason {@link RouteDeps.openNote} is one, and it
199
- * earns its place on a specific branch: after a successful write this is
200
- * called again to obtain the revision of the bytes now on disk, and it can
201
- * legitimately return `null` — a `weave_note` delete or an `rm` in another
202
- * terminal, landing in the window between the write and the re-read. That
203
- * is a genuine race with a correct answer (`404`: the write happened, and
204
- * the note is gone anyway), and it is unreachable from a test without
205
- * being able to make the read fail on demand. The alternative was a
206
- * coverage-ignore comment over a branch that really can fire in
207
- * production, which is the wrong trade.
208
- */
209
- readNote?: ((slug: string) => Promise<RevisionedNote | null>) | undefined;
210
- /** Called when an SSE client attaches or detaches — resets the idle timer. */
211
- onActivity?: (() => void) | undefined;
212
- /**
213
- * Ignore filesystem events for `absPath` for a moment (§6).
214
- *
215
- * The watcher's `suppress`, injected. Absent in the route tests, which
216
- * boot without a watcher — a write with nothing to suppress is a write,
217
- * not an error, so this is optional rather than a required no-op the
218
- * caller has to supply.
219
- */
220
- suppress?: ((absPath: string) => void) | undefined;
221
71
  }
222
72
 
223
73
  const JSON_TYPE = "application/json; charset=utf-8";
@@ -330,7 +180,7 @@ export async function readJsonBody(req: IncomingMessage): Promise<unknown | null
330
180
  * `notes` is what the graph was built from. It is a separate argument because
331
181
  * the graph deliberately does not carry structured tags — `detail.tags` is a
332
182
  * comma-joined display string and re-parsing it here would be exactly the
333
- * "grow structure inside `detail`" move §4.2/§4.3 rule out. The caller
183
+ * "grow structure inside `detail`" move / rule out. The caller
334
184
  * already holds the notes (the cache read them to build the model), so this
335
185
  * costs nothing. Omitted → `tags: {}`, which is what a caller that genuinely
336
186
  * has no note list should ship.
@@ -351,7 +201,7 @@ export function toGraphPayload(model: CoreGraphModel, notes: readonly TaggedNote
351
201
  dangling: model.danglingLinks,
352
202
  // Still `null`, and deliberately: server-side layout needs
353
203
  // `src/web/shared/layout`, which imports d3-force, and the server tier's
354
- // npm allowlist is empty (§9: the published package has zero runtime
204
+ // npm allowlist is empty (: the published package has zero runtime
355
205
  // dependencies). The client runs the identical `shared/layout` code
356
206
  // itself, so this is a division of labour rather than a gap.
357
207
  positions: null,
@@ -375,7 +225,7 @@ export function toGraphPayload(model: CoreGraphModel, notes: readonly TaggedNote
375
225
  * front-matter tags without bumping `updated`, and deleting a note that is
376
226
  * not the newest. In each the payload differs and the old stamp did not, so a
377
227
  * conditional GET answered `304` and the client kept stale data — and, worse,
378
- * the SSE dedupe (which shares this key) discarded the frame that would have
228
+ * a client-side comparison could discard the update that would have
379
229
  * prompted a refetch. A digest changes if and only if the bytes change, which
380
230
  * is the property both consumers actually need.
381
231
  *
@@ -422,7 +272,7 @@ export function stampPayload(payload: GraphPayload): GraphPayload {
422
272
  * The digest function. SHA-256, truncated to 128 bits and hex-encoded.
423
273
  *
424
274
  * Truncation is safe here and worth the 32 bytes it saves on every ETag
425
- * header and every SSE frame: at 128 bits an accidental collision between two
275
+ * header values: at 128 bits an accidental collision between two
426
276
  * payloads is far below the probability of the cache being wrong for any
427
277
  * other reason. This is a cache validator, not a security boundary — nobody
428
278
  * is choosing our note contents to force a collision, and if they could, they
@@ -515,22 +365,8 @@ async function route(
515
365
  if (method === "GET" && path === "/") return sendShell(deps, res);
516
366
  if (method === "GET" && path === "/app.js") return sendBundle(deps, res);
517
367
  if (method === "GET" && path === "/api/graph") return sendGraph(deps, req, res);
518
- if (path === "/api/folder" || path.startsWith("/api/folder/")) {
519
- if (method === "POST" && path.endsWith("/rename")) {
520
- await handleRenameFolder(deps, path, req, res);
521
- return;
522
- }
523
- if (method === "POST" && path === "/api/folder") {
524
- await handleCreateFolder(deps, req, res);
525
- return;
526
- }
527
- if (method === "DELETE") {
528
- await handleDeleteFolder(deps, path, req, res);
529
- return;
530
- }
531
- }
532
368
  if (path.startsWith("/api/note/")) {
533
- const handled = await routeNote(deps, method, path.slice("/api/note/".length), req, res);
369
+ const handled = await routeNote(deps, method, path.slice("/api/note/".length), res);
534
370
  if (handled) return;
535
371
  }
536
372
  if (method === "GET" && path.startsWith("/api/okf/")) {
@@ -538,8 +374,6 @@ async function route(
538
374
  }
539
375
  if (method === "GET" && path === "/api/search") return sendSearch(deps, query, res);
540
376
  if (method === "POST" && path === "/api/open") return openNote(deps, req, res);
541
- if (method === "GET" && path === "/events") return attachSse(deps, req, res);
542
-
543
377
  sendText(res, 404, "not found\n");
544
378
  }
545
379
 
@@ -547,7 +381,7 @@ async function route(
547
381
 
548
382
  function sendShell(deps: RouteDeps, res: ServerResponse): void {
549
383
  const page = renderPage({
550
- bootstrap: { cwd: deps.cwd, vaultRoot: deps.vaultRoot, session: deps.session },
384
+ bootstrap: { cwd: deps.cwd },
551
385
  });
552
386
  res.writeHead(200, {
553
387
  ...baseHeaders(),
@@ -575,7 +409,7 @@ async function sendBundle(deps: RouteDeps, res: ServerResponse): Promise<void> {
575
409
  res.writeHead(200, {
576
410
  ...baseHeaders(),
577
411
  "content-type": "text/javascript; charset=utf-8",
578
- // §5.3. The artifact changes on rebuild and the server may outlive one.
412
+ // . The artifact changes on rebuild and the server may outlive one.
579
413
  "cache-control": "no-store",
580
414
  });
581
415
  res.end(source);
@@ -587,29 +421,26 @@ async function sendBundle(deps: RouteDeps, res: ServerResponse): Promise<void> {
587
421
  *
588
422
  * `WorkspaceCache` returns the *identical* snapshot object while nothing on
589
423
  * disk has moved, so this map turns a warm `/api/graph` into a pure lookup:
590
- * no `toGraphPayload`, no `JSON.stringify`, and — the point of §15.6's cost
424
+ * no `toGraphPayload`, no `JSON.stringify`, and — the point of 's cost
591
425
  * requirement — **no hashing at all**. The first request after a real change
592
426
  * gets a new snapshot object, misses, and pays once for the whole build.
593
427
  *
594
428
  * A `WeakMap` rather than a one-slot cache so that a request racing a rebuild
595
429
  * cannot evict the entry the other request is about to read, and so entries
596
430
  * for superseded snapshots are collected with them. The key is the snapshot
597
- * rather than the model because the payload depends on the notes too (§4.3).
431
+ * rather than the model because the payload depends on the notes too (§4.1).
598
432
  */
599
433
  const renderedGraphs = new WeakMap<WorkspaceSnapshot, { body: string; etag: string }>();
600
434
 
601
435
  /**
602
436
  * The stamp `/api/graph` would serve for this snapshot.
603
437
  *
604
- * Exported so the SSE liveness bridge broadcasts the **same** key the ETag
605
- * carries (§6). Two derivations of "the current stamp" would be two things to
606
- * keep in sync, and the failure mode is silent: frames that never match the
607
- * validator the client then sends. It shares {@link renderGraph}'s memo, so
608
- * asking for the stamp after the route has rendered the same snapshot — the
609
- * common case, since a change triggers both — costs nothing.
438
+ * Exported for tests and for the conditional client contract. It shares
439
+ * {@link renderGraph}'s memo, so asking for the stamp after the route has
440
+ * rendered the same snapshot costs nothing.
610
441
  */
611
442
  export function graphStamp(snapshot: WorkspaceSnapshot): string {
612
- // The memo stores the quoted ETag; the frame wants the bare digest.
443
+ // The memo stores the quoted ETag; the response exposes the bare digest.
613
444
  return renderGraph(snapshot).etag.slice(1, -1);
614
445
  }
615
446
 
@@ -628,7 +459,7 @@ function renderGraph(snapshot: WorkspaceSnapshot): { body: string; etag: string
628
459
  async function sendGraph(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
629
460
  // `snapshot()`, not `graph()`: the tag index has to be derived from the
630
461
  // same (already capped) note list the model was built from, or a tag could
631
- // name a slug this graph has no node for (§4.3).
462
+ // name a slug this graph has no node for (§4.2).
632
463
  const snapshot = await deps.cache.snapshot();
633
464
  const { body, etag } = renderGraph(snapshot);
634
465
 
@@ -687,76 +518,30 @@ function normalizeEtag(value: string): string {
687
518
  /**
688
519
  * Everything under `/api/note/`, in one place.
689
520
  *
690
- * Returns `false` for a method/shape this family does not serve, so the
691
- * caller falls through to its own `404` rather than this function owning a
692
- * second copy of the not-found response. That is also what keeps
693
- * `DELETE /api/graph` and `PUT /api/note/x` answering the same `404` as any
694
- * other unrouted request: the family claims a request or it does not.
695
- *
696
- * The `rest` after the slug is matched **exactly**, not by prefix. Slugs may
697
- * nest (`sessions/foo` — session memory lives in a vault subdirectory), so
698
- * the family has exactly one sub-resource path (`/rename`) and everything
699
- * else after a `/` belongs to the slug itself; anything else a caller
700
- * appends is a request for nothing and gets the `404` it deserves. Core's
701
- * `resolveNotePath` remains the traversal guard for whatever slug arrives.
521
+ * Returns `false` for methods this family does not serve, so the caller
522
+ * falls through to its own `404`. Core's `resolveNotePath` remains the
523
+ * traversal guard for whatever slug arrives.
702
524
  */
703
- const renameSuffix = "/rename";
704
- const moveSuffix = "/move";
705
-
706
525
  async function routeNote(
707
526
  deps: RouteDeps,
708
527
  method: string,
709
528
  target: string,
710
- req: IncomingMessage,
711
529
  res: ServerResponse,
712
530
  ): Promise<boolean> {
713
- // Sub-resources matched at the end: a nested slug can itself contain
714
- // slashes, so the split is anchored at the end, not the first slash.
715
- const isRename = target.endsWith(renameSuffix);
716
- const isMove = target.endsWith(moveSuffix);
717
- const slug = isRename
718
- ? target.slice(0, target.length - renameSuffix.length)
719
- : isMove
720
- ? target.slice(0, target.length - moveSuffix.length)
721
- : target;
722
- const rest = isRename ? renameSuffix : isMove ? moveSuffix : "";
723
-
724
- if (rest === "" && method === "GET") {
725
- await sendNote(deps, slug, res);
726
- return true;
727
- }
728
- if (rest === "" && method === "POST") {
729
- await saveNote(deps, slug, req, res);
730
- return true;
731
- }
732
- if (rest === "" && method === "DELETE") {
733
- await removeNote(deps, slug, res);
734
- return true;
735
- }
736
- if (rest === "/rename" && method === "POST") {
737
- await moveNote(deps, slug, req, res);
738
- return true;
739
- }
740
- if (rest === "/move" && method === "POST") {
741
- await moveNoteToFolderHandler(deps, slug, req, res);
531
+ if (method === "GET") {
532
+ await sendNote(deps, target, res);
742
533
  return true;
743
534
  }
744
535
  return false;
745
536
  }
746
537
 
747
538
  async function sendNote(deps: RouteDeps, rawSlug: string, res: ServerResponse): Promise<void> {
748
- // Traversal is `resolveNotePath`'s job, inside `getNoteWithRevision`: an
539
+ // Traversal is `resolveNotePath`'s job, inside `getNote`: an
749
540
  // unsafe slug returns null before anything touches the disk. `%2e%2e%2f`
750
541
  // was already decoded by `parseTarget`, so what arrives here is the literal
751
542
  // `../` the guard is written to reject.
752
543
  //
753
- // `getNoteWithRevision` rather than `readNoteForView`, because the editor
754
- // cannot save safely without the revision it read at load, and fetching it
755
- // separately would leave a window in which the two disagree. It stats
756
- // before it reads, so a writer landing between the two calls yields a
757
- // revision *older* than the content — the save that follows is refused
758
- // rather than silently accepted (see core's own note on the ordering).
759
- const current = await readNote(deps, rawSlug);
544
+ const current = await getNote(deps.vaultRoot, rawSlug);
760
545
  if (current === null) {
761
546
  sendJson(res, 404, { error: "no such note" });
762
547
  return;
@@ -764,31 +549,9 @@ async function sendNote(deps: RouteDeps, rawSlug: string, res: ServerResponse):
764
549
  sendJson(res, 200, notePayload(current), { "cache-control": "no-store" });
765
550
  }
766
551
 
767
- /** {@link RouteDeps.readNote}, or core's. One resolution, used by both routes. */
768
- function readNote(deps: RouteDeps, slug: string): Promise<RevisionedNote | null> {
769
- const read = deps.readNote ?? ((s: string) => getNoteWithRevision(deps.vaultRoot, s));
770
- return read(slug);
771
- }
772
552
 
773
- /**
774
- * Core's `RevisionedNote` → the wire's {@link NotePayload}.
775
- *
776
- * The projection is `readNoteForView`'s, restated: a `Note` carries `body`,
777
- * the five managed fields **and** `frontMatter`, the verbatim block P5a added
778
- * so unknown keys survive a write. `frontMatter` must not cross the wire.
779
- * Not because it is secret, but because shipping it would invite a client to
780
- * send it back, and the moment a browser round-trips a user's raw metadata
781
- * through JSON the preservation guarantee stops being "the write path re-reads
782
- * the file" and becomes "the client remembered to return the block unedited".
783
- * The first is enforced by core; the second is a hope.
784
- *
785
- * So the field is dropped **explicitly**, by naming what is kept. A spread
786
- * with a `delete` would be exempt from the excess-property check and would
787
- * ship the block the day someone adds a sixth field — the same reasoning
788
- * `summarizeNote` gives for dropping it there.
789
- */
790
- function notePayload(current: RevisionedNote): NotePayload {
791
- const { note } = current;
553
+
554
+ function notePayload(note: Note): NotePayload {
792
555
  const view: ViewNote = {
793
556
  slug: note.slug,
794
557
  title: note.title,
@@ -798,293 +561,7 @@ function notePayload(current: RevisionedNote): NotePayload {
798
561
  tags: note.tags,
799
562
  source: note.source,
800
563
  };
801
- return { note: view, revision: current.revision };
802
- }
803
-
804
- /**
805
- * Answer a core {@link MutationResult}.
806
- *
807
- * The whole status mapping, in one function shared by both write routes, so
808
- * "a conflict is a 409" is a fact about this server rather than about
809
- * whichever handler was written most recently.
810
- *
811
- * A success re-reads the note through {@link getNoteWithRevision} rather than
812
- * returning the `Note` core handed back. Core's value is correct about
813
- * *content* and says nothing about *revision*, and the client's next save
814
- * needs a revision that matches the bytes now on disk — deriving one from the
815
- * write we just performed would mean re-implementing `revisionOf` out here,
816
- * against a stat this function does not have. The re-read costs one `stat`
817
- * plus one `readFile` on a file that is certainly in the page cache, and it
818
- * is the only way to hand back a revision that is true rather than inferred.
819
- *
820
- * The `null` branch is not dead code being defensive: between the write and
821
- * the re-read, a `weave_note` delete or an `rm` in another terminal can
822
- * genuinely remove the file. `404` is then the honest answer — the write did
823
- * happen, and the note is gone anyway.
824
- */
825
- async function sendMutation(deps: RouteDeps, result: MutationResult, res: ServerResponse): Promise<void> {
826
- if (!result.ok) {
827
- sendMutationFailure(result, res);
828
- return;
829
- }
830
- const written = await readNote(deps, result.note.slug);
831
- if (written === null) {
832
- sendJson(res, 404, { error: "no such note" });
833
- return;
834
- }
835
- sendJson(res, 200, notePayload(written), { "cache-control": "no-store" });
836
- }
837
-
838
- function sendMutationFailure(failure: Extract<MutationResult, { ok: false }>, res: ServerResponse): void {
839
- if (failure.reason === "missing") {
840
- sendJson(res, 404, { error: "no such note" });
841
- return;
842
- }
843
- const payload: ConflictPayload =
844
- failure.reason === "conflict"
845
- ? {
846
- error: "the note changed on disk since it was read",
847
- reason: "conflict",
848
- // The whole note, not just its revision (§11 P5.3). This is what
849
- // lets the client offer reload-or-overwrite without a second round
850
- // trip — and the second round trip is another window in which the
851
- // file moves again, which would make the prompt itself stale.
852
- current: notePayload(failure.current),
853
- }
854
- : { error: "a note with that slug already exists", reason: "collision", slug: failure.slug };
855
- sendJson(res, 409, payload, { "cache-control": "no-store" });
856
- }
857
-
858
- /**
859
- * Open the watcher's self-write window for a slug, if there is a watcher.
860
- *
861
- * Called **before** the mutation, never after: `fs.watch` can deliver an
862
- * event while the write syscall is still returning, and a window opened
863
- * afterwards is a window that opens second. Suppressing a path the write then
864
- * fails to touch costs nothing — the entry expires on its own.
865
- *
866
- * An unsafe slug resolves to `null` and is skipped rather than suppressed;
867
- * the mutation is about to refuse it anyway, and suppressing a path we could
868
- * not resolve would mean either fabricating one or passing `null` down to a
869
- * watcher that would `resolve()` it into the process's cwd.
870
- */
871
- function suppressSlug(deps: RouteDeps, slug: string): void {
872
- const path = resolveNotePath(deps.vaultRoot, slug);
873
- if (path !== null) deps.suppress?.(path);
874
- }
875
-
876
- async function saveNote(deps: RouteDeps, slug: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
877
- const body = await readJsonBody(req);
878
- const input = parseSaveRequest(body);
879
- if (input === null) {
880
- sendJson(res, 400, { error: "expected { body?: string, meta?: object, expectedRevision?: string }" });
881
- return;
882
- }
883
- suppressSlug(deps, slug);
884
- await sendMutation(deps, await updateNote(deps.vaultRoot, slug, input), res);
885
- }
886
-
887
- async function moveNote(deps: RouteDeps, slug: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
888
- const body = await readJsonBody(req);
889
- const target = typeof body === "object" && body !== null ? (body as Partial<RenameNoteRequest>).slug : undefined;
890
- const newTitle = typeof body === "object" && body !== null ? (body as Partial<RenameNoteRequest>).title : undefined;
891
- if (typeof target !== "string" || target.length === 0) {
892
- sendJson(res, 400, { error: "expected { slug: string }" });
893
- return;
894
- }
895
- const oldFolder = slug.includes("/") ? slug.split("/").slice(0, -1).join("/") : "";
896
- const targetSlug = /[\/\\]/.test(target)
897
- ? slugifyPath(target)
898
- : oldFolder.length > 0
899
- ? `${oldFolder}/${slugify(target)}`
900
- : slugify(target);
901
-
902
- suppressSlug(deps, slug);
903
- suppressSlug(deps, targetSlug);
904
- await sendMutation(deps, await renameNote(deps.vaultRoot, slug, target, undefined, newTitle), res);
905
- }
906
-
907
- async function moveNoteToFolderHandler(
908
- deps: RouteDeps,
909
- slug: string,
910
- req: IncomingMessage,
911
- res: ServerResponse,
912
- ): Promise<void> {
913
- const body = await readJsonBody(req);
914
- const targetFolder =
915
- typeof body === "object" && body !== null
916
- ? (body as Partial<MoveNoteRequest>).targetFolder ?? null
917
- : null;
918
- suppressSlug(deps, slug);
919
- const result = await moveNoteToFolder(deps.vaultRoot, slug, targetFolder);
920
- if (result.ok) {
921
- suppressSlug(deps, result.note.slug);
922
- }
923
- await sendMutation(deps, result, res);
924
- }
925
-
926
- async function handleCreateFolder(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
927
- const body = await readJsonBody(req);
928
- const path =
929
- typeof body === "object" && body !== null
930
- ? ((body as Partial<CreateFolderRequest>).path ?? (body as { name?: string }).name)
931
- : undefined;
932
- if (typeof path !== "string" || path.trim().length === 0) {
933
- sendJson(res, 400, { error: "expected { path: string }" });
934
- return;
935
- }
936
- const result = await createFolder(deps.vaultRoot, path);
937
- if (!result.ok) {
938
- if (result.reason === "collision") {
939
- sendJson(res, 409, { error: "a file already exists with that path", reason: "collision" });
940
- return;
941
- }
942
- sendJson(res, 400, { error: "invalid folder path", reason: "invalid-name" });
943
- return;
944
- }
945
- sendJson(res, 200, { ok: true, path: result.path });
946
- }
947
-
948
- async function handleDeleteFolder(
949
- deps: RouteDeps,
950
- urlPath: string,
951
- req: IncomingMessage,
952
- res: ServerResponse,
953
- ): Promise<void> {
954
- let folderPath: string | undefined;
955
- if (urlPath.startsWith("/api/folder/")) {
956
- folderPath = decodeURIComponent(urlPath.slice("/api/folder/".length));
957
- } else {
958
- const body = await readJsonBody(req);
959
- folderPath = typeof body === "object" && body !== null ? (body as { path?: string }).path : undefined;
960
- }
961
- if (typeof folderPath !== "string" || folderPath.trim().length === 0) {
962
- sendJson(res, 400, { error: "expected { path: string }" });
963
- return;
964
- }
965
- const result = await deleteFolder(deps.vaultRoot, folderPath);
966
- if (!result.ok) {
967
- if (result.reason === "invalid-name") {
968
- sendJson(res, 400, { error: "invalid folder path", reason: "invalid-name" });
969
- return;
970
- }
971
- sendJson(res, 404, { error: "no such folder" });
972
- return;
973
- }
974
- deps.suppress?.(join(deps.vaultRoot, NOTES_DIR, folderPath));
975
- sendJson(res, 200, { deleted: true });
976
- }
977
-
978
- async function handleRenameFolder(
979
- deps: RouteDeps,
980
- urlPath: string,
981
- req: IncomingMessage,
982
- res: ServerResponse,
983
- ): Promise<void> {
984
- const suffix = "/rename";
985
- const oldPathRaw = urlPath.slice("/api/folder/".length, urlPath.length - suffix.length);
986
- const oldPath = decodeURIComponent(oldPathRaw);
987
- const body = await readJsonBody(req);
988
- const newPath =
989
- typeof body === "object" && body !== null
990
- ? ((body as { newPath?: string; path?: string; target?: string }).newPath ??
991
- (body as { path?: string }).path ??
992
- (body as { target?: string }).target)
993
- : undefined;
994
- if (typeof newPath !== "string" || newPath.trim().length === 0) {
995
- sendJson(res, 400, { error: "expected { newPath: string }" });
996
- return;
997
- }
998
- const result = await renameFolder(deps.vaultRoot, oldPath, newPath);
999
- if (!result.ok) {
1000
- if (result.reason === "collision") {
1001
- sendJson(res, 409, { error: "a folder or file already exists with that path", reason: "collision" });
1002
- return;
1003
- }
1004
- if (result.reason === "invalid-name") {
1005
- sendJson(res, 400, { error: "invalid folder path", reason: "invalid-name" });
1006
- return;
1007
- }
1008
- sendJson(res, 404, { error: "no such folder" });
1009
- return;
1010
- }
1011
- deps.suppress?.(join(deps.vaultRoot, NOTES_DIR, oldPath));
1012
- deps.suppress?.(join(deps.vaultRoot, NOTES_DIR, result.path));
1013
- sendJson(res, 200, { ok: true, path: result.path });
1014
- }
1015
-
1016
- async function removeNote(deps: RouteDeps, slug: string, res: ServerResponse): Promise<void> {
1017
- suppressSlug(deps, slug);
1018
- const result = await deleteNote(deps.vaultRoot, slug);
1019
- if (!result.ok) {
1020
- sendJson(res, 404, { error: "no such note" });
1021
- return;
1022
- }
1023
- const payload: DeleteNoteResult = { deleted: true };
1024
- sendJson(res, 200, payload, { "cache-control": "no-store" });
1025
- }
1026
-
1027
- /**
1028
- * Narrow a decoded request body to core's `UpdateNoteInput`, or `null`.
1029
- *
1030
- * An **allowlist**, field by field, and that is the point rather than
1031
- * ceremony. `updateNote` spreads `input.meta` over the note's metadata, so
1032
- * anything that reaches it reaches the front matter: passing the parsed body
1033
- * straight through would let a local process `POST {"meta":{"created":"…"}}`
1034
- * and rewrite a field the API deliberately does not expose, or
1035
- * `{"meta":{"updated":"1970-…"}}` and make an edit look older than the state
1036
- * it overwrote. Only `title`, `tags` and `source` are copied, and `source`
1037
- * only when it is one of the three legal values — a note claiming
1038
- * `source: "verified"` would render with a provenance badge nothing in the
1039
- * palette matches.
1040
- *
1041
- * `null` for a body that is not an object, so the caller has exactly one
1042
- * failure branch. An **empty** object is valid: a save with no fields bumps
1043
- * `updated`, which is a meaningful (if unusual) request and not worth a
1044
- * special case.
1045
- */
1046
- export function parseSaveRequest(value: unknown): SaveNoteRequest | null {
1047
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
1048
- const raw = value as Record<string, unknown>;
1049
- const out: SaveNoteRequest = {};
1050
-
1051
- if (raw["body"] !== undefined) {
1052
- if (typeof raw["body"] !== "string") return null;
1053
- out.body = raw["body"];
1054
- }
1055
- if (raw["expectedRevision"] !== undefined) {
1056
- if (typeof raw["expectedRevision"] !== "string") return null;
1057
- out.expectedRevision = raw["expectedRevision"];
1058
- }
1059
- if (raw["meta"] !== undefined) {
1060
- const meta = parseSaveMeta(raw["meta"]);
1061
- if (meta === null) return null;
1062
- out.meta = meta;
1063
- }
1064
- return out;
1065
- }
1066
-
1067
- /** The three metadata fields a client may set. See {@link parseSaveRequest}. */
1068
- function parseSaveMeta(value: unknown): NonNullable<SaveNoteRequest["meta"]> | null {
1069
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
1070
- const raw = value as Record<string, unknown>;
1071
- const out: NonNullable<SaveNoteRequest["meta"]> = {};
1072
-
1073
- if (raw["title"] !== undefined) {
1074
- if (typeof raw["title"] !== "string") return null;
1075
- out.title = raw["title"];
1076
- }
1077
- if (raw["tags"] !== undefined) {
1078
- const tags = raw["tags"];
1079
- if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === "string")) return null;
1080
- out.tags = tags as string[];
1081
- }
1082
- if (raw["source"] !== undefined) {
1083
- const source = raw["source"];
1084
- if (source !== "human" && source !== "agent" && source !== "generated") return null;
1085
- out.source = source;
1086
- }
1087
- return out;
564
+ return { note: view };
1088
565
  }
1089
566
 
1090
567
  async function sendOkf(deps: RouteDeps, rel: string, res: ServerResponse): Promise<void> {
@@ -1123,13 +600,3 @@ async function openNote(deps: RouteDeps, req: IncomingMessage, res: ServerRespon
1123
600
  // in a network panel.
1124
601
  sendJson(res, opened ? 200 : 404, payload, { "cache-control": "no-store" });
1125
602
  }
1126
-
1127
- function attachSse(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): void {
1128
- const hub = deps.sse;
1129
- if (hub === undefined) {
1130
- sendText(res, 503, "pi-weave: live updates unavailable\n");
1131
- return;
1132
- }
1133
- hub.attach(req, res);
1134
- deps.onActivity?.();
1135
- }