@openparachute/vault 0.3.1 → 0.4.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/.parachute/module.json +15 -0
- package/README.md +9 -5
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +384 -78
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init-summary.test.ts +133 -0
- package/src/init-summary.ts +90 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +31 -14
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/routes.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* REST API route handlers for the multi-vault server.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors the
|
|
4
|
+
* Mirrors the MCP tools:
|
|
5
5
|
* /api/notes — query-notes, create-note, update-note, delete-note
|
|
6
6
|
* /api/tags — list-tags, update-tag, delete-tag
|
|
7
|
+
* /api/note-schemas — list/upsert/delete note_schemas + nested mappings
|
|
7
8
|
* /api/find-path — find-path
|
|
8
9
|
* /api/vault — vault-info
|
|
10
|
+
* (synthesize-notes is MCP-only; agents call it through the MCP transport.)
|
|
9
11
|
*
|
|
10
12
|
* Each handler receives a Store instance (already resolved for the vault)
|
|
11
13
|
* and the Request, and returns a Response.
|
|
@@ -13,9 +15,28 @@
|
|
|
13
15
|
|
|
14
16
|
import type { Store, Note } from "../core/src/types.ts";
|
|
15
17
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
16
|
-
import { toNoteIndex, filterMetadata } from "../core/src/notes.ts";
|
|
18
|
+
import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
|
|
17
19
|
import * as linkOps from "../core/src/links.ts";
|
|
18
20
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
21
|
+
import { MAPPING_KINDS, type SchemaMappingKind, type NoteSchemaField } from "../core/src/note-schemas.ts";
|
|
22
|
+
import {
|
|
23
|
+
filterNotesByTagScope,
|
|
24
|
+
noteWithinTagScope,
|
|
25
|
+
tagScopeForbidden,
|
|
26
|
+
tagsWithinScope,
|
|
27
|
+
} from "./tag-scope.ts";
|
|
28
|
+
import { findTokensReferencingTag } from "./token-store.ts";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Tag-scope context threaded through handlers. `allowed` is the
|
|
32
|
+
* pre-expanded set of permitted tags (root + descendants), `raw` is the
|
|
33
|
+
* original allowlist for error messages. Both null when the token is
|
|
34
|
+
* unscoped — handlers fast-path on `allowed === null` and behave
|
|
35
|
+
* identically to the pre-tag-scope code path.
|
|
36
|
+
*/
|
|
37
|
+
export type TagScopeCtx = { allowed: Set<string> | null; raw: string[] | null };
|
|
38
|
+
|
|
39
|
+
const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
|
|
19
40
|
import {
|
|
20
41
|
expandContent,
|
|
21
42
|
DEFAULT_EXPAND_DEPTH,
|
|
@@ -132,6 +153,7 @@ export async function handleNotes(
|
|
|
132
153
|
store: Store,
|
|
133
154
|
subpath: string,
|
|
134
155
|
vault?: string,
|
|
156
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
135
157
|
): Promise<Response> {
|
|
136
158
|
const url = new URL(req.url);
|
|
137
159
|
const method = req.method;
|
|
@@ -148,6 +170,12 @@ export async function handleNotes(
|
|
|
148
170
|
if (id) {
|
|
149
171
|
const note = await resolveNote(store, id);
|
|
150
172
|
if (!note) return json({ error: "Note not found", id }, 404);
|
|
173
|
+
// Tag-scope: a token can't see what its allowlist excludes. Surface
|
|
174
|
+
// as 404 (not 403) — the existence of the note is itself information
|
|
175
|
+
// we shouldn't leak across the scope boundary.
|
|
176
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
177
|
+
return json({ error: "Note not found", id }, 404);
|
|
178
|
+
}
|
|
151
179
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
152
180
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
153
181
|
const expand = parseExpandParams(url, db);
|
|
@@ -169,7 +197,11 @@ export async function handleNotes(
|
|
|
169
197
|
if (search) {
|
|
170
198
|
const searchTags = parseQueryList(url, "tag");
|
|
171
199
|
const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
|
|
172
|
-
const
|
|
200
|
+
const rawResults = await store.searchNotes(search, { tags: searchTags, limit });
|
|
201
|
+
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
202
|
+
// happens after the store query so an empty post-filter list still
|
|
203
|
+
// returns 200 [] (consistent with "no matches"), not 403.
|
|
204
|
+
const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
|
|
173
205
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
174
206
|
const inclMeta = parseIncludeMetadata(url);
|
|
175
207
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
|
|
@@ -189,6 +221,13 @@ export async function handleNotes(
|
|
|
189
221
|
}
|
|
190
222
|
|
|
191
223
|
// Structured query
|
|
224
|
+
//
|
|
225
|
+
// Surface asymmetry: REST uses three flat query params
|
|
226
|
+
// (`date_field`, `date_from`, `date_to`) while MCP takes a nested
|
|
227
|
+
// `date_filter: { field, from, to }` object. Both lower to the same
|
|
228
|
+
// store-level `dateFilter` shape — the difference is just that query
|
|
229
|
+
// strings are flat by nature. This mirrors the broader REST/MCP
|
|
230
|
+
// pattern across the API and is intentional, not a fix-it-up TODO.
|
|
192
231
|
const tags = parseQueryList(url, "tag");
|
|
193
232
|
let results: Note[];
|
|
194
233
|
try {
|
|
@@ -201,8 +240,22 @@ export async function handleNotes(
|
|
|
201
240
|
path: parseQuery(url, "path") ?? undefined,
|
|
202
241
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
203
242
|
metadata: undefined, // metadata filter not practical in query params
|
|
204
|
-
|
|
205
|
-
|
|
243
|
+
// `date_field=<name>&date_from=...&date_to=...` activates the
|
|
244
|
+
// generalized filter (filters on the named indexed field). Without
|
|
245
|
+
// `date_field`, `date_from`/`date_to` keep their legacy meaning of
|
|
246
|
+
// filtering on `created_at` (vault ingestion time).
|
|
247
|
+
...(parseQuery(url, "date_field")
|
|
248
|
+
? {
|
|
249
|
+
dateFilter: {
|
|
250
|
+
field: parseQuery(url, "date_field")!,
|
|
251
|
+
from: parseQuery(url, "date_from") ?? undefined,
|
|
252
|
+
to: parseQuery(url, "date_to") ?? undefined,
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
: {
|
|
256
|
+
dateFrom: parseQuery(url, "date_from") ?? undefined,
|
|
257
|
+
dateTo: parseQuery(url, "date_to") ?? undefined,
|
|
258
|
+
}),
|
|
206
259
|
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
207
260
|
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
208
261
|
limit: parseInt10(parseQuery(url, "limit")) ?? 50,
|
|
@@ -223,6 +276,10 @@ export async function handleNotes(
|
|
|
223
276
|
if (nearNoteId) {
|
|
224
277
|
const anchor = await resolveNote(store, nearNoteId);
|
|
225
278
|
if (!anchor) return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
|
|
279
|
+
// Tag-scope: anchor must itself be visible to this token.
|
|
280
|
+
if (!noteWithinTagScope(anchor, tagScope.allowed, tagScope.raw)) {
|
|
281
|
+
return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
|
|
282
|
+
}
|
|
226
283
|
const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
|
|
227
284
|
const relationship = parseQuery(url, "near[relationship]") ?? undefined;
|
|
228
285
|
const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship });
|
|
@@ -230,6 +287,11 @@ export async function handleNotes(
|
|
|
230
287
|
results = results.filter((n) => nearScope.has(n.id));
|
|
231
288
|
}
|
|
232
289
|
|
|
290
|
+
// Tag-scope: drop any result outside the allowlist before shaping
|
|
291
|
+
// output. Same semantics as the search path — empty result is 200 [],
|
|
292
|
+
// not 403.
|
|
293
|
+
results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
|
|
294
|
+
|
|
233
295
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
234
296
|
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
235
297
|
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
@@ -285,25 +347,110 @@ export async function handleNotes(
|
|
|
285
347
|
const body = await req.json() as any;
|
|
286
348
|
const items: any[] = body.notes ?? [body];
|
|
287
349
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
350
|
+
// Batch cap (#213): refuse oversized batches before doing any work. 500
|
|
351
|
+
// is the cap (Benjamin's number) — tighter blast radius than 1000 for
|
|
352
|
+
// the runaway-client case that flooded a deployment with 7,453 notes.
|
|
353
|
+
if (items.length > MAX_BATCH_SIZE) {
|
|
354
|
+
return json(
|
|
355
|
+
{
|
|
356
|
+
error_type: "batch_too_large",
|
|
357
|
+
error: "BatchTooLarge",
|
|
358
|
+
message: `max ${MAX_BATCH_SIZE} notes per request, got ${items.length}`,
|
|
359
|
+
limit: MAX_BATCH_SIZE,
|
|
360
|
+
},
|
|
361
|
+
413,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
297
364
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
365
|
+
// Empty-note pre-validation (#213): walk the batch first and reject the
|
|
366
|
+
// whole request if any item would be content+path empty. This makes
|
|
367
|
+
// mixed batches atomic for the empty-note case — no caller gets a
|
|
368
|
+
// half-applied batch where the prefix landed and the empty entry
|
|
369
|
+
// surfaced the 400. Mirrors the Store-level invariant exactly.
|
|
370
|
+
for (let i = 0; i < items.length; i++) {
|
|
371
|
+
const item = items[i];
|
|
372
|
+
const content = (item?.content ?? "").toString();
|
|
373
|
+
const rawPath = item?.path;
|
|
374
|
+
const pathEmpty = rawPath === undefined || rawPath === null
|
|
375
|
+
|| (typeof rawPath === "string" && rawPath.trim() === "");
|
|
376
|
+
if (!content.trim() && pathEmpty) {
|
|
377
|
+
return json(
|
|
378
|
+
{
|
|
379
|
+
error_type: "empty_note",
|
|
380
|
+
error: "EmptyNoteError",
|
|
381
|
+
message: `empty_note: a note must have either content or a path (item index ${i})`,
|
|
382
|
+
item_index: i,
|
|
383
|
+
},
|
|
384
|
+
400,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Tag-scope pre-validation: every new note in the batch must carry at
|
|
390
|
+
// least one tag inside the token's allowlist. Same atomic-batch
|
|
391
|
+
// discipline as the empty-note check — reject the whole request before
|
|
392
|
+
// any DB write so a tag-scoped token can't accidentally land a partial
|
|
393
|
+
// batch with an in-scope prefix.
|
|
394
|
+
if (tagScope.allowed) {
|
|
395
|
+
for (let i = 0; i < items.length; i++) {
|
|
396
|
+
if (!tagsWithinScope(items[i]?.tags, tagScope.allowed, tagScope.raw)) {
|
|
397
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
303
398
|
}
|
|
304
399
|
}
|
|
400
|
+
}
|
|
305
401
|
|
|
306
|
-
|
|
402
|
+
const created: Note[] = [];
|
|
403
|
+
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
404
|
+
// failure (path conflict, etc.) rolls back every prior insert. Without
|
|
405
|
+
// this, callers got half-applied batches where the prefix landed and
|
|
406
|
+
// the offending entry surfaced the 409 — see #236. Single-item posts
|
|
407
|
+
// are already atomic at the store layer and skip the wrap so they
|
|
408
|
+
// don't collide with concurrent single-item callers on the shared
|
|
409
|
+
// bun:sqlite connection.
|
|
410
|
+
const batched = items.length > 1;
|
|
411
|
+
if (batched) db.exec("BEGIN");
|
|
412
|
+
try {
|
|
413
|
+
for (const item of items) {
|
|
414
|
+
const note = await store.createNote(item.content ?? "", {
|
|
415
|
+
id: item.id,
|
|
416
|
+
path: item.path,
|
|
417
|
+
tags: item.tags,
|
|
418
|
+
metadata: item.metadata,
|
|
419
|
+
created_at: item.createdAt ?? item.created_at,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// Create explicit links
|
|
423
|
+
if (item.links) {
|
|
424
|
+
for (const link of item.links as { target: string; relationship: string }[]) {
|
|
425
|
+
const target = await resolveNote(store, link.target);
|
|
426
|
+
if (target) await store.createLink(note.id, target.id, link.relationship);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
created.push((await store.getNote(note.id)) ?? note);
|
|
431
|
+
}
|
|
432
|
+
if (batched) db.exec("COMMIT");
|
|
433
|
+
} catch (e: any) {
|
|
434
|
+
if (batched) db.exec("ROLLBACK");
|
|
435
|
+
// Duck-type for module-boundary robustness (matches the PATCH branch).
|
|
436
|
+
if (e && e.code === "PATH_CONFLICT") {
|
|
437
|
+
return json(
|
|
438
|
+
{ error_type: "path_conflict", error: "path_conflict", path: e.path, message: e.message },
|
|
439
|
+
409,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (e && e.code === "EMPTY_NOTE") {
|
|
443
|
+
return json(
|
|
444
|
+
{
|
|
445
|
+
error_type: "empty_note",
|
|
446
|
+
error: "EmptyNoteError",
|
|
447
|
+
message: e.message,
|
|
448
|
+
item_index: e.item_index ?? null,
|
|
449
|
+
},
|
|
450
|
+
400,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
throw e;
|
|
307
454
|
}
|
|
308
455
|
|
|
309
456
|
// Apply tag schema defaults
|
|
@@ -323,7 +470,7 @@ export async function handleNotes(
|
|
|
323
470
|
const idMatch = subpath.match(/^\/([^/]+)(\/.*)?$/);
|
|
324
471
|
if (!idMatch) return json({ error: "Not found" }, 404);
|
|
325
472
|
|
|
326
|
-
const idOrPath = decodeURIComponent(idMatch[1]);
|
|
473
|
+
const idOrPath = decodeURIComponent(idMatch[1]!);
|
|
327
474
|
const sub = idMatch[2] ?? "";
|
|
328
475
|
|
|
329
476
|
// Attachments sub-routes (keep as-is — Daily needs them)
|
|
@@ -331,6 +478,9 @@ export async function handleNotes(
|
|
|
331
478
|
if (method === "POST") {
|
|
332
479
|
const note = await resolveNote(store, idOrPath);
|
|
333
480
|
if (!note) return json({ error: "Not found" }, 404);
|
|
481
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
482
|
+
return json({ error: "Not found" }, 404);
|
|
483
|
+
}
|
|
334
484
|
const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
|
|
335
485
|
if (!body.path || !body.mimeType) return json({ error: "path and mimeType are required" }, 400);
|
|
336
486
|
|
|
@@ -361,6 +511,9 @@ export async function handleNotes(
|
|
|
361
511
|
if (method === "GET") {
|
|
362
512
|
const note = await resolveNote(store, idOrPath);
|
|
363
513
|
if (!note) return json({ error: "Not found" }, 404);
|
|
514
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
515
|
+
return json({ error: "Not found" }, 404);
|
|
516
|
+
}
|
|
364
517
|
return json(await store.getAttachments(note.id));
|
|
365
518
|
}
|
|
366
519
|
return json({ error: "Method not allowed" }, 405);
|
|
@@ -372,6 +525,9 @@ export async function handleNotes(
|
|
|
372
525
|
if (method === "DELETE") {
|
|
373
526
|
const note = await resolveNote(store, idOrPath);
|
|
374
527
|
if (!note) return json({ error: "Not found" }, 404);
|
|
528
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
529
|
+
return json({ error: "Not found" }, 404);
|
|
530
|
+
}
|
|
375
531
|
const result = await store.deleteAttachment(note.id, attId);
|
|
376
532
|
if (!result.deleted) return json({ error: "Not found" }, 404);
|
|
377
533
|
// Unlink the storage file only if no other attachment still references
|
|
@@ -395,6 +551,9 @@ export async function handleNotes(
|
|
|
395
551
|
if (method === "GET") {
|
|
396
552
|
const note = await resolveNote(store, idOrPath);
|
|
397
553
|
if (!note) return json({ error: "Not found" }, 404);
|
|
554
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
555
|
+
return json({ error: "Not found" }, 404);
|
|
556
|
+
}
|
|
398
557
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
399
558
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
400
559
|
const expand = parseExpandParams(url, db);
|
|
@@ -417,13 +576,60 @@ export async function handleNotes(
|
|
|
417
576
|
try {
|
|
418
577
|
const note = await resolveNote(store, idOrPath);
|
|
419
578
|
if (!note) throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
579
|
+
// Tag-scope: existing note must be in scope. Mirror the read-side
|
|
580
|
+
// 404-not-403 stance — a token can't see (and therefore can't
|
|
581
|
+
// discover-then-modify) notes outside its allowlist.
|
|
582
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
583
|
+
throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
584
|
+
}
|
|
420
585
|
const body = await req.json() as any;
|
|
586
|
+
// Tag-scope: post-update tag set must still satisfy scope. Compute
|
|
587
|
+
// the prospective tag set (existing − removed + added) and reject
|
|
588
|
+
// before any write if it would drift outside the allowlist. This
|
|
589
|
+
// covers the bot-untags-its-only-allowlisted-tag escape route.
|
|
590
|
+
if (tagScope.allowed) {
|
|
591
|
+
const removed = new Set<string>((body.tags?.remove as string[] | undefined) ?? []);
|
|
592
|
+
const projected = new Set<string>((note.tags ?? []).filter((t) => !removed.has(t)));
|
|
593
|
+
for (const t of (body.tags?.add as string[] | undefined) ?? []) projected.add(t);
|
|
594
|
+
if (!tagsWithinScope([...projected], tagScope.allowed, tagScope.raw)) {
|
|
595
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// --- Validate mutual exclusion of content modes ---
|
|
600
|
+
const hasContent = body.content !== undefined;
|
|
601
|
+
const hasAppendPrepend = body.append !== undefined || body.prepend !== undefined;
|
|
602
|
+
const hasContentEdit = body.content_edit !== undefined;
|
|
603
|
+
const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
|
|
604
|
+
if (contentModes > 1) {
|
|
605
|
+
return json(
|
|
606
|
+
{
|
|
607
|
+
error: "mutually_exclusive",
|
|
608
|
+
message: "`content`, `append`/`prepend`, and `content_edit` are mutually exclusive — pick one mode of content update.",
|
|
609
|
+
},
|
|
610
|
+
400,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
421
613
|
|
|
422
614
|
// --- Safety-by-default: refuse mutations without a precondition ---
|
|
423
615
|
// Mirror the MCP tool: require `if_updated_at` unless the caller
|
|
424
616
|
// explicitly sets `force: true`. 428 Precondition Required is the
|
|
425
617
|
// RFC 6585 status for exactly this case.
|
|
426
|
-
|
|
618
|
+
//
|
|
619
|
+
// Append/prepend-only updates are exempt — SQL-atomic concatenation
|
|
620
|
+
// is no-conflict-by-design. Tag/link mutations are *not* exempt
|
|
621
|
+
// (#201): they're idempotent set-ops, but still represent a
|
|
622
|
+
// non-content change the caller should observe before re-asserting.
|
|
623
|
+
const isAppendOnly = hasAppendPrepend
|
|
624
|
+
&& !hasContent
|
|
625
|
+
&& !hasContentEdit
|
|
626
|
+
&& body.path === undefined
|
|
627
|
+
&& body.metadata === undefined
|
|
628
|
+
&& body.created_at === undefined
|
|
629
|
+
&& body.createdAt === undefined
|
|
630
|
+
&& body.tags === undefined
|
|
631
|
+
&& body.links === undefined;
|
|
632
|
+
if (!isAppendOnly && body.if_updated_at === undefined && body.force !== true) {
|
|
427
633
|
return json(
|
|
428
634
|
{
|
|
429
635
|
error_type: "precondition_required",
|
|
@@ -437,10 +643,40 @@ export async function handleNotes(
|
|
|
437
643
|
);
|
|
438
644
|
}
|
|
439
645
|
|
|
646
|
+
// --- Resolve content_edit into a full content string ---
|
|
647
|
+
let contentOverride = body.content as string | undefined;
|
|
648
|
+
if (hasContentEdit) {
|
|
649
|
+
const ce = body.content_edit as { old_text?: unknown; new_text?: unknown };
|
|
650
|
+
if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
|
|
651
|
+
return json(
|
|
652
|
+
{ error: "bad_request", message: "`content_edit` requires { old_text: string, new_text: string }." },
|
|
653
|
+
400,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
const idx = note.content.indexOf(ce.old_text);
|
|
657
|
+
if (idx < 0) {
|
|
658
|
+
// 422 Unprocessable Entity, not 404: the note exists, the request is
|
|
659
|
+
// syntactically valid, but the search string can't be applied to the
|
|
660
|
+
// current content. Returning 404 implied "note doesn't exist" and
|
|
661
|
+
// confused operators chasing a missing record (#202).
|
|
662
|
+
return json(
|
|
663
|
+
{ error: "unprocessable_content", message: `content_edit: \`old_text\` not found in note "${note.id}". Re-read and retry.` },
|
|
664
|
+
422,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
const second = note.content.indexOf(ce.old_text, idx + 1);
|
|
668
|
+
if (second >= 0) {
|
|
669
|
+
return json(
|
|
670
|
+
{ error: "ambiguous", message: `content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context.` },
|
|
671
|
+
409,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
|
|
675
|
+
}
|
|
676
|
+
|
|
440
677
|
// --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
|
|
441
678
|
// The actual link deletions happen only after the core UPDATE succeeds,
|
|
442
679
|
// so a conflict leaves the note untouched.
|
|
443
|
-
let contentOverride = body.content as string | undefined;
|
|
444
680
|
const linksRemove = body.links?.remove as { target: string; relationship: string }[] | undefined;
|
|
445
681
|
const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
|
|
446
682
|
if (linksRemove) {
|
|
@@ -449,7 +685,12 @@ export async function handleNotes(
|
|
|
449
685
|
if (!target) continue;
|
|
450
686
|
resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
|
|
451
687
|
if (link.relationship === "wikilink" && target.path) {
|
|
452
|
-
|
|
688
|
+
// Materialize the prospective content for append/prepend callers
|
|
689
|
+
// so we don't fight the SQL-atomic path with a JS-level rewrite.
|
|
690
|
+
const current = contentOverride
|
|
691
|
+
?? (hasAppendPrepend
|
|
692
|
+
? (body.prepend as string ?? "") + note.content + (body.append as string ?? "")
|
|
693
|
+
: note.content);
|
|
453
694
|
const cleaned = removeWikilinkBrackets(current, target.path);
|
|
454
695
|
if (cleaned !== current) contentOverride = cleaned;
|
|
455
696
|
}
|
|
@@ -458,7 +699,12 @@ export async function handleNotes(
|
|
|
458
699
|
|
|
459
700
|
// --- Core update (runs the if_updated_at check atomically) ---
|
|
460
701
|
const updates: any = {};
|
|
461
|
-
if (contentOverride !== undefined)
|
|
702
|
+
if (contentOverride !== undefined) {
|
|
703
|
+
updates.content = contentOverride;
|
|
704
|
+
} else if (hasAppendPrepend) {
|
|
705
|
+
if (body.append !== undefined) updates.append = body.append;
|
|
706
|
+
if (body.prepend !== undefined) updates.prepend = body.prepend;
|
|
707
|
+
}
|
|
462
708
|
if (body.path !== undefined) updates.path = body.path;
|
|
463
709
|
if (body.metadata !== undefined) {
|
|
464
710
|
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
@@ -521,6 +767,27 @@ export async function handleNotes(
|
|
|
521
767
|
409,
|
|
522
768
|
);
|
|
523
769
|
}
|
|
770
|
+
// Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
|
|
771
|
+
if (e && e.code === "PATH_CONFLICT") {
|
|
772
|
+
return json(
|
|
773
|
+
{ error_type: "path_conflict", error: "path_conflict", path: e.path, message: e.message },
|
|
774
|
+
409,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
// Empty-note guard from the Store boundary (#213) — the proposed update
|
|
778
|
+
// would clear both content AND path. Surface as 400 so callers can fix
|
|
779
|
+
// the request without retrying.
|
|
780
|
+
if (e && e.code === "EMPTY_NOTE") {
|
|
781
|
+
return json(
|
|
782
|
+
{
|
|
783
|
+
error_type: "empty_note",
|
|
784
|
+
error: "EmptyNoteError",
|
|
785
|
+
message: e.message,
|
|
786
|
+
note_id: e.note_id ?? null,
|
|
787
|
+
},
|
|
788
|
+
400,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
524
791
|
throw e;
|
|
525
792
|
}
|
|
526
793
|
}
|
|
@@ -529,6 +796,11 @@ export async function handleNotes(
|
|
|
529
796
|
if (method === "DELETE") {
|
|
530
797
|
const note = await resolveNote(store, idOrPath);
|
|
531
798
|
if (!note) return json({ error: "Not found" }, 404);
|
|
799
|
+
// Tag-scope: can't delete what you can't read. 404 (not 403) for the
|
|
800
|
+
// same no-leak reason as the read paths.
|
|
801
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
802
|
+
return json({ error: "Not found" }, 404);
|
|
803
|
+
}
|
|
532
804
|
await store.deleteNote(note.id);
|
|
533
805
|
return json({ deleted: true, id: note.id });
|
|
534
806
|
}
|
|
@@ -541,7 +813,12 @@ export async function handleNotes(
|
|
|
541
813
|
// POST /api/tags/:name/rename
|
|
542
814
|
// ---------------------------------------------------------------------------
|
|
543
815
|
|
|
544
|
-
export async function handleTags(
|
|
816
|
+
export async function handleTags(
|
|
817
|
+
req: Request,
|
|
818
|
+
store: Store,
|
|
819
|
+
subpath = "",
|
|
820
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
821
|
+
): Promise<Response> {
|
|
545
822
|
const url = new URL(req.url);
|
|
546
823
|
|
|
547
824
|
// GET /tags — list all, or get single tag detail
|
|
@@ -549,27 +826,49 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
549
826
|
const singleTag = parseQuery(url, "tag");
|
|
550
827
|
|
|
551
828
|
if (singleTag) {
|
|
829
|
+
// Tag-scope: a tag-scoped token can only see tags reachable from its
|
|
830
|
+
// allowlist (root + descendants per the parent_names hierarchy).
|
|
831
|
+
// Anything else 404s — same "no leak" stance as note reads.
|
|
832
|
+
if (tagScope.allowed && !tagScope.allowed.has(singleTag)) {
|
|
833
|
+
return json({ error: "Tag not found", tag: singleTag }, 404);
|
|
834
|
+
}
|
|
552
835
|
const allTags = await store.listTags();
|
|
553
836
|
const found = allTags.find((t) => t.name === singleTag);
|
|
554
|
-
const
|
|
837
|
+
const record = await store.getTagRecord(singleTag);
|
|
555
838
|
return json({
|
|
556
839
|
name: singleTag,
|
|
557
840
|
count: found?.count ?? 0,
|
|
558
|
-
description:
|
|
559
|
-
fields:
|
|
841
|
+
description: record?.description ?? null,
|
|
842
|
+
fields: record?.fields ?? null,
|
|
843
|
+
relationships: record?.relationships ?? null,
|
|
844
|
+
parent_names: record?.parent_names ?? null,
|
|
845
|
+
created_at: record?.created_at ?? null,
|
|
846
|
+
updated_at: record?.updated_at ?? null,
|
|
560
847
|
});
|
|
561
848
|
}
|
|
562
849
|
|
|
563
850
|
const tags = await store.listTags();
|
|
851
|
+
const filtered = tagScope.allowed
|
|
852
|
+
? tags.filter((t) => tagScope.allowed!.has(t.name))
|
|
853
|
+
: tags;
|
|
564
854
|
if (parseBool(parseQuery(url, "include_schema"), false)) {
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
855
|
+
const records = new Map(
|
|
856
|
+
(await store.listTagRecords()).map((r) => [r.tag, r] as const),
|
|
857
|
+
);
|
|
858
|
+
return json(filtered.map((t) => {
|
|
859
|
+
const r = records.get(t.name);
|
|
860
|
+
return {
|
|
861
|
+
...t,
|
|
862
|
+
description: r?.description ?? null,
|
|
863
|
+
fields: r?.fields ?? null,
|
|
864
|
+
relationships: r?.relationships ?? null,
|
|
865
|
+
parent_names: r?.parent_names ?? null,
|
|
866
|
+
created_at: r?.created_at ?? null,
|
|
867
|
+
updated_at: r?.updated_at ?? null,
|
|
868
|
+
};
|
|
869
|
+
}));
|
|
571
870
|
}
|
|
572
|
-
return json(
|
|
871
|
+
return json(filtered);
|
|
573
872
|
}
|
|
574
873
|
|
|
575
874
|
// POST /tags/merge — atomic multi-source merge into a target tag.
|
|
@@ -588,6 +887,37 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
588
887
|
if (typeof target !== "string" || target.length === 0) {
|
|
589
888
|
return json({ error: "target must be a non-empty string" }, 400);
|
|
590
889
|
}
|
|
890
|
+
// Tag-scope: every source AND the target must be inside the allowlist.
|
|
891
|
+
// A merge that pulls notes out of a token's scope (or pushes notes into
|
|
892
|
+
// it) is a privilege escalation; refuse the whole op.
|
|
893
|
+
if (tagScope.allowed) {
|
|
894
|
+
for (const t of [...sources, target]) {
|
|
895
|
+
if (!tagScope.allowed.has(t)) {
|
|
896
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// Same dependency check as DELETE /tags/:name — merging consumes every
|
|
901
|
+
// source tag, so a source referenced by a tag-scoped token would orphan
|
|
902
|
+
// that token's allowlist. Aggregate matches across sources for a single
|
|
903
|
+
// 409 envelope.
|
|
904
|
+
const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
|
|
905
|
+
const db = (store as any).db;
|
|
906
|
+
for (const src of sources) {
|
|
907
|
+
const tokens = findTokensReferencingTag(db, src as string);
|
|
908
|
+
if (tokens.length > 0) referenced.push({ source: src as string, tokens });
|
|
909
|
+
}
|
|
910
|
+
if (referenced.length > 0) {
|
|
911
|
+
return json(
|
|
912
|
+
{
|
|
913
|
+
error: "TagInUseByTokens",
|
|
914
|
+
error_type: "tag_in_use_by_tokens",
|
|
915
|
+
message: `Cannot merge: ${referenced.length} source tag(s) referenced by tag-scoped token(s); revoke or re-mint them first.`,
|
|
916
|
+
referenced_by: referenced,
|
|
917
|
+
},
|
|
918
|
+
409,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
591
921
|
const result = await store.mergeTags(sources, target);
|
|
592
922
|
return json(result);
|
|
593
923
|
}
|
|
@@ -596,13 +926,34 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
596
926
|
const renameMatch = subpath.match(/^\/([^/]+)\/rename$/);
|
|
597
927
|
if (renameMatch) {
|
|
598
928
|
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
599
|
-
const oldName = decodeURIComponent(renameMatch[1]);
|
|
929
|
+
const oldName = decodeURIComponent(renameMatch[1]!);
|
|
600
930
|
const body = (await req.json().catch(() => null)) as { new_name?: unknown } | null;
|
|
601
931
|
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
602
932
|
const newName = body.new_name;
|
|
603
933
|
if (typeof newName !== "string" || newName.length === 0) {
|
|
604
934
|
return json({ error: "new_name must be a non-empty string" }, 400);
|
|
605
935
|
}
|
|
936
|
+
if (tagScope.allowed && (!tagScope.allowed.has(oldName) || !tagScope.allowed.has(newName))) {
|
|
937
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
938
|
+
}
|
|
939
|
+
// Same dependency check as DELETE / merge — until vault#240 ships the
|
|
940
|
+
// rename→token cascade, fail-closed on referenced names so the rename
|
|
941
|
+
// can't silently orphan a token's allowlist (the JSON-stored old name
|
|
942
|
+
// would no longer match anything). When the cascade lands this becomes
|
|
943
|
+
// an unconditional cascade + audit log entry per patterns#26 §Lifecycle.
|
|
944
|
+
const referenced_by = findTokensReferencingTag((store as any).db, oldName);
|
|
945
|
+
if (referenced_by.length > 0) {
|
|
946
|
+
return json(
|
|
947
|
+
{
|
|
948
|
+
error: "TagInUseByTokens",
|
|
949
|
+
error_type: "tag_in_use_by_tokens",
|
|
950
|
+
message: `Tag "${oldName}" is referenced by ${referenced_by.length} tag-scoped token(s); revoke or re-mint them before renaming. (vault#240 will replace this with an automatic cascade.)`,
|
|
951
|
+
tag: oldName,
|
|
952
|
+
referenced_by,
|
|
953
|
+
},
|
|
954
|
+
409,
|
|
955
|
+
);
|
|
956
|
+
}
|
|
606
957
|
const result = await store.renameTag(oldName, newName);
|
|
607
958
|
if ("error" in result) {
|
|
608
959
|
if (result.error === "not_found") return json({ error: "not_found", tag: oldName }, 404);
|
|
@@ -623,47 +974,297 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
623
974
|
// Routes with tag name
|
|
624
975
|
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
625
976
|
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
626
|
-
const tagName = decodeURIComponent(nameMatch[1]);
|
|
977
|
+
const tagName = decodeURIComponent(nameMatch[1]!);
|
|
627
978
|
|
|
628
|
-
// GET /tags/:name — single tag detail
|
|
979
|
+
// GET /tags/:name — single tag detail (full record)
|
|
629
980
|
if (req.method === "GET") {
|
|
981
|
+
if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
|
|
982
|
+
return json({ error: "Tag not found", tag: tagName }, 404);
|
|
983
|
+
}
|
|
630
984
|
const allTags = await store.listTags();
|
|
631
985
|
const found = allTags.find((t) => t.name === tagName);
|
|
632
|
-
const
|
|
986
|
+
const record = await store.getTagRecord(tagName);
|
|
633
987
|
return json({
|
|
634
988
|
name: tagName,
|
|
635
989
|
count: found?.count ?? 0,
|
|
636
|
-
description:
|
|
637
|
-
fields:
|
|
990
|
+
description: record?.description ?? null,
|
|
991
|
+
fields: record?.fields ?? null,
|
|
992
|
+
relationships: record?.relationships ?? null,
|
|
993
|
+
parent_names: record?.parent_names ?? null,
|
|
994
|
+
created_at: record?.created_at ?? null,
|
|
995
|
+
updated_at: record?.updated_at ?? null,
|
|
638
996
|
});
|
|
639
997
|
}
|
|
640
998
|
|
|
641
|
-
// PUT /tags/:name — upsert tag
|
|
999
|
+
// PUT /tags/:name — upsert tag identity row. Body accepts any combination
|
|
1000
|
+
// of { description, fields, relationships, parent_names }; omitted keys
|
|
1001
|
+
// are preserved, explicit null clears. See patterns/tag-data-model.md.
|
|
642
1002
|
if (req.method === "PUT") {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
description
|
|
648
|
-
fields
|
|
1003
|
+
if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
|
|
1004
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
1005
|
+
}
|
|
1006
|
+
const body = (await req.json()) as {
|
|
1007
|
+
description?: string | null;
|
|
1008
|
+
fields?: Record<string, unknown> | null;
|
|
1009
|
+
relationships?: Record<string, unknown> | null;
|
|
1010
|
+
parent_names?: unknown;
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
// Validate relationships shape + cardinality vocabulary up front so
|
|
1014
|
+
// a bad payload returns 400, not a thrown 500.
|
|
1015
|
+
let relationshipsPatch:
|
|
1016
|
+
| Record<string, tagSchemaOps.TagRelationship>
|
|
1017
|
+
| null
|
|
1018
|
+
| undefined;
|
|
1019
|
+
if (body.relationships === null) {
|
|
1020
|
+
relationshipsPatch = null;
|
|
1021
|
+
} else if (body.relationships !== undefined) {
|
|
1022
|
+
try {
|
|
1023
|
+
relationshipsPatch = tagSchemaOps.validateRelationships(body.relationships);
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
return json(
|
|
1026
|
+
{ error: (err as Error).message, error_type: "invalid_relationships" },
|
|
1027
|
+
400,
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
let parentNamesPatch: string[] | null | undefined;
|
|
1033
|
+
if (body.parent_names === null) {
|
|
1034
|
+
parentNamesPatch = null;
|
|
1035
|
+
} else if (body.parent_names !== undefined) {
|
|
1036
|
+
if (!Array.isArray(body.parent_names)) {
|
|
1037
|
+
return json({ error: "parent_names must be an array of tag names" }, 400);
|
|
1038
|
+
}
|
|
1039
|
+
const cleaned = (body.parent_names as unknown[]).filter(
|
|
1040
|
+
(p): p is string => typeof p === "string" && p.length > 0,
|
|
1041
|
+
);
|
|
1042
|
+
parentNamesPatch = cleaned.length > 0 ? cleaned : null;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Field merge mirrors MCP update-tag — preserves prior keys when the
|
|
1046
|
+
// payload only declares new ones.
|
|
1047
|
+
let fieldsPatch:
|
|
1048
|
+
| Record<string, tagSchemaOps.TagFieldSchema>
|
|
1049
|
+
| null
|
|
1050
|
+
| undefined;
|
|
1051
|
+
if (body.fields === null) {
|
|
1052
|
+
fieldsPatch = null;
|
|
1053
|
+
} else if (body.fields !== undefined) {
|
|
1054
|
+
const existing = await store.getTagSchema(tagName);
|
|
1055
|
+
const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
|
|
1056
|
+
...(existing?.fields ?? {}),
|
|
1057
|
+
...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
|
|
1058
|
+
};
|
|
1059
|
+
fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const result = await store.upsertTagRecord(tagName, {
|
|
1063
|
+
...(body.description !== undefined ? { description: body.description } : {}),
|
|
1064
|
+
...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
|
|
1065
|
+
...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
|
|
1066
|
+
...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
|
|
649
1067
|
});
|
|
650
|
-
return json(
|
|
1068
|
+
return json(result);
|
|
651
1069
|
}
|
|
652
1070
|
|
|
653
|
-
// DELETE /tags/:name — delete tag +
|
|
1071
|
+
// DELETE /tags/:name — delete tag + identity row + remove from all notes
|
|
654
1072
|
if (req.method === "DELETE") {
|
|
655
|
-
|
|
1073
|
+
if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
|
|
1074
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
1075
|
+
}
|
|
1076
|
+
// Tag-scoped tokens reference root tags by name; deleting a referenced
|
|
1077
|
+
// tag would silently orphan the token's allowlist. Fail closed (409)
|
|
1078
|
+
// and name the offending tokens so the operator can revoke or re-mint
|
|
1079
|
+
// before retrying. patterns/tag-scoped-tokens.md §Dependencies.
|
|
1080
|
+
const referenced_by = findTokensReferencingTag((store as any).db, tagName);
|
|
1081
|
+
if (referenced_by.length > 0) {
|
|
1082
|
+
return json(
|
|
1083
|
+
{
|
|
1084
|
+
error: "TagInUseByTokens",
|
|
1085
|
+
error_type: "tag_in_use_by_tokens",
|
|
1086
|
+
message: `Tag "${tagName}" is referenced by ${referenced_by.length} tag-scoped token(s); revoke or re-mint them before deleting.`,
|
|
1087
|
+
tag: tagName,
|
|
1088
|
+
referenced_by,
|
|
1089
|
+
},
|
|
1090
|
+
409,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
656
1093
|
return json(await store.deleteTag(tagName));
|
|
657
1094
|
}
|
|
658
1095
|
|
|
659
1096
|
return json({ error: "Method not allowed" }, 405);
|
|
660
1097
|
}
|
|
661
1098
|
|
|
1099
|
+
// ---------------------------------------------------------------------------
|
|
1100
|
+
// Note schemas — GET/PUT/DELETE /api/note-schemas[/:name],
|
|
1101
|
+
// GET/POST/DELETE /api/note-schemas/:name/mappings
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
|
|
1104
|
+
export async function handleNoteSchemas(
|
|
1105
|
+
req: Request,
|
|
1106
|
+
store: Store,
|
|
1107
|
+
subpath = "",
|
|
1108
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
1109
|
+
): Promise<Response> {
|
|
1110
|
+
const url = new URL(req.url);
|
|
1111
|
+
|
|
1112
|
+
// Tag-scope filter for `tag`-kind mappings. `path_prefix` mappings carry no
|
|
1113
|
+
// tag-axis information so they're always visible/writable. The single-tag
|
|
1114
|
+
// check delegates to `tagsWithinScope` so the string-form fallback in
|
|
1115
|
+
// patterns/tag-scoped-tokens.md §Storage details is honored end-to-end.
|
|
1116
|
+
const mappingInScope = (m: { match_kind: SchemaMappingKind; match_value: string }): boolean => {
|
|
1117
|
+
if (m.match_kind !== "tag") return true;
|
|
1118
|
+
return tagsWithinScope([m.match_value], tagScope.allowed, tagScope.raw);
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
// GET /note-schemas — list all
|
|
1122
|
+
if (req.method === "GET" && subpath === "") {
|
|
1123
|
+
const schemas = await store.listNoteSchemas();
|
|
1124
|
+
if (parseBool(parseQuery(url, "include_mappings"), false)) {
|
|
1125
|
+
const allMappings = (await store.listSchemaMappings()).filter(mappingInScope);
|
|
1126
|
+
const byName = new Map<string, typeof allMappings>();
|
|
1127
|
+
for (const m of allMappings) {
|
|
1128
|
+
const list = byName.get(m.schema_name) ?? [];
|
|
1129
|
+
list.push(m);
|
|
1130
|
+
byName.set(m.schema_name, list);
|
|
1131
|
+
}
|
|
1132
|
+
return json(schemas.map((s) => ({ ...s, mappings: byName.get(s.name) ?? [] })));
|
|
1133
|
+
}
|
|
1134
|
+
return json(schemas);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// /note-schemas/:name(/mappings)
|
|
1138
|
+
const mappingsMatch = subpath.match(/^\/([^/]+)\/mappings$/);
|
|
1139
|
+
if (mappingsMatch) {
|
|
1140
|
+
const schemaName = decodeURIComponent(mappingsMatch[1]!);
|
|
1141
|
+
|
|
1142
|
+
// GET /note-schemas/:name/mappings
|
|
1143
|
+
if (req.method === "GET") {
|
|
1144
|
+
const mappings = (await store.listSchemaMappings({ schema_name: schemaName })).filter(mappingInScope);
|
|
1145
|
+
return json(mappings);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// POST /note-schemas/:name/mappings — body: { match_kind, match_value }
|
|
1149
|
+
if (req.method === "POST") {
|
|
1150
|
+
const body = (await req.json().catch(() => null)) as
|
|
1151
|
+
| { match_kind?: unknown; match_value?: unknown }
|
|
1152
|
+
| null;
|
|
1153
|
+
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
1154
|
+
const match_kind = body.match_kind;
|
|
1155
|
+
const match_value = body.match_value;
|
|
1156
|
+
if (typeof match_kind !== "string" || !MAPPING_KINDS.includes(match_kind as SchemaMappingKind)) {
|
|
1157
|
+
return json(
|
|
1158
|
+
{ error: `match_kind must be one of: ${MAPPING_KINDS.join(", ")}`, error_type: "invalid_match_kind" },
|
|
1159
|
+
400,
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
if (typeof match_value !== "string" || match_value.length === 0) {
|
|
1163
|
+
return json({ error: "match_value must be a non-empty string" }, 400);
|
|
1164
|
+
}
|
|
1165
|
+
// Tag-scope write gate: a tag mapping for an out-of-scope tag would let
|
|
1166
|
+
// a tag-scoped token bind a schema to a tag it can't see. Mirrors the
|
|
1167
|
+
// vault#241 write-gate shape (403 + tag_scope_violation envelope).
|
|
1168
|
+
if (!mappingInScope({ match_kind: match_kind as SchemaMappingKind, match_value })) {
|
|
1169
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
1170
|
+
}
|
|
1171
|
+
// Validate FK explicitly so a bad schema_name surfaces as 404 (not a
|
|
1172
|
+
// raw SQLITE_CONSTRAINT 500).
|
|
1173
|
+
if (!(await store.getNoteSchema(schemaName))) {
|
|
1174
|
+
return json({ error: "Schema not found", schema_name: schemaName }, 404);
|
|
1175
|
+
}
|
|
1176
|
+
await store.setSchemaMapping(schemaName, match_kind as SchemaMappingKind, match_value);
|
|
1177
|
+
return json({ ok: true, schema_name: schemaName, match_kind, match_value }, 201);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// DELETE /note-schemas/:name/mappings?match_kind=...&match_value=...
|
|
1181
|
+
// Query params (not URL segments) because match_value can contain slashes
|
|
1182
|
+
// for path-prefix mappings.
|
|
1183
|
+
if (req.method === "DELETE") {
|
|
1184
|
+
const match_kind = parseQuery(url, "match_kind");
|
|
1185
|
+
const match_value = parseQuery(url, "match_value");
|
|
1186
|
+
if (!match_kind || !MAPPING_KINDS.includes(match_kind as SchemaMappingKind)) {
|
|
1187
|
+
return json(
|
|
1188
|
+
{ error: `match_kind must be one of: ${MAPPING_KINDS.join(", ")}`, error_type: "invalid_match_kind" },
|
|
1189
|
+
400,
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
if (!match_value) {
|
|
1193
|
+
return json({ error: "match_value query parameter is required" }, 400);
|
|
1194
|
+
}
|
|
1195
|
+
if (!mappingInScope({ match_kind: match_kind as SchemaMappingKind, match_value })) {
|
|
1196
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
1197
|
+
}
|
|
1198
|
+
const deleted = await store.deleteSchemaMapping(
|
|
1199
|
+
schemaName,
|
|
1200
|
+
match_kind as SchemaMappingKind,
|
|
1201
|
+
match_value,
|
|
1202
|
+
);
|
|
1203
|
+
return json({ deleted, schema_name: schemaName, match_kind, match_value });
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
return json({ error: "Method not allowed" }, 405);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// /note-schemas/:name (no nested segment)
|
|
1210
|
+
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
1211
|
+
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
1212
|
+
const name = decodeURIComponent(nameMatch[1]!);
|
|
1213
|
+
|
|
1214
|
+
// GET /note-schemas/:name — single (with mappings)
|
|
1215
|
+
if (req.method === "GET") {
|
|
1216
|
+
const schema = await store.getNoteSchema(name);
|
|
1217
|
+
if (!schema) return json({ error: "Schema not found", name }, 404);
|
|
1218
|
+
const mappings = (await store.listSchemaMappings({ schema_name: name })).filter(mappingInScope);
|
|
1219
|
+
return json({ ...schema, mappings });
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// PUT /note-schemas/:name — partial-upsert (mirrors update-tag shape)
|
|
1223
|
+
if (req.method === "PUT") {
|
|
1224
|
+
const body = (await req.json().catch(() => null)) as {
|
|
1225
|
+
description?: string | null;
|
|
1226
|
+
fields?: Record<string, unknown> | null;
|
|
1227
|
+
required?: unknown;
|
|
1228
|
+
} | null;
|
|
1229
|
+
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
1230
|
+
|
|
1231
|
+
const patch: { description?: string | null; fields?: Record<string, NoteSchemaField> | null; required?: string[] | null } = {};
|
|
1232
|
+
if (body.description === null) patch.description = null;
|
|
1233
|
+
else if (body.description !== undefined) patch.description = body.description;
|
|
1234
|
+
if (body.fields === null) patch.fields = null;
|
|
1235
|
+
else if (body.fields !== undefined) patch.fields = body.fields as Record<string, NoteSchemaField>;
|
|
1236
|
+
if (body.required === null) patch.required = null;
|
|
1237
|
+
else if (body.required !== undefined) {
|
|
1238
|
+
if (!Array.isArray(body.required)) {
|
|
1239
|
+
return json({ error: "required must be an array of field names" }, 400);
|
|
1240
|
+
}
|
|
1241
|
+
patch.required = (body.required as unknown[]).filter(
|
|
1242
|
+
(x): x is string => typeof x === "string",
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const result = await store.upsertNoteSchema(name, patch);
|
|
1247
|
+
return json(result);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// DELETE /note-schemas/:name — drop schema; FK CASCADE drops its mappings.
|
|
1251
|
+
if (req.method === "DELETE") {
|
|
1252
|
+
const deleted = await store.deleteNoteSchema(name);
|
|
1253
|
+
return json({ deleted, name });
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
return json({ error: "Method not allowed" }, 405);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
662
1259
|
// ---------------------------------------------------------------------------
|
|
663
1260
|
// Find-path — GET /api/find-path?source=...&target=...
|
|
664
1261
|
// ---------------------------------------------------------------------------
|
|
665
1262
|
|
|
666
|
-
export async function handleFindPath(
|
|
1263
|
+
export async function handleFindPath(
|
|
1264
|
+
req: Request,
|
|
1265
|
+
store: Store,
|
|
1266
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
1267
|
+
): Promise<Response> {
|
|
667
1268
|
if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
|
|
668
1269
|
|
|
669
1270
|
const url = new URL(req.url);
|
|
@@ -675,11 +1276,28 @@ export async function handleFindPath(req: Request, store: Store): Promise<Respon
|
|
|
675
1276
|
try {
|
|
676
1277
|
const sourceNote = await resolveNote(store, source);
|
|
677
1278
|
if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
|
|
1279
|
+
if (!noteWithinTagScope(sourceNote, tagScope.allowed, tagScope.raw)) {
|
|
1280
|
+
return json({ error: `Note not found: "${source}"` }, 404);
|
|
1281
|
+
}
|
|
678
1282
|
const targetNote = await resolveNote(store, target);
|
|
679
1283
|
if (!targetNote) return json({ error: `Note not found: "${target}"` }, 404);
|
|
1284
|
+
if (!noteWithinTagScope(targetNote, tagScope.allowed, tagScope.raw)) {
|
|
1285
|
+
return json({ error: `Note not found: "${target}"` }, 404);
|
|
1286
|
+
}
|
|
680
1287
|
const maxDepth = Math.min(parseInt10(parseQuery(url, "max_depth")) ?? 5, 10);
|
|
681
1288
|
|
|
1289
|
+
// Tag-scope on the *path* itself: every intermediate hop must also be
|
|
1290
|
+
// within scope. A reachable target via an out-of-scope hop is not a
|
|
1291
|
+
// permitted answer — surface as "no path" (null).
|
|
682
1292
|
const result = linkOps.findPath(db, sourceNote.id, targetNote.id, { max_depth: maxDepth });
|
|
1293
|
+
if (result && tagScope.allowed) {
|
|
1294
|
+
for (const id of result.path) {
|
|
1295
|
+
const hop = await store.getNote(id);
|
|
1296
|
+
if (!hop || !noteWithinTagScope(hop, tagScope.allowed, tagScope.raw)) {
|
|
1297
|
+
return json(null);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
683
1301
|
return json(result);
|
|
684
1302
|
} catch (e: any) {
|
|
685
1303
|
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
@@ -789,7 +1407,7 @@ function renderMarkdown(md: string): string {
|
|
|
789
1407
|
let inCodeBlock = false;
|
|
790
1408
|
|
|
791
1409
|
for (let i = 0; i < lines.length; i++) {
|
|
792
|
-
const line = lines[i]
|
|
1410
|
+
const line = lines[i]!;
|
|
793
1411
|
|
|
794
1412
|
if (line.trimStart().startsWith("```")) {
|
|
795
1413
|
if (inCodeBlock) {
|
|
@@ -815,15 +1433,15 @@ function renderMarkdown(md: string): string {
|
|
|
815
1433
|
|
|
816
1434
|
const headerMatch = trimmed.match(/^(#{1,6})\s+(.+)/);
|
|
817
1435
|
if (headerMatch) {
|
|
818
|
-
const level = headerMatch[1]
|
|
819
|
-
out.push(`<h${level}>${inlineMarkdown(escapeHtml(headerMatch[2]))}</h${level}>`);
|
|
1436
|
+
const level = headerMatch[1]!.length;
|
|
1437
|
+
out.push(`<h${level}>${inlineMarkdown(escapeHtml(headerMatch[2]!))}</h${level}>`);
|
|
820
1438
|
continue;
|
|
821
1439
|
}
|
|
822
1440
|
|
|
823
1441
|
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
|
824
1442
|
const items: string[] = [trimmed.slice(2)];
|
|
825
1443
|
while (i + 1 < lines.length) {
|
|
826
|
-
const next = lines[i + 1]
|
|
1444
|
+
const next = lines[i + 1]!.trim();
|
|
827
1445
|
if (next.startsWith("- ") || next.startsWith("* ")) {
|
|
828
1446
|
items.push(next.slice(2));
|
|
829
1447
|
i++;
|
|
@@ -949,9 +1567,17 @@ export function assetsDir(vault: string): string {
|
|
|
949
1567
|
}
|
|
950
1568
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
|
951
1569
|
|
|
1570
|
+
// Storage allowlist policy:
|
|
1571
|
+
// - audio + image + .pdf (knowledge-vault content: papers, scans, receipts)
|
|
1572
|
+
// + .mp4 (mobile capture default; iOS records mp4, not webm).
|
|
1573
|
+
// - .svg and .html are deliberately excluded — both can embed `<script>`
|
|
1574
|
+
// tags, which would turn an upload into a same-origin XSS vector when
|
|
1575
|
+
// the asset is served back from /storage/. If a future use case needs
|
|
1576
|
+
// SVG, sanitize on read (strip <script>/<foreignObject>) and revisit.
|
|
952
1577
|
const ALLOWED_EXTENSIONS = new Set([
|
|
953
1578
|
".wav", ".mp3", ".m4a", ".ogg", ".webm",
|
|
954
1579
|
".png", ".jpg", ".jpeg", ".gif", ".webp",
|
|
1580
|
+
".pdf", ".mp4",
|
|
955
1581
|
]);
|
|
956
1582
|
|
|
957
1583
|
const MIME_TYPES: Record<string, string> = {
|
|
@@ -965,6 +1591,8 @@ const MIME_TYPES: Record<string, string> = {
|
|
|
965
1591
|
".jpeg": "image/jpeg",
|
|
966
1592
|
".gif": "image/gif",
|
|
967
1593
|
".webp": "image/webp",
|
|
1594
|
+
".pdf": "application/pdf",
|
|
1595
|
+
".mp4": "video/mp4",
|
|
968
1596
|
};
|
|
969
1597
|
|
|
970
1598
|
export async function handleStorage(req: Request, path: string, vault: string): Promise<Response> {
|
|
@@ -984,7 +1612,7 @@ export async function handleStorage(req: Request, path: string, vault: string):
|
|
|
984
1612
|
return json({ error: `File type ${ext} not allowed` }, 400);
|
|
985
1613
|
}
|
|
986
1614
|
|
|
987
|
-
const date = new Date().toISOString().split("T")[0]
|
|
1615
|
+
const date = new Date().toISOString().split("T")[0]!;
|
|
988
1616
|
const dir = join(assets, date);
|
|
989
1617
|
mkdirSync(dir, { recursive: true });
|
|
990
1618
|
|