@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/README.md +133 -0
- package/core/src/core.test.ts +1171 -518
- package/core/src/mcp.ts +37 -426
- package/core/src/notes.ts +405 -32
- package/core/src/schema-defaults.ts +214 -170
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +90 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +37 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +313 -206
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +875 -297
- package/core/src/note-schemas.ts +0 -232
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
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
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:
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
// `date_field
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/routing.test.ts
CHANGED
|
@@ -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 →
|
|
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(
|
|
1572
|
+
expect(res.status).toBe(200);
|
|
1573
1573
|
const body = (await res.json()) as {
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
referenced_by?: { id: string; label: string }[];
|
|
1574
|
+
renamed?: number;
|
|
1575
|
+
tokens_updated?: number;
|
|
1577
1576
|
};
|
|
1578
|
-
|
|
1579
|
-
expect(body.
|
|
1580
|
-
expect(body.
|
|
1581
|
-
|
|
1582
|
-
// Tag
|
|
1583
|
-
|
|
1584
|
-
expect((await store.listTags()).find((t) => t.name === "
|
|
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, () => {
|