@openparachute/vault 0.4.0 → 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.
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.
@@ -18,7 +16,6 @@ import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
18
16
  import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
19
17
  import * as linkOps from "../core/src/links.ts";
20
18
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
21
- import { MAPPING_KINDS, type SchemaMappingKind, type NoteSchemaField } from "../core/src/note-schemas.ts";
22
19
  import {
23
20
  filterNotesByTagScope,
24
21
  noteWithinTagScope,
@@ -83,6 +80,258 @@ function parseInt10(val: string | null): number | undefined {
83
80
  return isNaN(n) ? undefined : n;
84
81
  }
85
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
+
86
335
  /**
87
336
  * Parse include_metadata query param.
88
337
  * - absent/null → undefined (all metadata, default)
@@ -222,13 +471,32 @@ export async function handleNotes(
222
471
 
223
472
  // Structured query
224
473
  //
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.
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.
231
497
  const tags = parseQueryList(url, "tag");
498
+ const bracket = parseMetaBrackets(url);
499
+ if (bracket.error) return bracket.error;
232
500
  let results: Note[];
233
501
  try {
234
502
  results = await store.queryNotes({
@@ -239,23 +507,28 @@ export async function handleNotes(
239
507
  hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
240
508
  path: parseQuery(url, "path") ?? undefined,
241
509
  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
- }),
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
+ }),
259
532
  sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
260
533
  orderBy: parseQuery(url, "order_by") ?? undefined,
261
534
  limit: parseInt10(parseQuery(url, "limit")) ?? 50,
@@ -743,7 +1016,14 @@ export async function handleNotes(
743
1016
  }
744
1017
  }
745
1018
 
746
- 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));
747
1027
  } catch (e: any) {
748
1028
  if (e instanceof NotFoundError) return json({ error: e.message }, 404);
749
1029
  // Duck-type on `code` rather than `instanceof ConflictError`: this
@@ -936,24 +1216,10 @@ export async function handleTags(
936
1216
  if (tagScope.allowed && (!tagScope.allowed.has(oldName) || !tagScope.allowed.has(newName))) {
937
1217
  return tagScopeForbidden(tagScope.raw ?? []);
938
1218
  }
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
- }
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.
957
1223
  const result = await store.renameTag(oldName, newName);
958
1224
  if ("error" in result) {
959
1225
  if (result.error === "not_found") return json({ error: "not_found", tag: oldName }, 404);
@@ -962,7 +1228,8 @@ export async function handleTags(
962
1228
  {
963
1229
  error: "target_exists",
964
1230
  target: newName,
965
- 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.",
966
1233
  },
967
1234
  409,
968
1235
  );
@@ -1096,166 +1363,6 @@ export async function handleTags(
1096
1363
  return json({ error: "Method not allowed" }, 405);
1097
1364
  }
1098
1365
 
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
1366
  // ---------------------------------------------------------------------------
1260
1367
  // Find-path — GET /api/find-path?source=...&target=...
1261
1368
  // ---------------------------------------------------------------------------
@@ -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, () => {