@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/README.md +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -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/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- 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-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- 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 +1052 -333
- 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.
|
|
@@ -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
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
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:
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
// `date_field
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
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, () => {
|