@telepath-computer/vault-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # vault-server
2
+
3
+ `vault-server` makes an Obsidian vault — or any plain folder of markdown files —
4
+ accessible over HTTP. It's a local sidecar for web apps — dashboards, HTML
5
+ artifacts, anything that runs in a browser — that want to query, display,
6
+ and modify records and follow changes live, without touching the filesystem.
7
+ It follows Obsidian's conventions (YAML headers, wikilink references) but
8
+ never requires Obsidian itself: point it at any directory of markdown.
9
+
10
+ The URL space mirrors the vault: every file serves at its own path, and
11
+ dropping a markdown file's `.md` extension addresses it as structured JSON —
12
+ header parsed into fields, field references resolved. Directory URLs list records; a
13
+ WebSocket upgrade on `/` streams change events. `spec/` is the full contract;
14
+ this README shows the working surface.
15
+
16
+ ## Install and run
17
+
18
+ Node.js 24 or later.
19
+
20
+ ```sh
21
+ npm install -g @telepath-computer/vault-server
22
+ vault-server # serve the current directory
23
+ vault-server /path/to/vault # or an explicit vault root
24
+ ```
25
+
26
+ `--host` (default `127.0.0.1`) and `--port` (default `4747`) adjust binding.
27
+
28
+ The server binds localhost only by default and has no authentication —
29
+ expose it beyond the local machine at your own risk. CORS is wide open
30
+ (`Access-Control-Allow-Origin: *`), so browser pages from any origin can
31
+ call it directly.
32
+
33
+ ## Read
34
+
35
+ ```sh
36
+ curl http://127.0.0.1:4747/journal/today # record as JSON
37
+ curl http://127.0.0.1:4747/journal/today.md # same file, raw markdown
38
+ curl http://127.0.0.1:4747/photos/cat.jpg # any file, raw bytes
39
+ ```
40
+
41
+ A record's JSON view:
42
+
43
+ ```json
44
+ {
45
+ "path": "journal/today",
46
+ "fields": {
47
+ "status": "open",
48
+ "contact": { "$type": "ref", "path": "contacts/jane-doe" }
49
+ },
50
+ "body": "Raw markdown below the YAML header.\n",
51
+ "links": [{ "path": "contacts/jane-doe", "field": "contact" }],
52
+ "updated": "2026-08-07T09:15:00.000Z"
53
+ }
54
+ ```
55
+
56
+ `fields` is the markdown file's YAML header; `body`, its own key beside
57
+ them, is the markdown below it. A
58
+ field value that is entirely a wikilink (`contact: "[[Jane Doe]]"`) serves
59
+ as a reference resolved against the whole vault — `{ "$type": "ref",
60
+ "path": … }` — the target's API path, fetchable here — plus a `label` when
61
+ the link's rendered text differs. Prose inside
62
+ `body` serves as standard markdown — wikilinks come out as resolved
63
+ relative links, so any renderer navigates them against these same URLs.
64
+ `links` lists every resolved record link touching this record: outbound
65
+ entries name their target, while entries with `backlink: true` name their
66
+ source. `field` identifies the top-level header key; prose links omit it.
67
+ If the frontmatter is invalid, the whole file is returned as `body` with
68
+ empty `fields`, and the record carries `error: { "code":
69
+ "invalid_frontmatter", "message": "Frontmatter could not be parsed" }`.
70
+
71
+ ## Query
72
+
73
+ A trailing slash lists a directory's records recursively as
74
+ `{ path, fields, body?, updated, error? }`; `body` remains beside `fields`,
75
+ while the single-record `links` list is omitted. There are no query parameters;
76
+ filter and sort in JS:
77
+
78
+ ```js
79
+ const records = await (await fetch("http://127.0.0.1:4747/tasks/")).json();
80
+ const open = records
81
+ .filter((record) => record.fields.status === "open")
82
+ .sort((a, b) => b.updated.localeCompare(a.updated));
83
+ ```
84
+
85
+ ## Write
86
+
87
+ Record writes carry the record media type,
88
+ `application/vnd.telepath.record+json` — the content type is what
89
+ distinguishes a record write from an eventual raw-file upload at the
90
+ same kind of URL; the request body is the record's
91
+ `{ fields, body }` — the header object and the text below it, sent
92
+ separately as they are served. PATCH merge-patches `fields` (`null` deletes a
93
+ field) and replaces `body` only when that sibling key is present (`null`
94
+ removes it):
95
+
96
+ ```js
97
+ await fetch("http://127.0.0.1:4747/tasks/fix-roof", {
98
+ method: "PATCH",
99
+ headers: { "content-type": "application/vnd.telepath.record+json" },
100
+ body: JSON.stringify({ fields: { status: "done", due: null } }),
101
+ });
102
+ ```
103
+
104
+ PUT replaces the fields wholesale, creating the record if needed; DELETE
105
+ removes it. All
106
+ writes are atomic on disk, so a syncing vault never sees half a record.
107
+
108
+ ## Subscribe
109
+
110
+ A WebSocket upgrade on `/` streams every change in the vault — external
111
+ edits included (Obsidian on another device, Dropbox sync), not just writes
112
+ made through the API. Filter by path prefix client-side:
113
+
114
+ ```js
115
+ const socket = new WebSocket("ws://127.0.0.1:4747/");
116
+ socket.onmessage = ({ data }) => {
117
+ const { type, path } = JSON.parse(data); // created | updated | deleted
118
+ refresh(path);
119
+ };
120
+ ```
121
+
122
+ Events carry no content and there is no replay: list first, then apply
123
+ events, and re-list whenever the socket reconnects.
@@ -0,0 +1,63 @@
1
+ /** Composes the vault HTTP API, filesystem watcher, and WebSocket subscriptions. */
2
+ import { type Server } from "node:http";
3
+ import express from "express";
4
+ import { type RecordContent } from "./notes.ts";
5
+ /** A record in the form it will be stored, offered for approval before it is written. */
6
+ export type ValidatedRecord = RecordContent & {
7
+ path: string;
8
+ };
9
+ export type Validate = (record: ValidatedRecord) => void | Promise<void>;
10
+ export type LinkFormat = "wikilink" | "markdown";
11
+ export type VaultOptions = {
12
+ linkFormat?: LinkFormat;
13
+ rawBodies?: string[];
14
+ };
15
+ export type ServerOptions = {
16
+ jsonLimit?: string | number;
17
+ pingIntervalMs?: number;
18
+ };
19
+ export type VaultServerOptions = {
20
+ root: string;
21
+ vault?: VaultOptions;
22
+ server?: ServerOptions;
23
+ validate?: Validate;
24
+ };
25
+ export declare class VaultServer {
26
+ readonly app: express.Express;
27
+ readonly server: Server;
28
+ /** Resolves once the watcher has indexed the vault and the root is known good. */
29
+ readonly ready: Promise<void>;
30
+ private readonly root;
31
+ private readonly index;
32
+ private readonly validate;
33
+ private readonly watcher;
34
+ private readonly webSockets;
35
+ private readonly sockets;
36
+ private readonly pendingIndexRefreshes;
37
+ private readonly pendingWatcherEvents;
38
+ private readonly pingTimer;
39
+ private isReady;
40
+ private isClosing;
41
+ private startupRefreshFailed;
42
+ private startupRefreshError;
43
+ private boundHost;
44
+ constructor(options: VaultServerOptions);
45
+ /** The address the server is bound to, or undefined before listening. */
46
+ get url(): string | undefined;
47
+ /** The port actually bound, which for port 0 is the one the OS chose. */
48
+ get port(): number | undefined;
49
+ get recordCount(): number;
50
+ /** Accept connections, once the vault is indexed. Resolves with the server. */
51
+ listen({ host, port }?: {
52
+ host?: string;
53
+ port?: number;
54
+ }): Promise<this>;
55
+ close(): Promise<void>;
56
+ private context;
57
+ private readonly runMutation;
58
+ private emitEvent;
59
+ private handleWatcherChange;
60
+ private flushWatcherEvent;
61
+ private cancelPendingWatcherEvent;
62
+ private refreshIndex;
63
+ }
@@ -0,0 +1,463 @@
1
+ /** Composes the vault HTTP API, filesystem watcher, and WebSocket subscriptions. */
2
+ import { createServer } from "node:http";
3
+ import { realpathSync } from "node:fs";
4
+ import { stat } from "node:fs/promises";
5
+ import { relative } from "node:path";
6
+ import chokidar from "chokidar";
7
+ import express from "express";
8
+ import { WebSocketServer, WebSocket } from "ws";
9
+ import { deleteNote, InvalidReferenceError, InvalidUtf8Error, listNotes, mergeFields, readNote, readRawRecord, storedBody, storedFields, VaultIndex, writeNote } from "./notes.js";
10
+ import { hasDotSegment, isVaultPathConfined, resolveVaultPath, toVaultPath } from "./paths.js";
11
+ const noteContentType = "application/vnd.telepath.record+json";
12
+ const watcherDebounceMs = 50;
13
+ /** Raised when a caller's `validate` refuses a write. */
14
+ class ValidationError extends Error {
15
+ }
16
+ export class VaultServer {
17
+ app;
18
+ server;
19
+ /** Resolves once the watcher has indexed the vault and the root is known good. */
20
+ ready;
21
+ root;
22
+ index;
23
+ validate;
24
+ watcher;
25
+ webSockets = new WebSocketServer({ noServer: true });
26
+ sockets = new Map();
27
+ pendingIndexRefreshes = new Set();
28
+ pendingWatcherEvents = new Map();
29
+ pingTimer;
30
+ isReady = false;
31
+ isClosing = false;
32
+ startupRefreshFailed = false;
33
+ startupRefreshError;
34
+ boundHost;
35
+ constructor(options) {
36
+ const unknownOption = Object.keys(options).find((key) => !["root", "vault", "server", "validate"].includes(key));
37
+ if (unknownOption)
38
+ throw new Error(`unknown VaultServer option "${unknownOption}"; use the "vault" or "server" group`);
39
+ const { root, vault = {}, server = {}, validate } = options;
40
+ const { linkFormat = "wikilink", rawBodies = [] } = vault;
41
+ const { jsonLimit = "32mb", pingIntervalMs = 30_000 } = server;
42
+ // A symlinked root (e.g. ~/workspace/vault -> ~/Dropbox/Vault) must be
43
+ // resolved up front: the watcher ignores symlinks, so an unresolved root
44
+ // would index and watch nothing.
45
+ this.root = realpathSync(root);
46
+ this.validate = validate;
47
+ this.index = new VaultIndex(this.root, undefined, linkFormat, rawBodies);
48
+ this.app = express();
49
+ this.app.use((request, response, next) => {
50
+ response.set("Access-Control-Allow-Origin", "*");
51
+ if (request.method !== "OPTIONS") {
52
+ next();
53
+ return;
54
+ }
55
+ response.set({
56
+ "Access-Control-Allow-Methods": "GET, PUT, PATCH, DELETE",
57
+ "Access-Control-Allow-Headers": "content-type",
58
+ "Access-Control-Max-Age": "86400",
59
+ });
60
+ if (request.get("Access-Control-Request-Private-Network") === "true") {
61
+ response.set("Access-Control-Allow-Private-Network", "true");
62
+ }
63
+ response.status(204).end();
64
+ });
65
+ this.app.use(express.json({ type: (request) => shouldParseNoteBody(request), limit: jsonLimit }));
66
+ this.app.use(async (request, response) => { await handleRequest(this.context(), request, response); });
67
+ this.app.use((error, _request, response, _next) => {
68
+ if (isPayloadTooLarge(error))
69
+ sendError(response, 413, "request body too large");
70
+ else if (error instanceof SyntaxError)
71
+ sendError(response, 400, "malformed JSON");
72
+ else
73
+ sendError(response, 500, "internal server error");
74
+ });
75
+ this.server = createServer(this.app);
76
+ this.watcher = chokidar.watch(this.root, {
77
+ ignoreInitial: false,
78
+ followSymlinks: false,
79
+ ignored: (path, stats) => stats?.isSymbolicLink() === true || hasDotSegment(toVaultPath(relative(this.root, path))),
80
+ });
81
+ const watcherReady = new Promise((resolve, reject) => {
82
+ this.watcher.once("ready", resolve);
83
+ this.watcher.on("error", (error) => {
84
+ if (!this.isReady)
85
+ reject(error);
86
+ else
87
+ console.error("vault-server watcher error", error);
88
+ });
89
+ });
90
+ const rootReady = stat(this.root).then((rootStat) => { if (!rootStat.isDirectory())
91
+ throw new Error("vault root is not a directory"); });
92
+ this.ready = Promise.all([watcherReady, rootReady]).then(async () => {
93
+ while (this.pendingIndexRefreshes.size > 0)
94
+ await Promise.all([...this.pendingIndexRefreshes]);
95
+ if (this.startupRefreshFailed)
96
+ throw this.startupRefreshError;
97
+ this.isReady = true;
98
+ });
99
+ this.server.on("upgrade", async (request, socket, head) => {
100
+ const path = decodeRequestPath(request.url ?? "/");
101
+ if (path === null || hasHiddenUrlSegment(path) || !rawPathname(request.url ?? "/").endsWith("/")) {
102
+ socket.destroy();
103
+ return;
104
+ }
105
+ const absolutePath = resolveVaultPath(this.root, path);
106
+ if (!absolutePath || !await isVaultPathConfined(this.root, absolutePath)) {
107
+ socket.destroy();
108
+ return;
109
+ }
110
+ this.webSockets.handleUpgrade(request, socket, head, (webSocket) => {
111
+ const subscription = { isAlive: true };
112
+ this.sockets.set(webSocket, subscription);
113
+ webSocket.on("pong", () => { subscription.isAlive = true; });
114
+ webSocket.once("close", () => this.sockets.delete(webSocket));
115
+ this.webSockets.emit("connection", webSocket, request);
116
+ });
117
+ });
118
+ this.watcher.on("add", (path) => this.handleWatcherChange("created", path))
119
+ .on("change", (path) => this.handleWatcherChange("updated", path))
120
+ .on("unlink", (path) => {
121
+ const publish = this.isReady && !this.isClosing;
122
+ this.cancelPendingWatcherEvent(path);
123
+ this.index.delete(path);
124
+ if (publish)
125
+ this.emitEvent("deleted", path);
126
+ });
127
+ this.pingTimer = setInterval(() => {
128
+ for (const [socket, subscription] of this.sockets) {
129
+ if (socket.readyState !== WebSocket.OPEN)
130
+ continue;
131
+ if (!subscription.isAlive) {
132
+ socket.terminate();
133
+ continue;
134
+ }
135
+ subscription.isAlive = false;
136
+ socket.ping();
137
+ }
138
+ }, pingIntervalMs);
139
+ }
140
+ /** The address the server is bound to, or undefined before listening. */
141
+ get url() {
142
+ const port = this.port;
143
+ return port === undefined || this.boundHost === undefined ? undefined : `http://${this.boundHost}:${port}`;
144
+ }
145
+ /** The port actually bound, which for port 0 is the one the OS chose. */
146
+ get port() {
147
+ const address = this.server.address();
148
+ return address !== null && typeof address === "object" ? address.port : undefined;
149
+ }
150
+ get recordCount() { return this.index.markdownNoteCount; }
151
+ /** Accept connections, once the vault is indexed. Resolves with the server. */
152
+ async listen({ host = "127.0.0.1", port = 4747 } = {}) {
153
+ await this.ready;
154
+ await new Promise((resolve, reject) => {
155
+ const onError = (error) => { this.server.removeListener("listening", onListening); reject(error); };
156
+ const onListening = () => { this.server.removeListener("error", onError); resolve(); };
157
+ this.server.once("error", onError);
158
+ this.server.once("listening", onListening);
159
+ this.server.listen(port, host);
160
+ });
161
+ this.boundHost = host;
162
+ return this;
163
+ }
164
+ async close() {
165
+ this.isClosing = true;
166
+ for (const path of this.pendingWatcherEvents.keys())
167
+ this.cancelPendingWatcherEvent(path);
168
+ clearInterval(this.pingTimer);
169
+ for (const socket of this.sockets.keys())
170
+ socket.terminate();
171
+ await this.watcher.close();
172
+ this.webSockets.close();
173
+ if (this.server.listening)
174
+ await new Promise((resolve, reject) => this.server.close((error) => error ? reject(error) : resolve()));
175
+ this.boundHost = undefined;
176
+ }
177
+ context() {
178
+ return { root: this.root, index: this.index, validate: this.validate, runMutation: this.runMutation };
179
+ }
180
+ runMutation = async (type, absolutePath, action) => {
181
+ const result = await action();
182
+ this.cancelPendingWatcherEvent(absolutePath);
183
+ this.emitEvent(type, absolutePath);
184
+ return result;
185
+ };
186
+ emitEvent(type, absolutePath) {
187
+ const diskPath = toVaultPath(relative(this.root, absolutePath));
188
+ const path = diskPath.endsWith(".md") ? diskPath.slice(0, -3) : diskPath;
189
+ if (hasDotSegment(path))
190
+ return;
191
+ for (const socket of this.sockets.keys())
192
+ if (socket.readyState === WebSocket.OPEN)
193
+ socket.send(JSON.stringify({ type, path }));
194
+ }
195
+ handleWatcherChange(type, path) {
196
+ if (this.isClosing)
197
+ return;
198
+ if (!this.isReady) {
199
+ void this.refreshIndex(path);
200
+ return;
201
+ }
202
+ const existing = this.pendingWatcherEvents.get(path);
203
+ if (existing?.timer)
204
+ clearTimeout(existing.timer);
205
+ const pending = {
206
+ type: existing?.type === "created" || type === "created" ? "created" : "updated",
207
+ };
208
+ pending.timer = setTimeout(() => { delete pending.timer; void this.flushWatcherEvent(path, pending); }, watcherDebounceMs);
209
+ pending.timer.unref();
210
+ this.pendingWatcherEvents.set(path, pending);
211
+ }
212
+ async flushWatcherEvent(path, pending) {
213
+ const outcome = await this.refreshIndex(path);
214
+ if (this.pendingWatcherEvents.get(path) !== pending)
215
+ return;
216
+ this.pendingWatcherEvents.delete(path);
217
+ if (outcome === "committed" && !this.isClosing)
218
+ this.emitEvent(pending.type, path);
219
+ }
220
+ cancelPendingWatcherEvent(path) {
221
+ const pending = this.pendingWatcherEvents.get(path);
222
+ if (pending?.timer)
223
+ clearTimeout(pending.timer);
224
+ this.pendingWatcherEvents.delete(path);
225
+ }
226
+ refreshIndex(path) {
227
+ const startup = !this.isReady;
228
+ const refresh = this.index.refresh(path).catch((error) => {
229
+ if (startup) {
230
+ if (!this.startupRefreshFailed)
231
+ this.startupRefreshError = error;
232
+ this.startupRefreshFailed = true;
233
+ }
234
+ else
235
+ console.error("vault-server index refresh error", error);
236
+ return "failed";
237
+ });
238
+ this.pendingIndexRefreshes.add(refresh);
239
+ void refresh.finally(() => this.pendingIndexRefreshes.delete(refresh));
240
+ return refresh;
241
+ }
242
+ }
243
+ async function handleRequest(context, request, response) {
244
+ const path = decodeRequestPath(request.originalUrl);
245
+ if (path === null) {
246
+ sendError(response, 400, "invalid path");
247
+ return;
248
+ }
249
+ if (hasHiddenUrlSegment(path)) {
250
+ sendError(response, 404, "not found");
251
+ return;
252
+ }
253
+ const absolutePath = resolveVaultPath(context.root, path);
254
+ if (!absolutePath) {
255
+ sendError(response, 400, "path escapes vault");
256
+ return;
257
+ }
258
+ if (!await isVaultPathConfined(context.root, absolutePath)) {
259
+ sendError(response, 400, "path escapes vault");
260
+ return;
261
+ }
262
+ try {
263
+ if (request.method === "GET") {
264
+ await handleGet(context, absolutePath, request, response);
265
+ return;
266
+ }
267
+ if (["PUT", "PATCH", "DELETE"].includes(request.method)) {
268
+ await handleWrite(context, absolutePath, request, response);
269
+ return;
270
+ }
271
+ sendError(response, 405, "method not allowed");
272
+ }
273
+ catch (error) {
274
+ if (error instanceof ValidationError)
275
+ sendError(response, 422, error.message);
276
+ else if (error instanceof InvalidReferenceError)
277
+ sendError(response, 400, error.message);
278
+ else if (error instanceof InvalidUtf8Error)
279
+ sendError(response, 422, error.message);
280
+ else if (isMissing(error))
281
+ sendError(response, 404, "not found");
282
+ else if (error instanceof SyntaxError)
283
+ sendError(response, 400, "malformed JSON");
284
+ else {
285
+ console.error(error);
286
+ sendError(response, 500, "internal server error");
287
+ }
288
+ }
289
+ }
290
+ async function handleGet(context, absolutePath, request, response) {
291
+ if (rawPathname(request.originalUrl).endsWith("/")) {
292
+ if (!(await isDirectory(absolutePath))) {
293
+ sendError(response, 404, "directory not found");
294
+ return;
295
+ }
296
+ response.json(listNotes(context.index, canonicalPath(context.root, absolutePath)));
297
+ return;
298
+ }
299
+ if (await isFile(absolutePath)) {
300
+ response.sendFile(absolutePath);
301
+ return;
302
+ }
303
+ if (await isFile(`${absolutePath}.md`)) {
304
+ if (!await isVaultPathConfined(context.root, `${absolutePath}.md`)) {
305
+ sendError(response, 400, "path escapes vault");
306
+ return;
307
+ }
308
+ response.json(await readNote(context.root, `${absolutePath}.md`, context.index));
309
+ return;
310
+ }
311
+ if (await isDirectory(absolutePath)) {
312
+ response.redirect(308, `${request.path}/`);
313
+ return;
314
+ }
315
+ sendError(response, 404, "not found");
316
+ }
317
+ async function handleWrite(context, absolutePath, request, response) {
318
+ const path = decodeRequestPath(request.originalUrl) ?? "";
319
+ if (rawPathname(request.originalUrl).endsWith("/") || path.endsWith(".md")) {
320
+ sendError(response, 405, "path is read-only");
321
+ return;
322
+ }
323
+ if (request.method !== "DELETE" && !request.is(noteContentType)) {
324
+ sendError(response, 415, "unsupported content type");
325
+ return;
326
+ }
327
+ if (await isFile(absolutePath)) {
328
+ sendError(response, 409, "literal file occupies record URL");
329
+ return;
330
+ }
331
+ const notePath = `${absolutePath}.md`;
332
+ if (!await isVaultPathConfined(context.root, notePath)) {
333
+ sendError(response, 400, "path escapes vault");
334
+ return;
335
+ }
336
+ const exists = await isFile(notePath);
337
+ if (request.method === "DELETE") {
338
+ if (!exists) {
339
+ sendError(response, 404, "record not found");
340
+ return;
341
+ }
342
+ await context.runMutation("deleted", notePath, () => deleteNote(notePath, context.index));
343
+ response.status(204).end();
344
+ return;
345
+ }
346
+ if (!isObject(request.body)) {
347
+ sendError(response, 400, "body must be an object");
348
+ return;
349
+ }
350
+ if ("fields" in request.body && !isObject(request.body.fields)) {
351
+ sendError(response, 400, "fields must be an object");
352
+ return;
353
+ }
354
+ if ("body" in request.body && request.body.body !== null && typeof request.body.body !== "string") {
355
+ sendError(response, 400, "body must be a string");
356
+ return;
357
+ }
358
+ const linkingNotePath = canonicalPath(context.root, notePath);
359
+ if (request.method === "PUT") {
360
+ let previous;
361
+ if (exists) {
362
+ try {
363
+ previous = await readRawRecord(notePath);
364
+ }
365
+ catch (error) {
366
+ if (!(error instanceof InvalidUtf8Error))
367
+ throw error;
368
+ }
369
+ }
370
+ const fields = storedFields((request.body.fields ?? {}), previous?.fields, context.index, linkingNotePath);
371
+ const body = typeof request.body.body === "string"
372
+ ? storedBody(request.body.body, previous?.body, context.index, linkingNotePath)
373
+ : undefined;
374
+ const record = { fields, ...(body === undefined ? {} : { body }) };
375
+ await approve(context, notePath, record);
376
+ const note = await context.runMutation(exists ? "updated" : "created", notePath, () => writeNote(context.root, notePath, context.index, record));
377
+ response.status(exists ? 200 : 201).json(note);
378
+ return;
379
+ }
380
+ if (!exists) {
381
+ sendError(response, 404, "record not found");
382
+ return;
383
+ }
384
+ const current = await readNote(context.root, notePath, context.index);
385
+ const fieldPatch = request.body.fields;
386
+ const bodyTouched = Object.hasOwn(request.body, "body");
387
+ if ((fieldPatch === undefined || Object.keys(fieldPatch).length === 0) && !bodyTouched) {
388
+ response.json(current);
389
+ return;
390
+ }
391
+ const previous = await readRawRecord(notePath);
392
+ const fields = fieldPatch === undefined
393
+ ? previous.fields
394
+ : storedFields(mergeFields(current.fields, fieldPatch), previous.fields, context.index, linkingNotePath, fieldPatch);
395
+ const body = bodyTouched
396
+ ? typeof request.body.body === "string"
397
+ ? storedBody(request.body.body, previous.body, context.index, linkingNotePath)
398
+ : undefined
399
+ : previous.body;
400
+ const record = { fields, ...(body === undefined ? {} : { body }) };
401
+ await approve(context, notePath, record);
402
+ response.json(await context.runMutation("updated", notePath, () => writeNote(context.root, notePath, context.index, record, fieldPatch ?? {}, bodyTouched, previous.rawBody)));
403
+ }
404
+ /**
405
+ * Offer the record a write would produce to the caller's `validate`, before any
406
+ * of it reaches disk — so a refusal leaves nothing behind, not even a temp file.
407
+ * The fields are the ones that will be stored, not the ones a read would give
408
+ * back: a rule is judged against what the write actually persists.
409
+ */
410
+ async function approve(context, notePath, content) {
411
+ if (!context.validate)
412
+ return;
413
+ const diskPath = canonicalPath(context.root, notePath);
414
+ const record = { path: diskPath.slice(0, -3), ...content };
415
+ try {
416
+ await context.validate(record);
417
+ }
418
+ catch (error) {
419
+ throw new ValidationError(error instanceof Error ? error.message : String(error));
420
+ }
421
+ }
422
+ function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
423
+ async function isFile(path) { try {
424
+ return (await stat(path)).isFile();
425
+ }
426
+ catch {
427
+ return false;
428
+ } }
429
+ async function isDirectory(path) { try {
430
+ return (await stat(path)).isDirectory();
431
+ }
432
+ catch {
433
+ return false;
434
+ } }
435
+ function isMissing(error) { return isObject(error) && "code" in error && error.code === "ENOENT"; }
436
+ function isPayloadTooLarge(error) {
437
+ return isObject(error) && (("status" in error && error.status === 413) || ("type" in error && error.type === "entity.too.large"));
438
+ }
439
+ function rawPathname(url) { return url.split("?", 1)[0] ?? ""; }
440
+ function decodeRequestPath(url) {
441
+ try {
442
+ const path = decodeURIComponent(rawPathname(url)).replace(/^\/+/, "");
443
+ return path.includes("\0") ? null : path;
444
+ }
445
+ catch {
446
+ return null;
447
+ }
448
+ }
449
+ function hasHiddenUrlSegment(path) {
450
+ return path.split("/").some((segment) => segment !== "." && segment !== ".." && segment.startsWith("."));
451
+ }
452
+ function shouldParseNoteBody(request) {
453
+ if (request.method !== "PUT" && request.method !== "PATCH")
454
+ return false;
455
+ if (request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== noteContentType)
456
+ return false;
457
+ const url = request.url ?? "/";
458
+ const path = decodeRequestPath(url);
459
+ return path !== null && !hasHiddenUrlSegment(path) && !rawPathname(url).endsWith("/") && !path.endsWith(".md");
460
+ }
461
+ function canonicalPath(root, absolutePath) { return toVaultPath(relative(root, absolutePath)); }
462
+ function sendError(response, status, message) { response.status(status).json({ error: message }); }
463
+ //# sourceMappingURL=app.js.map