@openparachute/vault 0.3.3 → 0.4.3

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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/routes.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * REST API route handlers for the multi-vault server.
3
3
  *
4
- * Mirrors the 9 MCP tools:
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
7
  * /api/find-path — find-path
@@ -13,9 +13,27 @@
13
13
 
14
14
  import type { Store, Note } from "../core/src/types.ts";
15
15
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
- import { toNoteIndex, filterMetadata } from "../core/src/notes.ts";
16
+ import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
17
17
  import * as linkOps from "../core/src/links.ts";
18
18
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
19
+ import {
20
+ filterNotesByTagScope,
21
+ noteWithinTagScope,
22
+ tagScopeForbidden,
23
+ tagsWithinScope,
24
+ } from "./tag-scope.ts";
25
+ import { findTokensReferencingTag } from "./token-store.ts";
26
+
27
+ /**
28
+ * Tag-scope context threaded through handlers. `allowed` is the
29
+ * pre-expanded set of permitted tags (root + descendants), `raw` is the
30
+ * original allowlist for error messages. Both null when the token is
31
+ * unscoped — handlers fast-path on `allowed === null` and behave
32
+ * identically to the pre-tag-scope code path.
33
+ */
34
+ export type TagScopeCtx = { allowed: Set<string> | null; raw: string[] | null };
35
+
36
+ const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
19
37
  import {
20
38
  expandContent,
21
39
  DEFAULT_EXPAND_DEPTH,
@@ -62,6 +80,258 @@ function parseInt10(val: string | null): number | undefined {
62
80
  return isNaN(n) ? undefined : n;
63
81
  }
64
82
 
83
+ /**
84
+ * Parse bracket-style metadata filters (vault#285 friction point 1.3).
85
+ *
86
+ * Maps `?meta[field][op]=value` (Stripe / JSON:API / Strapi convention) to
87
+ * the engine `metadata` filter shape at `core/src/notes.ts:494-509`. Recognised
88
+ * forms:
89
+ *
90
+ * - `?meta[field]=value` — shorthand equality (JSON-scan;
91
+ * no indexed-field declaration
92
+ * required)
93
+ * - `?meta[field][op]=value` — operator query (routes through
94
+ * the indexed generated column;
95
+ * engine raises FIELD_NOT_INDEXED
96
+ * if the field isn't declared)
97
+ * - `?meta[field][in][]=v1&[in][]=v2` — array form for in/not_in
98
+ * - `?meta[field][in]=v1,v2` — comma-separated form for in/not_in
99
+ *
100
+ * Supported operators mirror the engine: `eq`, `ne`, `gt`, `gte`, `lt`,
101
+ * `lte`, `in`, `not_in`, `exists`. `exists` requires `"true"` or `"false"`;
102
+ * other values reject with INVALID_OPERATOR_VALUE.
103
+ *
104
+ * Compound filters AND together: multiple `meta[a][gte]=1&meta[a][lt]=5`
105
+ * on the same field merge into one operator object; filters on different
106
+ * fields stack as independent AND clauses (engine semantics).
107
+ *
108
+ * **Bridge for the real `n.created_at` / `n.updated_at` columns** — these
109
+ * route through `dateFilter` (the existing engine path that exempts them
110
+ * from the indexed-field gate), not through the metadata-filter path. Only
111
+ * `gte` (→ inclusive `from`) and `lt` (→ exclusive `to`) are accepted on
112
+ * these fields, matching the dateFilter contract exactly. Other operators
113
+ * reject with INVALID_QUERY so callers don't think `meta[created_at][eq]=…`
114
+ * works.
115
+ *
116
+ * Returns `{ metadata?, dateFilter?, error? }`. When `error` is set the
117
+ * caller should return it directly (already shaped as a 400 with
118
+ * `error` + `code`).
119
+ */
120
+ function parseMetaBrackets(url: URL): {
121
+ metadata?: Record<string, unknown>;
122
+ dateFilter?: { field: string; from?: string; to?: string };
123
+ error?: Response;
124
+ } {
125
+ // Real columns on `notes` — exempt from the indexed-field gate, routed
126
+ // through `dateFilter` instead of `metadata`.
127
+ const REAL_DATE_COLUMNS = new Set(["created_at", "updated_at"]);
128
+ // Operators that take an array value. Used for parser-level rejection of
129
+ // `[]`-array syntax on the wrong operator (e.g. `meta[field][eq][]=value`).
130
+ const ARRAY_OPS = new Set(["in", "not_in"]);
131
+ // `meta[FIELD]` or `meta[FIELD][OP]` or `meta[FIELD][OP][]`. Field names are
132
+ // bounded by `FIELD_NAME_RE` at the engine layer; the parser is liberal here
133
+ // and lets the engine raise the loud error on bad names.
134
+ const META_RE = /^meta\[([^\]]+)\](?:\[([^\]]+)\](\[\])?)?$/;
135
+
136
+ // `metadata[field]` is either a primitive (shorthand `eq` via json_extract)
137
+ // or a sub-object of operator clauses. The two are *mutually exclusive per
138
+ // field per request*: mixing them is a silent-data-loss footgun (op set,
139
+ // then shorthand stomps; or shorthand set, then op stomps) so we reject
140
+ // loudly. Track each field's chosen form here.
141
+ const metadata: Record<string, unknown> = {};
142
+ const shorthandFields = new Set<string>();
143
+ const opBucketsByField = new Map<string, Map<string, string[]>>(); // field → op → values (array form)
144
+ const opObjectByField = new Map<string, Record<string, unknown>>(); // field → built op object (single-value ops)
145
+
146
+ // dateFilter accumulates `from` (gte) and `to` (lt) bounds on a single
147
+ // column. Spanning both `created_at` AND `updated_at` in one request is
148
+ // not expressible (the engine takes one `field`), so we reject early
149
+ // rather than silently corrupting one bound. See vault#289 review F1.
150
+ let dateField: "created_at" | "updated_at" | null = null;
151
+ let dateFrom: string | undefined;
152
+ let dateTo: string | undefined;
153
+
154
+ function rejectMixedForms(field: string): Response {
155
+ return json(
156
+ {
157
+ error: `bracket-meta filter: cannot mix shorthand and operator forms for the same field — \`meta[${field}]=…\` and \`meta[${field}][<op>]=…\` are mutually exclusive in one request. Pick one form.`,
158
+ code: "INVALID_QUERY",
159
+ },
160
+ 400,
161
+ );
162
+ }
163
+
164
+ function getOpObject(field: string): Record<string, unknown> {
165
+ let bucket = opObjectByField.get(field);
166
+ if (!bucket) {
167
+ bucket = {};
168
+ opObjectByField.set(field, bucket);
169
+ }
170
+ return bucket;
171
+ }
172
+
173
+ for (const [key, value] of url.searchParams.entries()) {
174
+ const m = META_RE.exec(key);
175
+ if (!m) continue;
176
+ const field = m[1]!;
177
+ const op = m[2];
178
+ const isArray = m[3] === "[]";
179
+
180
+ // Bridge: real date columns route to dateFilter, not metadata.
181
+ if (REAL_DATE_COLUMNS.has(field)) {
182
+ if (!op) {
183
+ return {
184
+ error: json(
185
+ {
186
+ error: `bracket-date filter on \`${field}\` requires an operator: meta[${field}][gte]=… (lower bound) or meta[${field}][lt]=… (upper bound, exclusive).`,
187
+ code: "INVALID_QUERY",
188
+ },
189
+ 400,
190
+ ),
191
+ };
192
+ }
193
+ if (op !== "gte" && op !== "lt") {
194
+ return {
195
+ error: json(
196
+ {
197
+ error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract uses these two ops because the equivalent flat shape (\`date_field=${field}&date_from=…&date_to=…\`) is half-open by design.`,
198
+ code: "INVALID_QUERY",
199
+ },
200
+ 400,
201
+ ),
202
+ };
203
+ }
204
+ // F1: dateFilter takes a single column. Reject the cross-column
205
+ // case before assigning — otherwise the second column's
206
+ // assignment would silently override the first.
207
+ if (dateField !== null && dateField !== field) {
208
+ return {
209
+ error: json(
210
+ {
211
+ error: `bracket-date filter cannot span both \`created_at\` and \`updated_at\` in one request — issue two queries or use one column per request.`,
212
+ code: "INVALID_QUERY",
213
+ },
214
+ 400,
215
+ ),
216
+ };
217
+ }
218
+ dateField = field as "created_at" | "updated_at";
219
+ if (op === "gte") dateFrom = value;
220
+ else dateTo = value;
221
+ continue;
222
+ }
223
+
224
+ // Regular metadata field.
225
+ if (!op) {
226
+ // Shorthand: `?meta[field]=value` → primitive (engine routes through
227
+ // json_extract; no indexed declaration required).
228
+ // F2: reject if any operator form already wrote a bucket for this
229
+ // field — the two shapes don't compose and the silent stomp would
230
+ // drop one form's intent. Mirror check for the reverse order below.
231
+ if (opObjectByField.has(field) || opBucketsByField.has(field)) {
232
+ return { error: rejectMixedForms(field) };
233
+ }
234
+ shorthandFields.add(field);
235
+ metadata[field] = value;
236
+ continue;
237
+ }
238
+ // F2: reject if shorthand already wrote a primitive for this field.
239
+ if (shorthandFields.has(field)) {
240
+ return { error: rejectMixedForms(field) };
241
+ }
242
+ // F4: `[]`-array syntax only makes sense for `in` / `not_in`. Other ops
243
+ // (eq, gt, exists, …) take a scalar; `meta[field][eq][]=v` is a
244
+ // shape error — surface it at the parser layer with a clear message
245
+ // instead of letting the engine raise a generic INVALID_OPERATOR_VALUE
246
+ // downstream.
247
+ if (isArray && !ARRAY_OPS.has(op)) {
248
+ return {
249
+ error: json(
250
+ {
251
+ error: `bracket-meta filter: array form \`meta[${field}][${op}][]=…\` is only valid for \`in\` and \`not_in\`. \`${op}\` takes a single value — use \`meta[${field}][${op}]=value\` instead.`,
252
+ code: "INVALID_OPERATOR_VALUE",
253
+ },
254
+ 400,
255
+ ),
256
+ };
257
+ }
258
+ if (isArray) {
259
+ // `meta[field][in][]=v1&meta[field][in][]=v2`. Nested map keeps
260
+ // field and op as separate dimensions — no string-concat ambiguity
261
+ // for field names that contain (or might one day be allowed to
262
+ // contain) the delimiter character. See vault#289 review F5.
263
+ let fieldBucket = opBucketsByField.get(field);
264
+ if (!fieldBucket) {
265
+ fieldBucket = new Map<string, string[]>();
266
+ opBucketsByField.set(field, fieldBucket);
267
+ }
268
+ let values = fieldBucket.get(op);
269
+ if (!values) {
270
+ values = [];
271
+ fieldBucket.set(op, values);
272
+ }
273
+ values.push(value);
274
+ continue;
275
+ }
276
+ if (op === "in" || op === "not_in") {
277
+ // Comma form: `meta[field][in]=v1,v2`. Mutually exclusive with the
278
+ // `[]` array form per field+op — last write wins if both are
279
+ // supplied; we don't reject because the resulting array is well-
280
+ // defined regardless of which form a caller picked.
281
+ const arr = value.includes(",")
282
+ ? value.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
283
+ : [value];
284
+ getOpObject(field)[op] = arr;
285
+ } else if (op === "exists") {
286
+ const bool = value === "true" ? true : value === "false" ? false : null;
287
+ if (bool === null) {
288
+ return {
289
+ error: json(
290
+ {
291
+ error: `bracket-meta filter: \`exists\` on \`${field}\` requires "true" or "false", got "${value}"`,
292
+ code: "INVALID_OPERATOR_VALUE",
293
+ },
294
+ 400,
295
+ ),
296
+ };
297
+ }
298
+ getOpObject(field)[op] = bool;
299
+ } else {
300
+ // eq / ne / gt / gte / lt / lte — all primitive, single value. Type
301
+ // coercion is left to SQLite affinity rules: indexed columns are
302
+ // declared TEXT or INTEGER, and SQLite will compare a string-shaped
303
+ // numeric correctly against an INTEGER column. The engine raises
304
+ // UNKNOWN_OPERATOR if `op` isn't in SUPPORTED_OPS.
305
+ getOpObject(field)[op] = value;
306
+ }
307
+ }
308
+
309
+ // Roll up `[]`-array buckets onto their op-objects. Done after the main
310
+ // loop so `in`/`not_in` array-form and comma-form on the same field
311
+ // collapse into one merged op-object cleanly.
312
+ for (const [field, opMap] of opBucketsByField) {
313
+ for (const [op, values] of opMap) {
314
+ getOpObject(field)[op] = values;
315
+ }
316
+ }
317
+ // Roll up op-objects onto the metadata payload.
318
+ for (const [field, opObj] of opObjectByField) {
319
+ metadata[field] = opObj;
320
+ }
321
+
322
+ const result: {
323
+ metadata?: Record<string, unknown>;
324
+ dateFilter?: { field: string; from?: string; to?: string };
325
+ } = {};
326
+ if (Object.keys(metadata).length > 0) result.metadata = metadata;
327
+ if (dateField) {
328
+ result.dateFilter = { field: dateField };
329
+ if (dateFrom !== undefined) result.dateFilter.from = dateFrom;
330
+ if (dateTo !== undefined) result.dateFilter.to = dateTo;
331
+ }
332
+ return result;
333
+ }
334
+
65
335
  /**
66
336
  * Parse include_metadata query param.
67
337
  * - absent/null → undefined (all metadata, default)
@@ -132,6 +402,7 @@ export async function handleNotes(
132
402
  store: Store,
133
403
  subpath: string,
134
404
  vault?: string,
405
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
135
406
  ): Promise<Response> {
136
407
  const url = new URL(req.url);
137
408
  const method = req.method;
@@ -148,6 +419,12 @@ export async function handleNotes(
148
419
  if (id) {
149
420
  const note = await resolveNote(store, id);
150
421
  if (!note) return json({ error: "Note not found", id }, 404);
422
+ // Tag-scope: a token can't see what its allowlist excludes. Surface
423
+ // as 404 (not 403) — the existence of the note is itself information
424
+ // we shouldn't leak across the scope boundary.
425
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
426
+ return json({ error: "Note not found", id }, 404);
427
+ }
151
428
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
152
429
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
153
430
  const expand = parseExpandParams(url, db);
@@ -169,7 +446,11 @@ export async function handleNotes(
169
446
  if (search) {
170
447
  const searchTags = parseQueryList(url, "tag");
171
448
  const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
172
- const results = await store.searchNotes(search, { tags: searchTags, limit });
449
+ const rawResults = await store.searchNotes(search, { tags: searchTags, limit });
450
+ // Tag-scope: drop any result the token isn't permitted to see. Filter
451
+ // happens after the store query so an empty post-filter list still
452
+ // returns 200 [] (consistent with "no matches"), not 403.
453
+ const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
173
454
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
174
455
  const inclMeta = parseIncludeMetadata(url);
175
456
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
@@ -189,7 +470,33 @@ export async function handleNotes(
189
470
  }
190
471
 
191
472
  // Structured query
473
+ //
474
+ // Two filter syntaxes coexist on this endpoint:
475
+ //
476
+ // - **Bracket-style** (canonical, vault#285 friction point 1.3):
477
+ // `?meta[field][op]=value` / `?meta[created_at][gte]=…`. Exposes
478
+ // the full engine `metadata` filter (eq/ne/gt/gte/lt/lte/in/
479
+ // not_in/exists) and the dateFilter bridge through one consistent
480
+ // shape. See `parseMetaBrackets` for the grammar.
481
+ //
482
+ // - **Flat date params** (DEPRECATED): `?date_field=created_at&
483
+ // date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
484
+ // Still functional through 0.5.x; planned removal in 0.6.0
485
+ // (vault#288). New consumers should use bracket-style.
486
+ //
487
+ // Precedence on overlap: bracket-style wins. If a caller passes both
488
+ // `meta[created_at][gte]=X` and `date_field=created_at&date_from=Y`,
489
+ // the bracket form is the dateFilter the engine sees; the flat
490
+ // params are silently dropped. We don't error — the bracket form is
491
+ // documented as canonical, and rejecting the overlap would block a
492
+ // realistic migration path where a caller half-converted their code.
493
+ //
494
+ // Surface asymmetry: REST flattens to a query string; MCP takes a
495
+ // nested `date_filter: { field, from, to }` object directly. Both
496
+ // lower to the same store-level `dateFilter` shape.
192
497
  const tags = parseQueryList(url, "tag");
498
+ const bracket = parseMetaBrackets(url);
499
+ if (bracket.error) return bracket.error;
193
500
  let results: Note[];
194
501
  try {
195
502
  results = await store.queryNotes({
@@ -200,9 +507,28 @@ export async function handleNotes(
200
507
  hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
201
508
  path: parseQuery(url, "path") ?? undefined,
202
509
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
203
- metadata: undefined, // metadata filter not practical in query params
204
- dateFrom: parseQuery(url, "date_from") ?? undefined,
205
- dateTo: parseQuery(url, "date_to") ?? undefined,
510
+ metadata: bracket.metadata,
511
+ // Date-range precedence chain (highest to lowest):
512
+ // 1. Bracket-style `meta[created_at][gte]=…` (canonical).
513
+ // 2. Flat `date_field=…&date_from=…&date_to=…` (deprecated).
514
+ // 3. Legacy `date_from=…&date_to=…` (no date_field, deprecated)
515
+ // — filters on `n.created_at` by definition.
516
+ // The engine rejects combinations of `dateFilter` with the legacy
517
+ // `dateFrom`/`dateTo`, so we never set both shapes simultaneously.
518
+ ...(bracket.dateFilter
519
+ ? { dateFilter: bracket.dateFilter }
520
+ : parseQuery(url, "date_field")
521
+ ? {
522
+ dateFilter: {
523
+ field: parseQuery(url, "date_field")!,
524
+ from: parseQuery(url, "date_from") ?? undefined,
525
+ to: parseQuery(url, "date_to") ?? undefined,
526
+ },
527
+ }
528
+ : {
529
+ dateFrom: parseQuery(url, "date_from") ?? undefined,
530
+ dateTo: parseQuery(url, "date_to") ?? undefined,
531
+ }),
206
532
  sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
207
533
  orderBy: parseQuery(url, "order_by") ?? undefined,
208
534
  limit: parseInt10(parseQuery(url, "limit")) ?? 50,
@@ -223,6 +549,10 @@ export async function handleNotes(
223
549
  if (nearNoteId) {
224
550
  const anchor = await resolveNote(store, nearNoteId);
225
551
  if (!anchor) return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
552
+ // Tag-scope: anchor must itself be visible to this token.
553
+ if (!noteWithinTagScope(anchor, tagScope.allowed, tagScope.raw)) {
554
+ return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
555
+ }
226
556
  const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
227
557
  const relationship = parseQuery(url, "near[relationship]") ?? undefined;
228
558
  const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship });
@@ -230,6 +560,11 @@ export async function handleNotes(
230
560
  results = results.filter((n) => nearScope.has(n.id));
231
561
  }
232
562
 
563
+ // Tag-scope: drop any result outside the allowlist before shaping
564
+ // output. Same semantics as the search path — empty result is 200 [],
565
+ // not 403.
566
+ results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
567
+
233
568
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
234
569
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
235
570
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
@@ -285,25 +620,110 @@ export async function handleNotes(
285
620
  const body = await req.json() as any;
286
621
  const items: any[] = body.notes ?? [body];
287
622
 
288
- const created: Note[] = [];
289
- for (const item of items) {
290
- const note = await store.createNote(item.content ?? "", {
291
- id: item.id,
292
- path: item.path,
293
- tags: item.tags,
294
- metadata: item.metadata,
295
- created_at: item.createdAt ?? item.created_at,
296
- });
623
+ // Batch cap (#213): refuse oversized batches before doing any work. 500
624
+ // is the cap (Benjamin's number) — tighter blast radius than 1000 for
625
+ // the runaway-client case that flooded a deployment with 7,453 notes.
626
+ if (items.length > MAX_BATCH_SIZE) {
627
+ return json(
628
+ {
629
+ error_type: "batch_too_large",
630
+ error: "BatchTooLarge",
631
+ message: `max ${MAX_BATCH_SIZE} notes per request, got ${items.length}`,
632
+ limit: MAX_BATCH_SIZE,
633
+ },
634
+ 413,
635
+ );
636
+ }
297
637
 
298
- // Create explicit links
299
- if (item.links) {
300
- for (const link of item.links as { target: string; relationship: string }[]) {
301
- const target = await resolveNote(store, link.target);
302
- if (target) await store.createLink(note.id, target.id, link.relationship);
638
+ // Empty-note pre-validation (#213): walk the batch first and reject the
639
+ // whole request if any item would be content+path empty. This makes
640
+ // mixed batches atomic for the empty-note case no caller gets a
641
+ // half-applied batch where the prefix landed and the empty entry
642
+ // surfaced the 400. Mirrors the Store-level invariant exactly.
643
+ for (let i = 0; i < items.length; i++) {
644
+ const item = items[i];
645
+ const content = (item?.content ?? "").toString();
646
+ const rawPath = item?.path;
647
+ const pathEmpty = rawPath === undefined || rawPath === null
648
+ || (typeof rawPath === "string" && rawPath.trim() === "");
649
+ if (!content.trim() && pathEmpty) {
650
+ return json(
651
+ {
652
+ error_type: "empty_note",
653
+ error: "EmptyNoteError",
654
+ message: `empty_note: a note must have either content or a path (item index ${i})`,
655
+ item_index: i,
656
+ },
657
+ 400,
658
+ );
659
+ }
660
+ }
661
+
662
+ // Tag-scope pre-validation: every new note in the batch must carry at
663
+ // least one tag inside the token's allowlist. Same atomic-batch
664
+ // discipline as the empty-note check — reject the whole request before
665
+ // any DB write so a tag-scoped token can't accidentally land a partial
666
+ // batch with an in-scope prefix.
667
+ if (tagScope.allowed) {
668
+ for (let i = 0; i < items.length; i++) {
669
+ if (!tagsWithinScope(items[i]?.tags, tagScope.allowed, tagScope.raw)) {
670
+ return tagScopeForbidden(tagScope.raw ?? []);
303
671
  }
304
672
  }
673
+ }
674
+
675
+ const created: Note[] = [];
676
+ // Wrap multi-item batches in a SQLite transaction so a mid-batch
677
+ // failure (path conflict, etc.) rolls back every prior insert. Without
678
+ // this, callers got half-applied batches where the prefix landed and
679
+ // the offending entry surfaced the 409 — see #236. Single-item posts
680
+ // are already atomic at the store layer and skip the wrap so they
681
+ // don't collide with concurrent single-item callers on the shared
682
+ // bun:sqlite connection.
683
+ const batched = items.length > 1;
684
+ if (batched) db.exec("BEGIN");
685
+ try {
686
+ for (const item of items) {
687
+ const note = await store.createNote(item.content ?? "", {
688
+ id: item.id,
689
+ path: item.path,
690
+ tags: item.tags,
691
+ metadata: item.metadata,
692
+ created_at: item.createdAt ?? item.created_at,
693
+ });
694
+
695
+ // Create explicit links
696
+ if (item.links) {
697
+ for (const link of item.links as { target: string; relationship: string }[]) {
698
+ const target = await resolveNote(store, link.target);
699
+ if (target) await store.createLink(note.id, target.id, link.relationship);
700
+ }
701
+ }
305
702
 
306
- created.push((await store.getNote(note.id)) ?? note);
703
+ created.push((await store.getNote(note.id)) ?? note);
704
+ }
705
+ if (batched) db.exec("COMMIT");
706
+ } catch (e: any) {
707
+ if (batched) db.exec("ROLLBACK");
708
+ // Duck-type for module-boundary robustness (matches the PATCH branch).
709
+ if (e && e.code === "PATH_CONFLICT") {
710
+ return json(
711
+ { error_type: "path_conflict", error: "path_conflict", path: e.path, message: e.message },
712
+ 409,
713
+ );
714
+ }
715
+ if (e && e.code === "EMPTY_NOTE") {
716
+ return json(
717
+ {
718
+ error_type: "empty_note",
719
+ error: "EmptyNoteError",
720
+ message: e.message,
721
+ item_index: e.item_index ?? null,
722
+ },
723
+ 400,
724
+ );
725
+ }
726
+ throw e;
307
727
  }
308
728
 
309
729
  // Apply tag schema defaults
@@ -323,7 +743,7 @@ export async function handleNotes(
323
743
  const idMatch = subpath.match(/^\/([^/]+)(\/.*)?$/);
324
744
  if (!idMatch) return json({ error: "Not found" }, 404);
325
745
 
326
- const idOrPath = decodeURIComponent(idMatch[1]);
746
+ const idOrPath = decodeURIComponent(idMatch[1]!);
327
747
  const sub = idMatch[2] ?? "";
328
748
 
329
749
  // Attachments sub-routes (keep as-is — Daily needs them)
@@ -331,6 +751,9 @@ export async function handleNotes(
331
751
  if (method === "POST") {
332
752
  const note = await resolveNote(store, idOrPath);
333
753
  if (!note) return json({ error: "Not found" }, 404);
754
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
755
+ return json({ error: "Not found" }, 404);
756
+ }
334
757
  const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
335
758
  if (!body.path || !body.mimeType) return json({ error: "path and mimeType are required" }, 400);
336
759
 
@@ -361,6 +784,9 @@ export async function handleNotes(
361
784
  if (method === "GET") {
362
785
  const note = await resolveNote(store, idOrPath);
363
786
  if (!note) return json({ error: "Not found" }, 404);
787
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
788
+ return json({ error: "Not found" }, 404);
789
+ }
364
790
  return json(await store.getAttachments(note.id));
365
791
  }
366
792
  return json({ error: "Method not allowed" }, 405);
@@ -372,6 +798,9 @@ export async function handleNotes(
372
798
  if (method === "DELETE") {
373
799
  const note = await resolveNote(store, idOrPath);
374
800
  if (!note) return json({ error: "Not found" }, 404);
801
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
802
+ return json({ error: "Not found" }, 404);
803
+ }
375
804
  const result = await store.deleteAttachment(note.id, attId);
376
805
  if (!result.deleted) return json({ error: "Not found" }, 404);
377
806
  // Unlink the storage file only if no other attachment still references
@@ -395,6 +824,9 @@ export async function handleNotes(
395
824
  if (method === "GET") {
396
825
  const note = await resolveNote(store, idOrPath);
397
826
  if (!note) return json({ error: "Not found" }, 404);
827
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
828
+ return json({ error: "Not found" }, 404);
829
+ }
398
830
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
399
831
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
400
832
  const expand = parseExpandParams(url, db);
@@ -417,13 +849,60 @@ export async function handleNotes(
417
849
  try {
418
850
  const note = await resolveNote(store, idOrPath);
419
851
  if (!note) throw new NotFoundError(`Note not found: "${idOrPath}"`);
852
+ // Tag-scope: existing note must be in scope. Mirror the read-side
853
+ // 404-not-403 stance — a token can't see (and therefore can't
854
+ // discover-then-modify) notes outside its allowlist.
855
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
856
+ throw new NotFoundError(`Note not found: "${idOrPath}"`);
857
+ }
420
858
  const body = await req.json() as any;
859
+ // Tag-scope: post-update tag set must still satisfy scope. Compute
860
+ // the prospective tag set (existing − removed + added) and reject
861
+ // before any write if it would drift outside the allowlist. This
862
+ // covers the bot-untags-its-only-allowlisted-tag escape route.
863
+ if (tagScope.allowed) {
864
+ const removed = new Set<string>((body.tags?.remove as string[] | undefined) ?? []);
865
+ const projected = new Set<string>((note.tags ?? []).filter((t) => !removed.has(t)));
866
+ for (const t of (body.tags?.add as string[] | undefined) ?? []) projected.add(t);
867
+ if (!tagsWithinScope([...projected], tagScope.allowed, tagScope.raw)) {
868
+ return tagScopeForbidden(tagScope.raw ?? []);
869
+ }
870
+ }
871
+
872
+ // --- Validate mutual exclusion of content modes ---
873
+ const hasContent = body.content !== undefined;
874
+ const hasAppendPrepend = body.append !== undefined || body.prepend !== undefined;
875
+ const hasContentEdit = body.content_edit !== undefined;
876
+ const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
877
+ if (contentModes > 1) {
878
+ return json(
879
+ {
880
+ error: "mutually_exclusive",
881
+ message: "`content`, `append`/`prepend`, and `content_edit` are mutually exclusive — pick one mode of content update.",
882
+ },
883
+ 400,
884
+ );
885
+ }
421
886
 
422
887
  // --- Safety-by-default: refuse mutations without a precondition ---
423
888
  // Mirror the MCP tool: require `if_updated_at` unless the caller
424
889
  // explicitly sets `force: true`. 428 Precondition Required is the
425
890
  // RFC 6585 status for exactly this case.
426
- if (body.if_updated_at === undefined && body.force !== true) {
891
+ //
892
+ // Append/prepend-only updates are exempt — SQL-atomic concatenation
893
+ // is no-conflict-by-design. Tag/link mutations are *not* exempt
894
+ // (#201): they're idempotent set-ops, but still represent a
895
+ // non-content change the caller should observe before re-asserting.
896
+ const isAppendOnly = hasAppendPrepend
897
+ && !hasContent
898
+ && !hasContentEdit
899
+ && body.path === undefined
900
+ && body.metadata === undefined
901
+ && body.created_at === undefined
902
+ && body.createdAt === undefined
903
+ && body.tags === undefined
904
+ && body.links === undefined;
905
+ if (!isAppendOnly && body.if_updated_at === undefined && body.force !== true) {
427
906
  return json(
428
907
  {
429
908
  error_type: "precondition_required",
@@ -437,10 +916,40 @@ export async function handleNotes(
437
916
  );
438
917
  }
439
918
 
919
+ // --- Resolve content_edit into a full content string ---
920
+ let contentOverride = body.content as string | undefined;
921
+ if (hasContentEdit) {
922
+ const ce = body.content_edit as { old_text?: unknown; new_text?: unknown };
923
+ if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
924
+ return json(
925
+ { error: "bad_request", message: "`content_edit` requires { old_text: string, new_text: string }." },
926
+ 400,
927
+ );
928
+ }
929
+ const idx = note.content.indexOf(ce.old_text);
930
+ if (idx < 0) {
931
+ // 422 Unprocessable Entity, not 404: the note exists, the request is
932
+ // syntactically valid, but the search string can't be applied to the
933
+ // current content. Returning 404 implied "note doesn't exist" and
934
+ // confused operators chasing a missing record (#202).
935
+ return json(
936
+ { error: "unprocessable_content", message: `content_edit: \`old_text\` not found in note "${note.id}". Re-read and retry.` },
937
+ 422,
938
+ );
939
+ }
940
+ const second = note.content.indexOf(ce.old_text, idx + 1);
941
+ if (second >= 0) {
942
+ return json(
943
+ { error: "ambiguous", message: `content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context.` },
944
+ 409,
945
+ );
946
+ }
947
+ contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
948
+ }
949
+
440
950
  // --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
441
951
  // The actual link deletions happen only after the core UPDATE succeeds,
442
952
  // so a conflict leaves the note untouched.
443
- let contentOverride = body.content as string | undefined;
444
953
  const linksRemove = body.links?.remove as { target: string; relationship: string }[] | undefined;
445
954
  const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
446
955
  if (linksRemove) {
@@ -449,7 +958,12 @@ export async function handleNotes(
449
958
  if (!target) continue;
450
959
  resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
451
960
  if (link.relationship === "wikilink" && target.path) {
452
- const current = contentOverride ?? note.content;
961
+ // Materialize the prospective content for append/prepend callers
962
+ // so we don't fight the SQL-atomic path with a JS-level rewrite.
963
+ const current = contentOverride
964
+ ?? (hasAppendPrepend
965
+ ? (body.prepend as string ?? "") + note.content + (body.append as string ?? "")
966
+ : note.content);
453
967
  const cleaned = removeWikilinkBrackets(current, target.path);
454
968
  if (cleaned !== current) contentOverride = cleaned;
455
969
  }
@@ -458,7 +972,12 @@ export async function handleNotes(
458
972
 
459
973
  // --- Core update (runs the if_updated_at check atomically) ---
460
974
  const updates: any = {};
461
- if (contentOverride !== undefined) updates.content = contentOverride;
975
+ if (contentOverride !== undefined) {
976
+ updates.content = contentOverride;
977
+ } else if (hasAppendPrepend) {
978
+ if (body.append !== undefined) updates.append = body.append;
979
+ if (body.prepend !== undefined) updates.prepend = body.prepend;
980
+ }
462
981
  if (body.path !== undefined) updates.path = body.path;
463
982
  if (body.metadata !== undefined) {
464
983
  const existing = (note.metadata as Record<string, unknown>) ?? {};
@@ -497,7 +1016,14 @@ export async function handleNotes(
497
1016
  }
498
1017
  }
499
1018
 
500
- return json(await store.getNote(note.id));
1019
+ // Response shape: full Note (back-compat default) or lean NoteIndex
1020
+ // (vault#285 friction point 2.response — opt-out for callers making
1021
+ // frequent small edits to large notes). Mirror the MCP `update-note`
1022
+ // `include_content` knob exactly.
1023
+ const updatedNote = await store.getNote(note.id);
1024
+ if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
1025
+ const includeContentResp = body.include_content !== false;
1026
+ return json(includeContentResp ? updatedNote : toNoteIndex(updatedNote));
501
1027
  } catch (e: any) {
502
1028
  if (e instanceof NotFoundError) return json({ error: e.message }, 404);
503
1029
  // Duck-type on `code` rather than `instanceof ConflictError`: this
@@ -521,6 +1047,27 @@ export async function handleNotes(
521
1047
  409,
522
1048
  );
523
1049
  }
1050
+ // Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
1051
+ if (e && e.code === "PATH_CONFLICT") {
1052
+ return json(
1053
+ { error_type: "path_conflict", error: "path_conflict", path: e.path, message: e.message },
1054
+ 409,
1055
+ );
1056
+ }
1057
+ // Empty-note guard from the Store boundary (#213) — the proposed update
1058
+ // would clear both content AND path. Surface as 400 so callers can fix
1059
+ // the request without retrying.
1060
+ if (e && e.code === "EMPTY_NOTE") {
1061
+ return json(
1062
+ {
1063
+ error_type: "empty_note",
1064
+ error: "EmptyNoteError",
1065
+ message: e.message,
1066
+ note_id: e.note_id ?? null,
1067
+ },
1068
+ 400,
1069
+ );
1070
+ }
524
1071
  throw e;
525
1072
  }
526
1073
  }
@@ -529,6 +1076,11 @@ export async function handleNotes(
529
1076
  if (method === "DELETE") {
530
1077
  const note = await resolveNote(store, idOrPath);
531
1078
  if (!note) return json({ error: "Not found" }, 404);
1079
+ // Tag-scope: can't delete what you can't read. 404 (not 403) for the
1080
+ // same no-leak reason as the read paths.
1081
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
1082
+ return json({ error: "Not found" }, 404);
1083
+ }
532
1084
  await store.deleteNote(note.id);
533
1085
  return json({ deleted: true, id: note.id });
534
1086
  }
@@ -541,7 +1093,12 @@ export async function handleNotes(
541
1093
  // POST /api/tags/:name/rename
542
1094
  // ---------------------------------------------------------------------------
543
1095
 
544
- export async function handleTags(req: Request, store: Store, subpath = ""): Promise<Response> {
1096
+ export async function handleTags(
1097
+ req: Request,
1098
+ store: Store,
1099
+ subpath = "",
1100
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
1101
+ ): Promise<Response> {
545
1102
  const url = new URL(req.url);
546
1103
 
547
1104
  // GET /tags — list all, or get single tag detail
@@ -549,27 +1106,49 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
549
1106
  const singleTag = parseQuery(url, "tag");
550
1107
 
551
1108
  if (singleTag) {
1109
+ // Tag-scope: a tag-scoped token can only see tags reachable from its
1110
+ // allowlist (root + descendants per the parent_names hierarchy).
1111
+ // Anything else 404s — same "no leak" stance as note reads.
1112
+ if (tagScope.allowed && !tagScope.allowed.has(singleTag)) {
1113
+ return json({ error: "Tag not found", tag: singleTag }, 404);
1114
+ }
552
1115
  const allTags = await store.listTags();
553
1116
  const found = allTags.find((t) => t.name === singleTag);
554
- const schema = await store.getTagSchema(singleTag);
1117
+ const record = await store.getTagRecord(singleTag);
555
1118
  return json({
556
1119
  name: singleTag,
557
1120
  count: found?.count ?? 0,
558
- description: schema?.description ?? null,
559
- fields: schema?.fields ?? null,
1121
+ description: record?.description ?? null,
1122
+ fields: record?.fields ?? null,
1123
+ relationships: record?.relationships ?? null,
1124
+ parent_names: record?.parent_names ?? null,
1125
+ created_at: record?.created_at ?? null,
1126
+ updated_at: record?.updated_at ?? null,
560
1127
  });
561
1128
  }
562
1129
 
563
1130
  const tags = await store.listTags();
1131
+ const filtered = tagScope.allowed
1132
+ ? tags.filter((t) => tagScope.allowed!.has(t.name))
1133
+ : tags;
564
1134
  if (parseBool(parseQuery(url, "include_schema"), false)) {
565
- const schemas = await store.getTagSchemaMap();
566
- return json(tags.map((t) => ({
567
- ...t,
568
- description: schemas[t.name]?.description ?? null,
569
- fields: schemas[t.name]?.fields ?? null,
570
- })));
1135
+ const records = new Map(
1136
+ (await store.listTagRecords()).map((r) => [r.tag, r] as const),
1137
+ );
1138
+ return json(filtered.map((t) => {
1139
+ const r = records.get(t.name);
1140
+ return {
1141
+ ...t,
1142
+ description: r?.description ?? null,
1143
+ fields: r?.fields ?? null,
1144
+ relationships: r?.relationships ?? null,
1145
+ parent_names: r?.parent_names ?? null,
1146
+ created_at: r?.created_at ?? null,
1147
+ updated_at: r?.updated_at ?? null,
1148
+ };
1149
+ }));
571
1150
  }
572
- return json(tags);
1151
+ return json(filtered);
573
1152
  }
574
1153
 
575
1154
  // POST /tags/merge — atomic multi-source merge into a target tag.
@@ -588,6 +1167,37 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
588
1167
  if (typeof target !== "string" || target.length === 0) {
589
1168
  return json({ error: "target must be a non-empty string" }, 400);
590
1169
  }
1170
+ // Tag-scope: every source AND the target must be inside the allowlist.
1171
+ // A merge that pulls notes out of a token's scope (or pushes notes into
1172
+ // it) is a privilege escalation; refuse the whole op.
1173
+ if (tagScope.allowed) {
1174
+ for (const t of [...sources, target]) {
1175
+ if (!tagScope.allowed.has(t)) {
1176
+ return tagScopeForbidden(tagScope.raw ?? []);
1177
+ }
1178
+ }
1179
+ }
1180
+ // Same dependency check as DELETE /tags/:name — merging consumes every
1181
+ // source tag, so a source referenced by a tag-scoped token would orphan
1182
+ // that token's allowlist. Aggregate matches across sources for a single
1183
+ // 409 envelope.
1184
+ const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
1185
+ const db = (store as any).db;
1186
+ for (const src of sources) {
1187
+ const tokens = findTokensReferencingTag(db, src as string);
1188
+ if (tokens.length > 0) referenced.push({ source: src as string, tokens });
1189
+ }
1190
+ if (referenced.length > 0) {
1191
+ return json(
1192
+ {
1193
+ error: "TagInUseByTokens",
1194
+ error_type: "tag_in_use_by_tokens",
1195
+ message: `Cannot merge: ${referenced.length} source tag(s) referenced by tag-scoped token(s); revoke or re-mint them first.`,
1196
+ referenced_by: referenced,
1197
+ },
1198
+ 409,
1199
+ );
1200
+ }
591
1201
  const result = await store.mergeTags(sources, target);
592
1202
  return json(result);
593
1203
  }
@@ -596,13 +1206,20 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
596
1206
  const renameMatch = subpath.match(/^\/([^/]+)\/rename$/);
597
1207
  if (renameMatch) {
598
1208
  if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
599
- const oldName = decodeURIComponent(renameMatch[1]);
1209
+ const oldName = decodeURIComponent(renameMatch[1]!);
600
1210
  const body = (await req.json().catch(() => null)) as { new_name?: unknown } | null;
601
1211
  if (!body) return json({ error: "Invalid JSON body" }, 400);
602
1212
  const newName = body.new_name;
603
1213
  if (typeof newName !== "string" || newName.length === 0) {
604
1214
  return json({ error: "new_name must be a non-empty string" }, 400);
605
1215
  }
1216
+ if (tagScope.allowed && (!tagScope.allowed.has(oldName) || !tagScope.allowed.has(newName))) {
1217
+ return tagScopeForbidden(tagScope.raw ?? []);
1218
+ }
1219
+ // Vault#240: rename now cascades to tokens.scoped_tags (and every
1220
+ // other surface). The old fail-closed token-reference check has been
1221
+ // removed; the cascade rewrites the JSON allowlist atomically. See
1222
+ // notes.ts:renameTag for the surfaces touched.
606
1223
  const result = await store.renameTag(oldName, newName);
607
1224
  if ("error" in result) {
608
1225
  if (result.error === "not_found") return json({ error: "not_found", tag: oldName }, 404);
@@ -611,7 +1228,8 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
611
1228
  {
612
1229
  error: "target_exists",
613
1230
  target: newName,
614
- message: "Target tag already exists; use POST /api/tags/merge to combine them.",
1231
+ conflicting: result.conflicting,
1232
+ message: "Target tag (or one of its sub-tags) already exists; use POST /api/tags/merge to combine them.",
615
1233
  },
616
1234
  409,
617
1235
  );
@@ -623,36 +1241,122 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
623
1241
  // Routes with tag name
624
1242
  const nameMatch = subpath.match(/^\/([^/]+)$/);
625
1243
  if (!nameMatch) return json({ error: "Not found" }, 404);
626
- const tagName = decodeURIComponent(nameMatch[1]);
1244
+ const tagName = decodeURIComponent(nameMatch[1]!);
627
1245
 
628
- // GET /tags/:name — single tag detail
1246
+ // GET /tags/:name — single tag detail (full record)
629
1247
  if (req.method === "GET") {
1248
+ if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
1249
+ return json({ error: "Tag not found", tag: tagName }, 404);
1250
+ }
630
1251
  const allTags = await store.listTags();
631
1252
  const found = allTags.find((t) => t.name === tagName);
632
- const schema = await store.getTagSchema(tagName);
1253
+ const record = await store.getTagRecord(tagName);
633
1254
  return json({
634
1255
  name: tagName,
635
1256
  count: found?.count ?? 0,
636
- description: schema?.description ?? null,
637
- fields: schema?.fields ?? null,
1257
+ description: record?.description ?? null,
1258
+ fields: record?.fields ?? null,
1259
+ relationships: record?.relationships ?? null,
1260
+ parent_names: record?.parent_names ?? null,
1261
+ created_at: record?.created_at ?? null,
1262
+ updated_at: record?.updated_at ?? null,
638
1263
  });
639
1264
  }
640
1265
 
641
- // PUT /tags/:name — upsert tag schema (description + fields)
1266
+ // PUT /tags/:name — upsert tag identity row. Body accepts any combination
1267
+ // of { description, fields, relationships, parent_names }; omitted keys
1268
+ // are preserved, explicit null clears. See patterns/tag-data-model.md.
642
1269
  if (req.method === "PUT") {
643
- const body = await req.json() as { description?: string; fields?: Record<string, unknown> };
644
- const existing = await store.getTagSchema(tagName);
645
- const mergedFields = { ...existing?.fields, ...(body.fields as any) };
646
- const schema = await store.upsertTagSchema(tagName, {
647
- description: body.description ?? existing?.description,
648
- fields: Object.keys(mergedFields).length > 0 ? mergedFields : undefined,
1270
+ if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
1271
+ return tagScopeForbidden(tagScope.raw ?? []);
1272
+ }
1273
+ const body = (await req.json()) as {
1274
+ description?: string | null;
1275
+ fields?: Record<string, unknown> | null;
1276
+ relationships?: Record<string, unknown> | null;
1277
+ parent_names?: unknown;
1278
+ };
1279
+
1280
+ // Validate relationships shape + cardinality vocabulary up front so
1281
+ // a bad payload returns 400, not a thrown 500.
1282
+ let relationshipsPatch:
1283
+ | Record<string, tagSchemaOps.TagRelationship>
1284
+ | null
1285
+ | undefined;
1286
+ if (body.relationships === null) {
1287
+ relationshipsPatch = null;
1288
+ } else if (body.relationships !== undefined) {
1289
+ try {
1290
+ relationshipsPatch = tagSchemaOps.validateRelationships(body.relationships);
1291
+ } catch (err) {
1292
+ return json(
1293
+ { error: (err as Error).message, error_type: "invalid_relationships" },
1294
+ 400,
1295
+ );
1296
+ }
1297
+ }
1298
+
1299
+ let parentNamesPatch: string[] | null | undefined;
1300
+ if (body.parent_names === null) {
1301
+ parentNamesPatch = null;
1302
+ } else if (body.parent_names !== undefined) {
1303
+ if (!Array.isArray(body.parent_names)) {
1304
+ return json({ error: "parent_names must be an array of tag names" }, 400);
1305
+ }
1306
+ const cleaned = (body.parent_names as unknown[]).filter(
1307
+ (p): p is string => typeof p === "string" && p.length > 0,
1308
+ );
1309
+ parentNamesPatch = cleaned.length > 0 ? cleaned : null;
1310
+ }
1311
+
1312
+ // Field merge mirrors MCP update-tag — preserves prior keys when the
1313
+ // payload only declares new ones.
1314
+ let fieldsPatch:
1315
+ | Record<string, tagSchemaOps.TagFieldSchema>
1316
+ | null
1317
+ | undefined;
1318
+ if (body.fields === null) {
1319
+ fieldsPatch = null;
1320
+ } else if (body.fields !== undefined) {
1321
+ const existing = await store.getTagSchema(tagName);
1322
+ const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
1323
+ ...(existing?.fields ?? {}),
1324
+ ...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
1325
+ };
1326
+ fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
1327
+ }
1328
+
1329
+ const result = await store.upsertTagRecord(tagName, {
1330
+ ...(body.description !== undefined ? { description: body.description } : {}),
1331
+ ...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
1332
+ ...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
1333
+ ...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
649
1334
  });
650
- return json(schema);
1335
+ return json(result);
651
1336
  }
652
1337
 
653
- // DELETE /tags/:name — delete tag + schema from all notes
1338
+ // DELETE /tags/:name — delete tag + identity row + remove from all notes
654
1339
  if (req.method === "DELETE") {
655
- await store.deleteTagSchema(tagName);
1340
+ if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
1341
+ return tagScopeForbidden(tagScope.raw ?? []);
1342
+ }
1343
+ // Tag-scoped tokens reference root tags by name; deleting a referenced
1344
+ // tag would silently orphan the token's allowlist. Fail closed (409)
1345
+ // and name the offending tokens so the operator can revoke or re-mint
1346
+ // before retrying. patterns/tag-scoped-tokens.md §Dependencies.
1347
+ const referenced_by = findTokensReferencingTag((store as any).db, tagName);
1348
+ if (referenced_by.length > 0) {
1349
+ return json(
1350
+ {
1351
+ error: "TagInUseByTokens",
1352
+ error_type: "tag_in_use_by_tokens",
1353
+ message: `Tag "${tagName}" is referenced by ${referenced_by.length} tag-scoped token(s); revoke or re-mint them before deleting.`,
1354
+ tag: tagName,
1355
+ referenced_by,
1356
+ },
1357
+ 409,
1358
+ );
1359
+ }
656
1360
  return json(await store.deleteTag(tagName));
657
1361
  }
658
1362
 
@@ -663,7 +1367,11 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
663
1367
  // Find-path — GET /api/find-path?source=...&target=...
664
1368
  // ---------------------------------------------------------------------------
665
1369
 
666
- export async function handleFindPath(req: Request, store: Store): Promise<Response> {
1370
+ export async function handleFindPath(
1371
+ req: Request,
1372
+ store: Store,
1373
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
1374
+ ): Promise<Response> {
667
1375
  if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
668
1376
 
669
1377
  const url = new URL(req.url);
@@ -675,11 +1383,28 @@ export async function handleFindPath(req: Request, store: Store): Promise<Respon
675
1383
  try {
676
1384
  const sourceNote = await resolveNote(store, source);
677
1385
  if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
1386
+ if (!noteWithinTagScope(sourceNote, tagScope.allowed, tagScope.raw)) {
1387
+ return json({ error: `Note not found: "${source}"` }, 404);
1388
+ }
678
1389
  const targetNote = await resolveNote(store, target);
679
1390
  if (!targetNote) return json({ error: `Note not found: "${target}"` }, 404);
1391
+ if (!noteWithinTagScope(targetNote, tagScope.allowed, tagScope.raw)) {
1392
+ return json({ error: `Note not found: "${target}"` }, 404);
1393
+ }
680
1394
  const maxDepth = Math.min(parseInt10(parseQuery(url, "max_depth")) ?? 5, 10);
681
1395
 
1396
+ // Tag-scope on the *path* itself: every intermediate hop must also be
1397
+ // within scope. A reachable target via an out-of-scope hop is not a
1398
+ // permitted answer — surface as "no path" (null).
682
1399
  const result = linkOps.findPath(db, sourceNote.id, targetNote.id, { max_depth: maxDepth });
1400
+ if (result && tagScope.allowed) {
1401
+ for (const id of result.path) {
1402
+ const hop = await store.getNote(id);
1403
+ if (!hop || !noteWithinTagScope(hop, tagScope.allowed, tagScope.raw)) {
1404
+ return json(null);
1405
+ }
1406
+ }
1407
+ }
683
1408
  return json(result);
684
1409
  } catch (e: any) {
685
1410
  if (e instanceof NotFoundError) return json({ error: e.message }, 404);
@@ -789,7 +1514,7 @@ function renderMarkdown(md: string): string {
789
1514
  let inCodeBlock = false;
790
1515
 
791
1516
  for (let i = 0; i < lines.length; i++) {
792
- const line = lines[i];
1517
+ const line = lines[i]!;
793
1518
 
794
1519
  if (line.trimStart().startsWith("```")) {
795
1520
  if (inCodeBlock) {
@@ -815,15 +1540,15 @@ function renderMarkdown(md: string): string {
815
1540
 
816
1541
  const headerMatch = trimmed.match(/^(#{1,6})\s+(.+)/);
817
1542
  if (headerMatch) {
818
- const level = headerMatch[1].length;
819
- out.push(`<h${level}>${inlineMarkdown(escapeHtml(headerMatch[2]))}</h${level}>`);
1543
+ const level = headerMatch[1]!.length;
1544
+ out.push(`<h${level}>${inlineMarkdown(escapeHtml(headerMatch[2]!))}</h${level}>`);
820
1545
  continue;
821
1546
  }
822
1547
 
823
1548
  if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
824
1549
  const items: string[] = [trimmed.slice(2)];
825
1550
  while (i + 1 < lines.length) {
826
- const next = lines[i + 1].trim();
1551
+ const next = lines[i + 1]!.trim();
827
1552
  if (next.startsWith("- ") || next.startsWith("* ")) {
828
1553
  items.push(next.slice(2));
829
1554
  i++;
@@ -949,9 +1674,17 @@ export function assetsDir(vault: string): string {
949
1674
  }
950
1675
  const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
951
1676
 
1677
+ // Storage allowlist policy:
1678
+ // - audio + image + .pdf (knowledge-vault content: papers, scans, receipts)
1679
+ // + .mp4 (mobile capture default; iOS records mp4, not webm).
1680
+ // - .svg and .html are deliberately excluded — both can embed `<script>`
1681
+ // tags, which would turn an upload into a same-origin XSS vector when
1682
+ // the asset is served back from /storage/. If a future use case needs
1683
+ // SVG, sanitize on read (strip <script>/<foreignObject>) and revisit.
952
1684
  const ALLOWED_EXTENSIONS = new Set([
953
1685
  ".wav", ".mp3", ".m4a", ".ogg", ".webm",
954
1686
  ".png", ".jpg", ".jpeg", ".gif", ".webp",
1687
+ ".pdf", ".mp4",
955
1688
  ]);
956
1689
 
957
1690
  const MIME_TYPES: Record<string, string> = {
@@ -965,6 +1698,8 @@ const MIME_TYPES: Record<string, string> = {
965
1698
  ".jpeg": "image/jpeg",
966
1699
  ".gif": "image/gif",
967
1700
  ".webp": "image/webp",
1701
+ ".pdf": "application/pdf",
1702
+ ".mp4": "video/mp4",
968
1703
  };
969
1704
 
970
1705
  export async function handleStorage(req: Request, path: string, vault: string): Promise<Response> {
@@ -984,7 +1719,7 @@ export async function handleStorage(req: Request, path: string, vault: string):
984
1719
  return json({ error: `File type ${ext} not allowed` }, 400);
985
1720
  }
986
1721
 
987
- const date = new Date().toISOString().split("T")[0];
1722
+ const date = new Date().toISOString().split("T")[0]!;
988
1723
  const dir = join(assets, date);
989
1724
  mkdirSync(dir, { recursive: true });
990
1725