@openparachute/vault 0.4.0 → 0.4.4-rc.11

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/src/routes.ts CHANGED
@@ -4,10 +4,8 @@
4
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
8
7
  * /api/find-path — find-path
9
8
  * /api/vault — vault-info
10
- * (synthesize-notes is MCP-only; agents call it through the MCP transport.)
11
9
  *
12
10
  * Each handler receives a Store instance (already resolved for the vault)
13
11
  * and the Request, and returns a Response.
@@ -16,9 +14,9 @@
16
14
  import type { Store, Note } from "../core/src/types.ts";
17
15
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
18
16
  import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
17
+ import { attachValidationStatus } from "../core/src/mcp.ts";
19
18
  import * as linkOps from "../core/src/links.ts";
20
19
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
21
- import { MAPPING_KINDS, type SchemaMappingKind, type NoteSchemaField } from "../core/src/note-schemas.ts";
22
20
  import {
23
21
  filterNotesByTagScope,
24
22
  noteWithinTagScope,
@@ -83,6 +81,258 @@ function parseInt10(val: string | null): number | undefined {
83
81
  return isNaN(n) ? undefined : n;
84
82
  }
85
83
 
84
+ /**
85
+ * Parse bracket-style metadata filters (vault#285 friction point 1.3).
86
+ *
87
+ * Maps `?meta[field][op]=value` (Stripe / JSON:API / Strapi convention) to
88
+ * the engine `metadata` filter shape at `core/src/notes.ts:494-509`. Recognised
89
+ * forms:
90
+ *
91
+ * - `?meta[field]=value` — shorthand equality (JSON-scan;
92
+ * no indexed-field declaration
93
+ * required)
94
+ * - `?meta[field][op]=value` — operator query (routes through
95
+ * the indexed generated column;
96
+ * engine raises FIELD_NOT_INDEXED
97
+ * if the field isn't declared)
98
+ * - `?meta[field][in][]=v1&[in][]=v2` — array form for in/not_in
99
+ * - `?meta[field][in]=v1,v2` — comma-separated form for in/not_in
100
+ *
101
+ * Supported operators mirror the engine: `eq`, `ne`, `gt`, `gte`, `lt`,
102
+ * `lte`, `in`, `not_in`, `exists`. `exists` requires `"true"` or `"false"`;
103
+ * other values reject with INVALID_OPERATOR_VALUE.
104
+ *
105
+ * Compound filters AND together: multiple `meta[a][gte]=1&meta[a][lt]=5`
106
+ * on the same field merge into one operator object; filters on different
107
+ * fields stack as independent AND clauses (engine semantics).
108
+ *
109
+ * **Bridge for the real `n.created_at` / `n.updated_at` columns** — these
110
+ * route through `dateFilter` (the existing engine path that exempts them
111
+ * from the indexed-field gate), not through the metadata-filter path. Only
112
+ * `gte` (→ inclusive `from`) and `lt` (→ exclusive `to`) are accepted on
113
+ * these fields, matching the dateFilter contract exactly. Other operators
114
+ * reject with INVALID_QUERY so callers don't think `meta[created_at][eq]=…`
115
+ * works.
116
+ *
117
+ * Returns `{ metadata?, dateFilter?, error? }`. When `error` is set the
118
+ * caller should return it directly (already shaped as a 400 with
119
+ * `error` + `code`).
120
+ */
121
+ function parseMetaBrackets(url: URL): {
122
+ metadata?: Record<string, unknown>;
123
+ dateFilter?: { field: string; from?: string; to?: string };
124
+ error?: Response;
125
+ } {
126
+ // Real columns on `notes` — exempt from the indexed-field gate, routed
127
+ // through `dateFilter` instead of `metadata`.
128
+ const REAL_DATE_COLUMNS = new Set(["created_at", "updated_at"]);
129
+ // Operators that take an array value. Used for parser-level rejection of
130
+ // `[]`-array syntax on the wrong operator (e.g. `meta[field][eq][]=value`).
131
+ const ARRAY_OPS = new Set(["in", "not_in"]);
132
+ // `meta[FIELD]` or `meta[FIELD][OP]` or `meta[FIELD][OP][]`. Field names are
133
+ // bounded by `FIELD_NAME_RE` at the engine layer; the parser is liberal here
134
+ // and lets the engine raise the loud error on bad names.
135
+ const META_RE = /^meta\[([^\]]+)\](?:\[([^\]]+)\](\[\])?)?$/;
136
+
137
+ // `metadata[field]` is either a primitive (shorthand `eq` via json_extract)
138
+ // or a sub-object of operator clauses. The two are *mutually exclusive per
139
+ // field per request*: mixing them is a silent-data-loss footgun (op set,
140
+ // then shorthand stomps; or shorthand set, then op stomps) so we reject
141
+ // loudly. Track each field's chosen form here.
142
+ const metadata: Record<string, unknown> = {};
143
+ const shorthandFields = new Set<string>();
144
+ const opBucketsByField = new Map<string, Map<string, string[]>>(); // field → op → values (array form)
145
+ const opObjectByField = new Map<string, Record<string, unknown>>(); // field → built op object (single-value ops)
146
+
147
+ // dateFilter accumulates `from` (gte) and `to` (lt) bounds on a single
148
+ // column. Spanning both `created_at` AND `updated_at` in one request is
149
+ // not expressible (the engine takes one `field`), so we reject early
150
+ // rather than silently corrupting one bound. See vault#289 review F1.
151
+ let dateField: "created_at" | "updated_at" | null = null;
152
+ let dateFrom: string | undefined;
153
+ let dateTo: string | undefined;
154
+
155
+ function rejectMixedForms(field: string): Response {
156
+ return json(
157
+ {
158
+ 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.`,
159
+ code: "INVALID_QUERY",
160
+ },
161
+ 400,
162
+ );
163
+ }
164
+
165
+ function getOpObject(field: string): Record<string, unknown> {
166
+ let bucket = opObjectByField.get(field);
167
+ if (!bucket) {
168
+ bucket = {};
169
+ opObjectByField.set(field, bucket);
170
+ }
171
+ return bucket;
172
+ }
173
+
174
+ for (const [key, value] of url.searchParams.entries()) {
175
+ const m = META_RE.exec(key);
176
+ if (!m) continue;
177
+ const field = m[1]!;
178
+ const op = m[2];
179
+ const isArray = m[3] === "[]";
180
+
181
+ // Bridge: real date columns route to dateFilter, not metadata.
182
+ if (REAL_DATE_COLUMNS.has(field)) {
183
+ if (!op) {
184
+ return {
185
+ error: json(
186
+ {
187
+ error: `bracket-date filter on \`${field}\` requires an operator: meta[${field}][gte]=… (lower bound) or meta[${field}][lt]=… (upper bound, exclusive).`,
188
+ code: "INVALID_QUERY",
189
+ },
190
+ 400,
191
+ ),
192
+ };
193
+ }
194
+ if (op !== "gte" && op !== "lt") {
195
+ return {
196
+ error: json(
197
+ {
198
+ 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.`,
199
+ code: "INVALID_QUERY",
200
+ },
201
+ 400,
202
+ ),
203
+ };
204
+ }
205
+ // F1: dateFilter takes a single column. Reject the cross-column
206
+ // case before assigning — otherwise the second column's
207
+ // assignment would silently override the first.
208
+ if (dateField !== null && dateField !== field) {
209
+ return {
210
+ error: json(
211
+ {
212
+ error: `bracket-date filter cannot span both \`created_at\` and \`updated_at\` in one request — issue two queries or use one column per request.`,
213
+ code: "INVALID_QUERY",
214
+ },
215
+ 400,
216
+ ),
217
+ };
218
+ }
219
+ dateField = field as "created_at" | "updated_at";
220
+ if (op === "gte") dateFrom = value;
221
+ else dateTo = value;
222
+ continue;
223
+ }
224
+
225
+ // Regular metadata field.
226
+ if (!op) {
227
+ // Shorthand: `?meta[field]=value` → primitive (engine routes through
228
+ // json_extract; no indexed declaration required).
229
+ // F2: reject if any operator form already wrote a bucket for this
230
+ // field — the two shapes don't compose and the silent stomp would
231
+ // drop one form's intent. Mirror check for the reverse order below.
232
+ if (opObjectByField.has(field) || opBucketsByField.has(field)) {
233
+ return { error: rejectMixedForms(field) };
234
+ }
235
+ shorthandFields.add(field);
236
+ metadata[field] = value;
237
+ continue;
238
+ }
239
+ // F2: reject if shorthand already wrote a primitive for this field.
240
+ if (shorthandFields.has(field)) {
241
+ return { error: rejectMixedForms(field) };
242
+ }
243
+ // F4: `[]`-array syntax only makes sense for `in` / `not_in`. Other ops
244
+ // (eq, gt, exists, …) take a scalar; `meta[field][eq][]=v` is a
245
+ // shape error — surface it at the parser layer with a clear message
246
+ // instead of letting the engine raise a generic INVALID_OPERATOR_VALUE
247
+ // downstream.
248
+ if (isArray && !ARRAY_OPS.has(op)) {
249
+ return {
250
+ error: json(
251
+ {
252
+ 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.`,
253
+ code: "INVALID_OPERATOR_VALUE",
254
+ },
255
+ 400,
256
+ ),
257
+ };
258
+ }
259
+ if (isArray) {
260
+ // `meta[field][in][]=v1&meta[field][in][]=v2`. Nested map keeps
261
+ // field and op as separate dimensions — no string-concat ambiguity
262
+ // for field names that contain (or might one day be allowed to
263
+ // contain) the delimiter character. See vault#289 review F5.
264
+ let fieldBucket = opBucketsByField.get(field);
265
+ if (!fieldBucket) {
266
+ fieldBucket = new Map<string, string[]>();
267
+ opBucketsByField.set(field, fieldBucket);
268
+ }
269
+ let values = fieldBucket.get(op);
270
+ if (!values) {
271
+ values = [];
272
+ fieldBucket.set(op, values);
273
+ }
274
+ values.push(value);
275
+ continue;
276
+ }
277
+ if (op === "in" || op === "not_in") {
278
+ // Comma form: `meta[field][in]=v1,v2`. Mutually exclusive with the
279
+ // `[]` array form per field+op — last write wins if both are
280
+ // supplied; we don't reject because the resulting array is well-
281
+ // defined regardless of which form a caller picked.
282
+ const arr = value.includes(",")
283
+ ? value.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
284
+ : [value];
285
+ getOpObject(field)[op] = arr;
286
+ } else if (op === "exists") {
287
+ const bool = value === "true" ? true : value === "false" ? false : null;
288
+ if (bool === null) {
289
+ return {
290
+ error: json(
291
+ {
292
+ error: `bracket-meta filter: \`exists\` on \`${field}\` requires "true" or "false", got "${value}"`,
293
+ code: "INVALID_OPERATOR_VALUE",
294
+ },
295
+ 400,
296
+ ),
297
+ };
298
+ }
299
+ getOpObject(field)[op] = bool;
300
+ } else {
301
+ // eq / ne / gt / gte / lt / lte — all primitive, single value. Type
302
+ // coercion is left to SQLite affinity rules: indexed columns are
303
+ // declared TEXT or INTEGER, and SQLite will compare a string-shaped
304
+ // numeric correctly against an INTEGER column. The engine raises
305
+ // UNKNOWN_OPERATOR if `op` isn't in SUPPORTED_OPS.
306
+ getOpObject(field)[op] = value;
307
+ }
308
+ }
309
+
310
+ // Roll up `[]`-array buckets onto their op-objects. Done after the main
311
+ // loop so `in`/`not_in` array-form and comma-form on the same field
312
+ // collapse into one merged op-object cleanly.
313
+ for (const [field, opMap] of opBucketsByField) {
314
+ for (const [op, values] of opMap) {
315
+ getOpObject(field)[op] = values;
316
+ }
317
+ }
318
+ // Roll up op-objects onto the metadata payload.
319
+ for (const [field, opObj] of opObjectByField) {
320
+ metadata[field] = opObj;
321
+ }
322
+
323
+ const result: {
324
+ metadata?: Record<string, unknown>;
325
+ dateFilter?: { field: string; from?: string; to?: string };
326
+ } = {};
327
+ if (Object.keys(metadata).length > 0) result.metadata = metadata;
328
+ if (dateField) {
329
+ result.dateFilter = { field: dateField };
330
+ if (dateFrom !== undefined) result.dateFilter.from = dateFrom;
331
+ if (dateTo !== undefined) result.dateFilter.to = dateTo;
332
+ }
333
+ return result;
334
+ }
335
+
86
336
  /**
87
337
  * Parse include_metadata query param.
88
338
  * - absent/null → undefined (all metadata, default)
@@ -222,13 +472,32 @@ export async function handleNotes(
222
472
 
223
473
  // Structured query
224
474
  //
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.
475
+ // Two filter syntaxes coexist on this endpoint:
476
+ //
477
+ // - **Bracket-style** (canonical, vault#285 friction point 1.3):
478
+ // `?meta[field][op]=value` / `?meta[created_at][gte]=…`. Exposes
479
+ // the full engine `metadata` filter (eq/ne/gt/gte/lt/lte/in/
480
+ // not_in/exists) and the dateFilter bridge through one consistent
481
+ // shape. See `parseMetaBrackets` for the grammar.
482
+ //
483
+ // - **Flat date params** (DEPRECATED): `?date_field=created_at&
484
+ // date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
485
+ // Still functional through 0.5.x; planned removal in 0.6.0
486
+ // (vault#288). New consumers should use bracket-style.
487
+ //
488
+ // Precedence on overlap: bracket-style wins. If a caller passes both
489
+ // `meta[created_at][gte]=X` and `date_field=created_at&date_from=Y`,
490
+ // the bracket form is the dateFilter the engine sees; the flat
491
+ // params are silently dropped. We don't error — the bracket form is
492
+ // documented as canonical, and rejecting the overlap would block a
493
+ // realistic migration path where a caller half-converted their code.
494
+ //
495
+ // Surface asymmetry: REST flattens to a query string; MCP takes a
496
+ // nested `date_filter: { field, from, to }` object directly. Both
497
+ // lower to the same store-level `dateFilter` shape.
231
498
  const tags = parseQueryList(url, "tag");
499
+ const bracket = parseMetaBrackets(url);
500
+ if (bracket.error) return bracket.error;
232
501
  let results: Note[];
233
502
  try {
234
503
  results = await store.queryNotes({
@@ -239,23 +508,28 @@ export async function handleNotes(
239
508
  hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
240
509
  path: parseQuery(url, "path") ?? undefined,
241
510
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
242
- metadata: undefined, // metadata filter not practical in query params
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
- }),
511
+ metadata: bracket.metadata,
512
+ // Date-range precedence chain (highest to lowest):
513
+ // 1. Bracket-style `meta[created_at][gte]=…` (canonical).
514
+ // 2. Flat `date_field=…&date_from=…&date_to=…` (deprecated).
515
+ // 3. Legacy `date_from=…&date_to=…` (no date_field, deprecated)
516
+ // — filters on `n.created_at` by definition.
517
+ // The engine rejects combinations of `dateFilter` with the legacy
518
+ // `dateFrom`/`dateTo`, so we never set both shapes simultaneously.
519
+ ...(bracket.dateFilter
520
+ ? { dateFilter: bracket.dateFilter }
521
+ : parseQuery(url, "date_field")
522
+ ? {
523
+ dateFilter: {
524
+ field: parseQuery(url, "date_field")!,
525
+ from: parseQuery(url, "date_from") ?? undefined,
526
+ to: parseQuery(url, "date_to") ?? undefined,
527
+ },
528
+ }
529
+ : {
530
+ dateFrom: parseQuery(url, "date_from") ?? undefined,
531
+ dateTo: parseQuery(url, "date_to") ?? undefined,
532
+ }),
259
533
  sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
260
534
  orderBy: parseQuery(url, "order_by") ?? undefined,
261
535
  limit: parseInt10(parseQuery(url, "limit")) ?? 50,
@@ -460,7 +734,13 @@ export async function handleNotes(
460
734
  }
461
735
  }
462
736
 
463
- return json(body.notes ? created : created[0], 201);
737
+ // Attach `validation_status` so HTTP create-note matches the MCP
738
+ // surface (vault#287). Mirrors the MCP create-note attach site at
739
+ // `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
740
+ // unchanged when no tag declares fields, so vaults without any tag
741
+ // schemas see no behavior change.
742
+ const final = created.map((n) => attachValidationStatus(store, db, n));
743
+ return json(body.notes ? final : final[0], 201);
464
744
  }
465
745
 
466
746
  return json({ error: "Method not allowed" }, 405);
@@ -743,7 +1023,23 @@ export async function handleNotes(
743
1023
  }
744
1024
  }
745
1025
 
746
- return json(await store.getNote(note.id));
1026
+ // Response shape: full Note (back-compat default) or lean NoteIndex
1027
+ // (vault#285 friction point 2.response — opt-out for callers making
1028
+ // frequent small edits to large notes). Mirror the MCP `update-note`
1029
+ // `include_content` knob exactly, *and* `validation_status` attachment
1030
+ // (vault#287) so HTTP and MCP consumers see the same schema-validation
1031
+ // signal. Recipe matches `core/src/mcp.ts:751` — attach to the full
1032
+ // Note first, then carry the field across the lean conversion (since
1033
+ // `toNoteIndex` drops unknown fields).
1034
+ const updatedNote = await store.getNote(note.id);
1035
+ if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
1036
+ const validated = attachValidationStatus(store, db, updatedNote);
1037
+ const includeContentResp = body.include_content !== false;
1038
+ if (includeContentResp) return json(validated);
1039
+ const lean: any = toNoteIndex(validated);
1040
+ const vs = (validated as any).validation_status;
1041
+ if (vs !== undefined) lean.validation_status = vs;
1042
+ return json(lean);
747
1043
  } catch (e: any) {
748
1044
  if (e instanceof NotFoundError) return json({ error: e.message }, 404);
749
1045
  // Duck-type on `code` rather than `instanceof ConflictError`: this
@@ -936,24 +1232,10 @@ export async function handleTags(
936
1232
  if (tagScope.allowed && (!tagScope.allowed.has(oldName) || !tagScope.allowed.has(newName))) {
937
1233
  return tagScopeForbidden(tagScope.raw ?? []);
938
1234
  }
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
- }
1235
+ // Vault#240: rename now cascades to tokens.scoped_tags (and every
1236
+ // other surface). The old fail-closed token-reference check has been
1237
+ // removed; the cascade rewrites the JSON allowlist atomically. See
1238
+ // notes.ts:renameTag for the surfaces touched.
957
1239
  const result = await store.renameTag(oldName, newName);
958
1240
  if ("error" in result) {
959
1241
  if (result.error === "not_found") return json({ error: "not_found", tag: oldName }, 404);
@@ -962,7 +1244,8 @@ export async function handleTags(
962
1244
  {
963
1245
  error: "target_exists",
964
1246
  target: newName,
965
- message: "Target tag already exists; use POST /api/tags/merge to combine them.",
1247
+ conflicting: result.conflicting,
1248
+ message: "Target tag (or one of its sub-tags) already exists; use POST /api/tags/merge to combine them.",
966
1249
  },
967
1250
  409,
968
1251
  );
@@ -1096,166 +1379,6 @@ export async function handleTags(
1096
1379
  return json({ error: "Method not allowed" }, 405);
1097
1380
  }
1098
1381
 
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
-
1259
1382
  // ---------------------------------------------------------------------------
1260
1383
  // Find-path — GET /api/find-path?source=...&target=...
1261
1384
  // ---------------------------------------------------------------------------
@@ -1553,7 +1553,7 @@ describe("scope enforcement on /api/*", () => {
1553
1553
  expect(res.status).toBe(200);
1554
1554
  });
1555
1555
 
1556
- test("POST /api/tags/:name/rename → 409 when a tag-scoped token references the old name", async () => {
1556
+ test("POST /api/tags/:name/rename → 200 cascades token allowlists (vault#240)", async () => {
1557
1557
  createVault("journal");
1558
1558
  const store = getVaultStore("journal");
1559
1559
  await store.createNote("h", { tags: ["health"] });
@@ -1569,19 +1569,19 @@ describe("scope enforcement on /api/*", () => {
1569
1569
  }),
1570
1570
  path,
1571
1571
  );
1572
- expect(res.status).toBe(409);
1572
+ expect(res.status).toBe(200);
1573
1573
  const body = (await res.json()) as {
1574
- error_type?: string;
1575
- tag?: string;
1576
- referenced_by?: { id: string; label: string }[];
1574
+ renamed?: number;
1575
+ tokens_updated?: number;
1577
1576
  };
1578
- expect(body.error_type).toBe("tag_in_use_by_tokens");
1579
- expect(body.tag).toBe("health");
1580
- expect(body.referenced_by?.length).toBe(1);
1581
-
1582
- // Tag was not renamed.
1583
- expect((await store.listTags()).find((t) => t.name === "health")).toBeTruthy();
1584
- expect((await store.listTags()).find((t) => t.name === "wellness")).toBeFalsy();
1577
+ // The cascade reports per-surface counts so callers can see what shifted.
1578
+ expect(body.renamed).toBe(1);
1579
+ expect(body.tokens_updated).toBe(1);
1580
+
1581
+ // Tag is renamed; the previously-409-blocking token's scoped_tags
1582
+ // rewrites alongside.
1583
+ expect((await store.listTags()).find((t) => t.name === "health")).toBeFalsy();
1584
+ expect((await store.listTags()).find((t) => t.name === "wellness")).toBeTruthy();
1585
1585
  });
1586
1586
 
1587
1587
  test("POST /api/tags/merge → 409 when a tag-scoped token references a source", async () => {
package/src/routing.ts CHANGED
@@ -48,7 +48,6 @@ import { defaultAdminSpaDistDir, isAdminSpaPath, serveAdminSpa } from "./admin-s
48
48
  import {
49
49
  handleNotes,
50
50
  handleTags,
51
- handleNoteSchemas,
52
51
  handleFindPath,
53
52
  handleVault,
54
53
  handleUnresolvedWikilinks,
@@ -520,7 +519,6 @@ export async function route(
520
519
 
521
520
  if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
522
521
  if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
523
- if (apiPath.startsWith("/note-schemas")) return handleNoteSchemas(req, store, apiPath.slice(13), tagScope);
524
522
  if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
525
523
  if (apiPath === "/vault") {
526
524
  return handleVault(req, store, vaultConfig, () => {