@openparachute/vault 0.7.0-rc.4 → 0.7.0-rc.6
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/core/src/attribution.test.ts +11 -3
- package/core/src/contract-taxonomy.test.ts +193 -17
- package/core/src/contract-typed-index.test.ts +228 -11
- package/core/src/core.test.ts +67 -14
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +360 -0
- package/core/src/mcp.ts +164 -28
- package/core/src/notes.ts +98 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/schema-defaults.ts +48 -7
- package/core/src/schema.ts +147 -2
- package/core/src/seed-packs.ts +20 -13
- package/core/src/store.ts +35 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +166 -6
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +90 -5
- package/src/mirror-import.ts +5 -0
- package/src/onboarding-seed.test.ts +5 -2
- package/src/routes.ts +91 -52
- package/src/routing.ts +29 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +43 -0
- package/src/vault.test.ts +58 -35
package/core/src/tag-schemas.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { Database } from "bun:sqlite";
|
|
19
19
|
import { mapFieldType, validateFieldName } from "./indexed-fields.js";
|
|
20
|
+
import { loadTagHierarchy, findParentCycle } from "./tag-hierarchy.js";
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
// Types
|
|
@@ -43,6 +44,25 @@ export interface TagFieldSchema {
|
|
|
43
44
|
// "one" (scalar, default) or "many" (array). Advisory unless `strict:true`.
|
|
44
45
|
// Distinct from relationship cardinality. vault#299.
|
|
45
46
|
cardinality?: "one" | "many";
|
|
47
|
+
/**
|
|
48
|
+
* Explicit backfill value (vault#553 Decision B). When a note gains this
|
|
49
|
+
* tag and doesn't set the field, `applySchemaDefaults` (core/src/mcp.ts)
|
|
50
|
+
* writes THIS value onto the note. Undeclared (the default) means the
|
|
51
|
+
* field stays ABSENT on notes that don't set it — no more implicit
|
|
52
|
+
* first-enum-value / type-zero-value backfill. This is what makes
|
|
53
|
+
* `exists:false` trustworthy: "never set" and "explicitly set to the
|
|
54
|
+
* default" are now distinguishable states.
|
|
55
|
+
*
|
|
56
|
+
* Must conform to this field's own `type` (and `enum`, when declared) —
|
|
57
|
+
* `upsertTagRecord` (core/src/store.ts, the single chokepoint both REST
|
|
58
|
+
* and MCP funnel through) validates the default BEFORE persisting via
|
|
59
|
+
* {@link validateFieldDefault}; a non-conforming default is a tag-schema
|
|
60
|
+
* error (`invalid_field_default` / `TagFieldViolation` reason
|
|
61
|
+
* `"invalid_default"`), not a silent typo. `undefined` is JSON-serialized
|
|
62
|
+
* away by `JSON.stringify` — an omitted `default` key round-trips as "not
|
|
63
|
+
* declared", never as a stored `null`.
|
|
64
|
+
*/
|
|
65
|
+
default?: unknown;
|
|
46
66
|
}
|
|
47
67
|
|
|
48
68
|
/**
|
|
@@ -132,10 +152,128 @@ export function getTagRecord(db: Database, tag: string): TagRecord | null {
|
|
|
132
152
|
return row ? rowToRecord(row) : null;
|
|
133
153
|
}
|
|
134
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Thrown by `upsertTagRecord` (and therefore by both `PUT /api/tags/:name`
|
|
157
|
+
* and the MCP `update-tag` tool — this is the single chokepoint both share,
|
|
158
|
+
* see `core/src/store.ts:upsertTagRecord`) when the incoming `parent_names`
|
|
159
|
+
* would create a cycle in the hierarchy (vault#552). Traversal elsewhere
|
|
160
|
+
* (`getTagDescendants`) is already cycle-safe — a visited-set stops it from
|
|
161
|
+
* looping forever — but the WRITE itself was dishonest about creating one:
|
|
162
|
+
* an adversarial or accidental `A→B` then `B→A` (or a bare self-parent)
|
|
163
|
+
* silently succeeded pre-#552. `cycle` is the offending path, e.g.
|
|
164
|
+
* `["A", "B", "A"]` for a direct A↔B cycle, or the longer chain for a
|
|
165
|
+
* transitive one; the SERVER layer scope-scrubs out-of-scope names in it
|
|
166
|
+
* for a tag-scoped caller (`scrubParentCycleError` in src/tag-scope.ts),
|
|
167
|
+
* same posture as `TagFieldConflictError`.
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint — REST
|
|
171
|
+
* `PUT /api/tags/:name` and MCP `update-tag` both funnel through it) when a
|
|
172
|
+
* field's declared `default` (vault#553 Decision B) doesn't conform to that
|
|
173
|
+
* SAME field's own `type` (or `enum`, when declared). A bad default is a
|
|
174
|
+
* tag-schema authoring error, not silently-accepted junk that would poison
|
|
175
|
+
* every note gaining the tag — fail closed, same posture as
|
|
176
|
+
* `IndexedFieldError` and `ParentCycleError`. `error_type` is stamped
|
|
177
|
+
* directly (own-field validation leaf, one caller-facing shape) so the
|
|
178
|
+
* generic MCP domain-error mapping (`src/mcp-http.ts`) and REST's dedicated
|
|
179
|
+
* catch branch both surface it without a bespoke class-specific mapping.
|
|
180
|
+
*/
|
|
181
|
+
export class InvalidFieldDefaultError extends Error {
|
|
182
|
+
code = "INVALID_FIELD_DEFAULT" as const;
|
|
183
|
+
error_type = "invalid_field_default" as const;
|
|
184
|
+
field: string;
|
|
185
|
+
|
|
186
|
+
constructor(field: string, message: string) {
|
|
187
|
+
super(message);
|
|
188
|
+
this.name = "InvalidFieldDefaultError";
|
|
189
|
+
this.field = field;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate that a field's declared `default` (vault#553 Decision B) conforms
|
|
195
|
+
* to its own `type` and (when declared) `enum`. Returns `null` when the
|
|
196
|
+
* field has no `default` (the common case) or the default is valid;
|
|
197
|
+
* otherwise a {@link TagFieldViolation} with `reason: "invalid_default"`.
|
|
198
|
+
* Pure — never throws; the two callers (the store chokepoint's fail-fast
|
|
199
|
+
* pre-validate, and {@link collectTagFieldViolations}'s bundled MCP report)
|
|
200
|
+
* each decide how to surface it.
|
|
201
|
+
*
|
|
202
|
+
* Deliberately independent of `schema-defaults.ts`'s `valueMatchesType` (this
|
|
203
|
+
* module doesn't import that one, and vice versa — no cycle either way) so
|
|
204
|
+
* the two modules' type vocabularies can drift without cross-coupling; the
|
|
205
|
+
* rules are kept in lockstep by hand (string/number/integer/boolean/array/
|
|
206
|
+
* object, the SAME six as `TagFieldSchema.type`'s documented vocabulary).
|
|
207
|
+
*/
|
|
208
|
+
export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFieldViolation | null {
|
|
209
|
+
if (spec.default === undefined) return null;
|
|
210
|
+
const value = spec.default;
|
|
211
|
+
const typeOk = defaultMatchesType(value, spec.type);
|
|
212
|
+
if (!typeOk) {
|
|
213
|
+
return {
|
|
214
|
+
field,
|
|
215
|
+
reason: "invalid_default",
|
|
216
|
+
message: `field "${field}" declares default ${JSON.stringify(value)}, which does not match its own type "${spec.type}"`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (spec.enum && spec.enum.length > 0 && typeof value === "string" && !spec.enum.includes(value)) {
|
|
220
|
+
return {
|
|
221
|
+
field,
|
|
222
|
+
reason: "invalid_default",
|
|
223
|
+
message: `field "${field}" declares default "${value}", which is not one of its own enum values [${spec.enum.join(", ")}]`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Same six-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
|
|
230
|
+
function defaultMatchesType(value: unknown, type: string): boolean {
|
|
231
|
+
switch (type) {
|
|
232
|
+
case "string":
|
|
233
|
+
return typeof value === "string";
|
|
234
|
+
case "number":
|
|
235
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
236
|
+
case "integer":
|
|
237
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
238
|
+
case "boolean":
|
|
239
|
+
return typeof value === "boolean";
|
|
240
|
+
case "array":
|
|
241
|
+
return Array.isArray(value);
|
|
242
|
+
case "object":
|
|
243
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
244
|
+
default:
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export class ParentCycleError extends Error {
|
|
250
|
+
code = "PARENT_CYCLE" as const;
|
|
251
|
+
error_type = "parent_cycle" as const;
|
|
252
|
+
tag: string;
|
|
253
|
+
cycle: string[];
|
|
254
|
+
|
|
255
|
+
constructor(tag: string, cycle: string[]) {
|
|
256
|
+
super(
|
|
257
|
+
`parent_cycle: setting "${tag}"'s parent_names would create a cycle: ${cycle.join(" → ")}`,
|
|
258
|
+
);
|
|
259
|
+
this.name = "ParentCycleError";
|
|
260
|
+
this.tag = tag;
|
|
261
|
+
this.cycle = cycle;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
135
265
|
/**
|
|
136
266
|
* Upsert a tag record — partial update. Any field left `undefined` is
|
|
137
267
|
* preserved. Pass `null` explicitly to clear a column. Always touches
|
|
138
268
|
* `updated_at`; sets `created_at` on first insert.
|
|
269
|
+
*
|
|
270
|
+
* Cycle guard (vault#552): when `patch.parent_names` is a non-empty array,
|
|
271
|
+
* validate it against the CURRENT hierarchy (loaded fresh — this function is
|
|
272
|
+
* the shared chokepoint for both REST and MCP, so it can't rely on a
|
|
273
|
+
* possibly-stale caller cache) before touching any row. A conflicting parent
|
|
274
|
+
* throws {@link ParentCycleError} and nothing is persisted — same
|
|
275
|
+
* fail-closed-before-any-write posture as the cross-tag field-conflict
|
|
276
|
+
* checks in this module.
|
|
139
277
|
*/
|
|
140
278
|
export function upsertTagRecord(
|
|
141
279
|
db: Database,
|
|
@@ -147,6 +285,12 @@ export function upsertTagRecord(
|
|
|
147
285
|
parent_names?: string[] | null;
|
|
148
286
|
},
|
|
149
287
|
): TagRecord {
|
|
288
|
+
if (patch.parent_names != null && patch.parent_names.length > 0) {
|
|
289
|
+
const hierarchy = loadTagHierarchy(db);
|
|
290
|
+
const cycle = findParentCycle(hierarchy, tag, patch.parent_names);
|
|
291
|
+
if (cycle) throw new ParentCycleError(tag, cycle);
|
|
292
|
+
}
|
|
293
|
+
|
|
150
294
|
const now = new Date().toISOString();
|
|
151
295
|
db.prepare(
|
|
152
296
|
"INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
|
|
@@ -358,7 +502,12 @@ function jsonOrNull(value: unknown): string | null {
|
|
|
358
502
|
*/
|
|
359
503
|
export interface TagFieldViolation {
|
|
360
504
|
field: string;
|
|
361
|
-
reason:
|
|
505
|
+
reason:
|
|
506
|
+
| "type_conflict"
|
|
507
|
+
| "indexed_flag_conflict"
|
|
508
|
+
| "unsupported_indexed_type"
|
|
509
|
+
| "invalid_field_name"
|
|
510
|
+
| "invalid_default";
|
|
362
511
|
message: string;
|
|
363
512
|
/**
|
|
364
513
|
* The conflicting declarer tag — present on the cross-tag reasons
|
|
@@ -477,12 +626,17 @@ export function collectCrossTagFieldViolations(
|
|
|
477
626
|
/**
|
|
478
627
|
* Full field-violation collection: {@link collectCrossTagFieldViolations}
|
|
479
628
|
* PLUS the own-field checks (unsupported type for indexing, invalid
|
|
480
|
-
* identifier
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
629
|
+
* identifier, and — vault#553 Decision B — a non-conforming `default`).
|
|
630
|
+
* Used by the MCP `update-tag` tool, which — unlike REST — had no prior
|
|
631
|
+
* single-violation status-code contract to preserve for those checks (its
|
|
632
|
+
* old inline loop threw an unstructured `Error` for them, same as
|
|
633
|
+
* everything else pre-#554); collecting everything here is a strict
|
|
484
634
|
* improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
|
|
485
|
-
* why REST calls that narrower function directly instead of this one
|
|
635
|
+
* why REST calls that narrower function directly instead of this one — REST
|
|
636
|
+
* gets the SAME `invalid_default` coverage via `store.upsertTagRecord`'s
|
|
637
|
+
* fail-fast pre-validate (mirrors its indexed-type/name checks), just as a
|
|
638
|
+
* single-violation `InvalidFieldDefaultError` → 400 rather than a bundled
|
|
639
|
+
* `TagFieldConflictError` → 422.
|
|
486
640
|
*/
|
|
487
641
|
export function collectTagFieldViolations(
|
|
488
642
|
db: Database,
|
|
@@ -492,6 +646,12 @@ export function collectTagFieldViolations(
|
|
|
492
646
|
const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
|
|
493
647
|
|
|
494
648
|
for (const [fieldName, spec] of Object.entries(incomingFields)) {
|
|
649
|
+
// Own-field default-conformance check (vault#553 Decision B) — applies
|
|
650
|
+
// to EVERY field (not just indexed ones); a bad default is a tag-schema
|
|
651
|
+
// error regardless of whether the field is queryable.
|
|
652
|
+
const defaultViolation = validateFieldDefault(fieldName, spec);
|
|
653
|
+
if (defaultViolation) violations.push(defaultViolation);
|
|
654
|
+
|
|
495
655
|
if (spec.indexed === true) {
|
|
496
656
|
const mapped = mapFieldType(spec.type);
|
|
497
657
|
if (!mapped) {
|
package/core/src/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { SearchMode } from "./search-query.js";
|
|
|
6
6
|
import type { ValidationStatus } from "./schema-defaults.js";
|
|
7
7
|
import type { ConformanceReport } from "./conformance.js";
|
|
8
8
|
import type { FindPathResult } from "./links.js";
|
|
9
|
+
import type { DoctorReport, DoctorScanOpts } from "./doctor.js";
|
|
9
10
|
|
|
10
11
|
// ---- Re-exports ----
|
|
11
12
|
|
|
@@ -14,6 +15,7 @@ export type { PrunedField } from "./indexed-fields.js";
|
|
|
14
15
|
export type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
|
|
15
16
|
export type { ConformanceReport } from "./conformance.js";
|
|
16
17
|
export type { FindPathResult } from "./links.js";
|
|
18
|
+
export type { DoctorReport, DoctorFinding, DoctorFindingType, DoctorSeverity, DoctorScanOpts } from "./doctor.js";
|
|
17
19
|
|
|
18
20
|
// ---- Note ----
|
|
19
21
|
|
|
@@ -364,7 +366,20 @@ export interface Store {
|
|
|
364
366
|
* alongside the literal `count`. See `core/src/tag-hierarchy.ts:computeExpandedTagCounts`.
|
|
365
367
|
*/
|
|
366
368
|
listTags(): Promise<{ name: string; count: number; expanded_count: number }[]>;
|
|
367
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Delete a tag. Refused (vault#552) when another tag's `parent_names`
|
|
371
|
+
* still references it — pass `cascade` or `detach` (synonyms; either
|
|
372
|
+
* strips the stale reference from the referencing tags' `parent_names`
|
|
373
|
+
* in the same transaction) to proceed anyway. Notes are never deleted,
|
|
374
|
+
* only untagged.
|
|
375
|
+
*/
|
|
376
|
+
deleteTag(
|
|
377
|
+
name: string,
|
|
378
|
+
opts?: { cascade?: boolean; detach?: boolean },
|
|
379
|
+
): Promise<
|
|
380
|
+
| { deleted: boolean; notes_untagged: number; parent_refs_detached?: number }
|
|
381
|
+
| { error: "tag_referenced_as_parent"; referencing_tags: string[] }
|
|
382
|
+
>;
|
|
368
383
|
renameTag(
|
|
369
384
|
oldName: string,
|
|
370
385
|
newName: string,
|
|
@@ -431,6 +446,13 @@ export interface Store {
|
|
|
431
446
|
*/
|
|
432
447
|
reconcileDeclaredIndexes(): Promise<number>;
|
|
433
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Read-only taxonomy/metadata integrity scan (vault#552). Never mutates —
|
|
451
|
+
* every finding carries a suggested `remedy` the caller applies
|
|
452
|
+
* deliberately. See `core/src/doctor.ts` for the finding-type catalog.
|
|
453
|
+
*/
|
|
454
|
+
doctor(opts?: DoctorScanOpts): Promise<DoctorReport>;
|
|
455
|
+
|
|
434
456
|
// Tag records — full v14 identity row (description + fields + typed
|
|
435
457
|
// relationships + parent_names + timestamps). See
|
|
436
458
|
// docs/contracts/tag-data-model.md.
|
package/package.json
CHANGED
package/src/mcp-http.ts
CHANGED
|
@@ -170,6 +170,8 @@ async function handleMcp(
|
|
|
170
170
|
reason?: string;
|
|
171
171
|
limit?: number;
|
|
172
172
|
tag?: string;
|
|
173
|
+
cycle?: unknown;
|
|
174
|
+
referencing_tags?: unknown;
|
|
173
175
|
};
|
|
174
176
|
// Honest-queries validation errors (vault#550) — `limit`/`offset`/date
|
|
175
177
|
// values that are structurally invalid rather than merely "no
|
|
@@ -313,6 +315,17 @@ async function handleMcp(
|
|
|
313
315
|
violations: e.violations ?? [],
|
|
314
316
|
});
|
|
315
317
|
}
|
|
318
|
+
// parent_names cycle guard (vault#552) — `update-tag`'s write would
|
|
319
|
+
// close a cycle. Mirrors REST's 409 `parent_cycle` shape; the tool
|
|
320
|
+
// wrapper (src/mcp-tools.ts) scope-scrubs `cycle` before this throw
|
|
321
|
+
// for a tag-scoped caller, same as TAG_FIELD_CONFLICT above.
|
|
322
|
+
if (e?.code === "PARENT_CYCLE") {
|
|
323
|
+
throw new McpError(ErrorCode.InvalidRequest, message, {
|
|
324
|
+
error_type: "parent_cycle",
|
|
325
|
+
tag: e.tag,
|
|
326
|
+
cycle: e.cycle ?? [],
|
|
327
|
+
});
|
|
328
|
+
}
|
|
316
329
|
// Generic catch-all (vault#554): any remaining error that carries a
|
|
317
330
|
// stable `error_type` — either a domain error class that stamps it as
|
|
318
331
|
// an instance property (IndexedFieldError, and the classes handled by
|
package/src/mcp-tools.ts
CHANGED
|
@@ -23,11 +23,14 @@ import {
|
|
|
23
23
|
filterHydratedLinksByTagScope,
|
|
24
24
|
noteWithinTagScope,
|
|
25
25
|
scrubIndexedFieldConflictError,
|
|
26
|
+
scrubParentCycleError,
|
|
27
|
+
scrubReferencingTagsByScope,
|
|
26
28
|
scrubTagFieldViolationsByScope,
|
|
27
29
|
tagsWithinScope,
|
|
28
30
|
} from "./tag-scope.ts";
|
|
29
|
-
import { TagFieldConflictError } from "../core/src/tag-schemas.ts";
|
|
31
|
+
import { TagFieldConflictError, ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
30
32
|
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
33
|
+
import { runDoctorScan } from "../core/src/doctor.ts";
|
|
31
34
|
import {
|
|
32
35
|
findTokensReferencingTag,
|
|
33
36
|
recordMcpMintLedger,
|
|
@@ -219,11 +222,12 @@ export function generateScopedMcpTools(
|
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
/**
|
|
222
|
-
* Tag-delete and
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
+
* Tag-delete and tag-merge always check for tag-scoped tokens referencing
|
|
226
|
+
* the doomed tag(s) — regardless of whether the CALLER is itself
|
|
227
|
+
* tag-scoped. A successful delete/merge that orphans an allowlist would
|
|
225
228
|
* silently widen surface area downstream. Mirrors the REST 409
|
|
226
|
-
* `tag_in_use_by_tokens` envelope.
|
|
229
|
+
* `tag_in_use_by_tokens` envelope (routes.ts's DELETE /tags/:name and
|
|
230
|
+
* POST /tags/merge run the identical check).
|
|
227
231
|
*/
|
|
228
232
|
function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void {
|
|
229
233
|
const store = getVaultStore(vaultName);
|
|
@@ -243,6 +247,29 @@ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void
|
|
|
243
247
|
}
|
|
244
248
|
return await orig(params);
|
|
245
249
|
});
|
|
250
|
+
// vault#552: merging consumes every source tag (same identity-drop as
|
|
251
|
+
// delete), so a source referenced by a tag-scoped token would orphan that
|
|
252
|
+
// token's allowlist exactly like a bare delete would. Aggregate matches
|
|
253
|
+
// across all sources into one 409-equivalent envelope, same shape REST's
|
|
254
|
+
// POST /tags/merge returns.
|
|
255
|
+
wrapReadTool(tools, "merge-tags", async (orig, params) => {
|
|
256
|
+
const sources = Array.isArray((params as any).sources) ? ((params as any).sources as unknown[]) : [];
|
|
257
|
+
const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
|
|
258
|
+
for (const src of sources) {
|
|
259
|
+
if (typeof src !== "string") continue;
|
|
260
|
+
const tokens = findTokensReferencingTag(store.db, src);
|
|
261
|
+
if (tokens.length > 0) referenced.push({ source: src, tokens });
|
|
262
|
+
}
|
|
263
|
+
if (referenced.length > 0) {
|
|
264
|
+
return {
|
|
265
|
+
error: "TagInUseByTokens",
|
|
266
|
+
error_type: "tag_in_use_by_tokens",
|
|
267
|
+
message: `Cannot merge: ${referenced.length} source tag(s) referenced by tag-scoped token(s); revoke or re-mint them first.`,
|
|
268
|
+
referenced_by: referenced,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return await orig(params);
|
|
272
|
+
});
|
|
246
273
|
}
|
|
247
274
|
|
|
248
275
|
/**
|
|
@@ -535,6 +562,14 @@ function applyTagScopeWrappers(
|
|
|
535
562
|
if (err instanceof IndexedFieldError) {
|
|
536
563
|
throw scrubIndexedFieldConflictError(err, allowed);
|
|
537
564
|
}
|
|
565
|
+
// parent_names cycle guard (vault#552) — the hierarchy `upsertTagRecord`
|
|
566
|
+
// validates against is vault-wide (scope-unaware by architecture), so
|
|
567
|
+
// the cycle path can name an out-of-scope tag. Same "write stays
|
|
568
|
+
// rejected, path gets generalized" posture as the field-conflict scrub
|
|
569
|
+
// above.
|
|
570
|
+
if (err instanceof ParentCycleError) {
|
|
571
|
+
throw scrubParentCycleError(err, allowed);
|
|
572
|
+
}
|
|
538
573
|
throw err;
|
|
539
574
|
}
|
|
540
575
|
});
|
|
@@ -546,9 +581,59 @@ function applyTagScopeWrappers(
|
|
|
546
581
|
if (typeof tag === "string" && !allowed.has(tag)) {
|
|
547
582
|
return forbidden(`delete-tag: tag "${tag}" is outside the token's allowlist`);
|
|
548
583
|
}
|
|
584
|
+
const result = await orig(params);
|
|
585
|
+
// Referential-integrity refusal (vault#552) — scrub out-of-scope
|
|
586
|
+
// referencing tag names before returning. The delete stays refused
|
|
587
|
+
// either way; only the response's tag-name visibility changes.
|
|
588
|
+
if (result && typeof result === "object" && (result as any).error_type === "tag_referenced_as_parent") {
|
|
589
|
+
return {
|
|
590
|
+
...(result as Record<string, unknown>),
|
|
591
|
+
referencing_tags: scrubReferencingTagsByScope((result as any).referencing_tags ?? [], allowed),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return result;
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// rename-tag / merge-tags (vault#552 — MCP parity with the pre-existing
|
|
598
|
+
// REST engine). Same tag-scope posture as update-tag/delete-tag: every
|
|
599
|
+
// tag NAMED in the request (source(s) + target, or old/new name) must be
|
|
600
|
+
// inside the caller's allowlist — a rename/merge that pulls a tag out of
|
|
601
|
+
// scope (or in) is a privilege-boundary move, refuse the whole op before
|
|
602
|
+
// it reaches core.
|
|
603
|
+
wrapReadTool(tools, "rename-tag", async (orig, params) => {
|
|
604
|
+
const allowed = await getAllowed();
|
|
605
|
+
if (!allowed) return await orig(params);
|
|
606
|
+
const oldName = (params as any).old_name ?? (params as any).from ?? (params as any).tag;
|
|
607
|
+
const newName = (params as any).new_name ?? (params as any).to;
|
|
608
|
+
for (const t of [oldName, newName]) {
|
|
609
|
+
if (typeof t === "string" && !allowed.has(t)) {
|
|
610
|
+
return forbidden(`rename-tag: tag "${t}" is outside the token's allowlist`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return await orig(params);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
wrapReadTool(tools, "merge-tags", async (orig, params) => {
|
|
617
|
+
const allowed = await getAllowed();
|
|
618
|
+
if (!allowed) return await orig(params);
|
|
619
|
+
const sources = Array.isArray((params as any).sources) ? ((params as any).sources as unknown[]) : [];
|
|
620
|
+
const target = (params as any).target;
|
|
621
|
+
for (const t of [...sources, target]) {
|
|
622
|
+
if (typeof t === "string" && !allowed.has(t)) {
|
|
623
|
+
return forbidden(`merge-tags: tag "${t}" is outside the token's allowlist`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
549
626
|
return await orig(params);
|
|
550
627
|
});
|
|
551
628
|
|
|
629
|
+
// doctor (vault#552) — the scan itself is re-run with the caller's
|
|
630
|
+
// expanded allowlist rather than post-filtering the unscoped result, so
|
|
631
|
+
// aggregate counts (findings summary) never reflect out-of-scope data.
|
|
632
|
+
wrapReadTool(tools, "doctor", async (orig, params) => {
|
|
633
|
+
const allowed = await getAllowed();
|
|
634
|
+
if (!allowed) return await orig(params);
|
|
635
|
+
return runDoctorScan(store.db, { allowedTags: allowed });
|
|
636
|
+
});
|
|
552
637
|
}
|
|
553
638
|
|
|
554
639
|
function wrapReadTool(
|
package/src/mirror-import.ts
CHANGED
|
@@ -518,6 +518,11 @@ function importResultFromStats(
|
|
|
518
518
|
`Orphaned sidecar ${ss.sidecar_id} (path=${ss.expected_path ?? "—"}): ${ss.reason}`,
|
|
519
519
|
);
|
|
520
520
|
}
|
|
521
|
+
for (const sp of stats.skipped_schema_parents) {
|
|
522
|
+
warnings.push(
|
|
523
|
+
`Dropped parent_names on tag "${sp.tag}": ${sp.reason}`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
521
526
|
const result: ImportResult = {
|
|
522
527
|
notes_imported: stats.notes_created + stats.notes_updated,
|
|
523
528
|
tags_imported: stats.schemas_restored,
|
|
@@ -166,8 +166,11 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
166
166
|
expect(gs!.content).toContain("## Write gotchas");
|
|
167
167
|
expect(gs!.content).toContain("if_updated_at"); // optimistic concurrency
|
|
168
168
|
expect(gs!.content).toContain("force: true");
|
|
169
|
-
// schema-default back-fill
|
|
170
|
-
expect(gs!.content).toContain("
|
|
169
|
+
// schema-default back-fill is explicit-`default`-only (vault#553 Decision B).
|
|
170
|
+
expect(gs!.content).toContain("explicit `default`");
|
|
171
|
+
expect(gs!.content).toContain("exists: false");
|
|
172
|
+
// indexed⇒strict type enforcement (vault#553 Decision A).
|
|
173
|
+
expect(gs!.content).toContain("field's TYPE is always enforced");
|
|
171
174
|
});
|
|
172
175
|
|
|
173
176
|
test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
|
package/src/routes.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
contentRangeRequiresContent,
|
|
25
25
|
type ContentRange,
|
|
26
26
|
} from "../core/src/content-range.ts";
|
|
27
|
-
import { attachValidationStatus, enforceStrictWrite } from "../core/src/mcp.ts";
|
|
27
|
+
import { attachValidationStatus, enforceStrictWrite, applySchemaDefaults } from "../core/src/mcp.ts";
|
|
28
28
|
import type { ValidationWarning } from "../core/src/schema-defaults.ts";
|
|
29
29
|
import { logStrictBypass } from "./scopes.ts";
|
|
30
30
|
import * as linkOps from "../core/src/links.ts";
|
|
@@ -42,10 +42,13 @@ import {
|
|
|
42
42
|
filterNotesByTagScope,
|
|
43
43
|
noteWithinTagScope,
|
|
44
44
|
scrubIndexedFieldConflictError,
|
|
45
|
+
scrubParentCycleError,
|
|
46
|
+
scrubReferencingTagsByScope,
|
|
45
47
|
scrubTagFieldViolationsByScope,
|
|
46
48
|
tagScopeForbidden,
|
|
47
49
|
tagsWithinScope,
|
|
48
50
|
} from "./tag-scope.ts";
|
|
51
|
+
import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
|
|
49
52
|
import { findTokensReferencingTag } from "./token-store.ts";
|
|
50
53
|
|
|
51
54
|
/**
|
|
@@ -2665,6 +2668,41 @@ export async function handleTags(
|
|
|
2665
2668
|
400,
|
|
2666
2669
|
);
|
|
2667
2670
|
}
|
|
2671
|
+
if (err instanceof InvalidFieldDefaultError) {
|
|
2672
|
+
// vault#553 Decision B — a declared `default` doesn't conform to its
|
|
2673
|
+
// own field's type/enum. Own-field error (no cross-tag data), so no
|
|
2674
|
+
// scrub needed. Mirrors IndexedFieldError's 400 shape/posture — see
|
|
2675
|
+
// store.upsertTagRecord's pre-validate comment for why REST hits
|
|
2676
|
+
// this (fail-fast, single violation) while MCP's update-tag usually
|
|
2677
|
+
// reports it bundled via `TagFieldConflictError` instead.
|
|
2678
|
+
return json(
|
|
2679
|
+
{ error: err.message, error_type: "invalid_field_default", field: err.field },
|
|
2680
|
+
400,
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
if (err instanceof ParentCycleError) {
|
|
2684
|
+
// vault#552: parent_names would close a cycle. Nothing was
|
|
2685
|
+
// persisted. Scope-scrub the cycle path for a tag-scoped caller —
|
|
2686
|
+
// the write stays rejected either way. `error` is a SHORT code and
|
|
2687
|
+
// `message` the human sentence — the same split every sibling 409 in
|
|
2688
|
+
// this file uses (target_exists, tag_in_use_by_tokens,
|
|
2689
|
+
// tag_referenced_as_parent). parachute-surface's shared VaultClient
|
|
2690
|
+
// 409 handler reads `body.message` (falling back to a hardcoded
|
|
2691
|
+
// "Note was edited elsewhere"), so the message key is load-bearing:
|
|
2692
|
+
// without it the Tags parent_names editor would show the wrong
|
|
2693
|
+
// conflict text.
|
|
2694
|
+
const scrubbed = scrubParentCycleError(err, tagScope.allowed);
|
|
2695
|
+
return json(
|
|
2696
|
+
{
|
|
2697
|
+
error: "ParentCycle",
|
|
2698
|
+
error_type: "parent_cycle",
|
|
2699
|
+
tag: scrubbed.tag,
|
|
2700
|
+
cycle: scrubbed.cycle,
|
|
2701
|
+
message: scrubbed.message,
|
|
2702
|
+
},
|
|
2703
|
+
409,
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2668
2706
|
throw err;
|
|
2669
2707
|
}
|
|
2670
2708
|
return json(result);
|
|
@@ -2692,7 +2730,33 @@ export async function handleTags(
|
|
|
2692
2730
|
409,
|
|
2693
2731
|
);
|
|
2694
2732
|
}
|
|
2695
|
-
|
|
2733
|
+
// `?cascade=true` / `?detach=true` (synonyms — see DeleteTagOpts):
|
|
2734
|
+
// required to delete a tag another tag's parent_names still references
|
|
2735
|
+
// (vault#552). Query params, not a DELETE body, so the flag is visible
|
|
2736
|
+
// in access logs and works with any HTTP client without body support.
|
|
2737
|
+
const cascade = parseBool(parseQuery(url, "cascade"), false);
|
|
2738
|
+
const detach = parseBool(parseQuery(url, "detach"), false);
|
|
2739
|
+
const result = await store.deleteTag(tagName, { cascade, detach });
|
|
2740
|
+
if ("error" in result) {
|
|
2741
|
+
// Referential-integrity refusal (vault#552). Scope-scrub the
|
|
2742
|
+
// referencing tag names for a tag-scoped caller — the delete is
|
|
2743
|
+
// still refused (integrity is scope-independent), but an
|
|
2744
|
+
// out-of-scope referrer's name must not leak. Same posture as
|
|
2745
|
+
// `tag_field_conflict`'s scrub.
|
|
2746
|
+
const referencing_tags = scrubReferencingTagsByScope(result.referencing_tags, tagScope.allowed);
|
|
2747
|
+
return json(
|
|
2748
|
+
{
|
|
2749
|
+
error: "TagReferencedAsParent",
|
|
2750
|
+
error_type: "tag_referenced_as_parent",
|
|
2751
|
+
tag: tagName,
|
|
2752
|
+
referencing_tags,
|
|
2753
|
+
message: `Tag "${tagName}" is referenced by ${referencing_tags.length} other tag(s)' parent_names; pass ?cascade=true or ?detach=true to also remove the reference, or update the referencing tag(s) first.`,
|
|
2754
|
+
hint: "pass ?cascade=true or ?detach=true, or fix the referencing tag(s)' parent_names first",
|
|
2755
|
+
},
|
|
2756
|
+
409,
|
|
2757
|
+
);
|
|
2758
|
+
}
|
|
2759
|
+
return json(result);
|
|
2696
2760
|
}
|
|
2697
2761
|
|
|
2698
2762
|
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
@@ -2924,6 +2988,27 @@ export function handleUnresolvedWikilinks(
|
|
|
2924
2988
|
return Response.json({ unresolved: filtered, count: filtered.length });
|
|
2925
2989
|
}
|
|
2926
2990
|
|
|
2991
|
+
// ---------------------------------------------------------------------------
|
|
2992
|
+
// Doctor — GET /api/doctor (vault#552, admin-gated in routing.ts)
|
|
2993
|
+
// ---------------------------------------------------------------------------
|
|
2994
|
+
|
|
2995
|
+
/**
|
|
2996
|
+
* `GET /vault/{name}/api/doctor` — read-only taxonomy/metadata integrity
|
|
2997
|
+
* scan. Method + `vault:admin` scope are already enforced by the caller
|
|
2998
|
+
* (`src/routing.ts`, dispatched before the generic read/write gate — same
|
|
2999
|
+
* shape as `/api/triggers`); this handler just runs the scan with the
|
|
3000
|
+
* caller's tag-scope allowlist. See `core/src/doctor.ts` for the finding
|
|
3001
|
+
* catalog.
|
|
3002
|
+
*/
|
|
3003
|
+
export async function handleDoctor(
|
|
3004
|
+
_req: Request,
|
|
3005
|
+
store: Store,
|
|
3006
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
3007
|
+
): Promise<Response> {
|
|
3008
|
+
const report = await store.doctor({ allowedTags: tagScope.allowed });
|
|
3009
|
+
return json(report);
|
|
3010
|
+
}
|
|
3011
|
+
|
|
2927
3012
|
// ---------------------------------------------------------------------------
|
|
2928
3013
|
// Published notes — public, no-auth HTML rendering
|
|
2929
3014
|
// ---------------------------------------------------------------------------
|
|
@@ -3662,56 +3747,10 @@ export async function handleStorage(
|
|
|
3662
3747
|
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3663
3748
|
}
|
|
3664
3749
|
|
|
3665
|
-
//
|
|
3666
|
-
//
|
|
3667
|
-
//
|
|
3668
|
-
|
|
3669
|
-
// Returns the IDs of notes whose metadata was actually default-filled, so
|
|
3670
|
-
// the caller can re-read ONLY the mutated notes (and skip the re-read when
|
|
3671
|
-
// nothing changed). Mirrors the core/src/mcp.ts contract.
|
|
3672
|
-
async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
3673
|
-
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
3674
|
-
if (Object.keys(schemas).length === 0) return [];
|
|
3675
|
-
|
|
3676
|
-
const defaults: Record<string, unknown> = {};
|
|
3677
|
-
for (const tag of tags) {
|
|
3678
|
-
const schema = schemas[tag];
|
|
3679
|
-
if (!schema?.fields) continue;
|
|
3680
|
-
for (const [field, fieldSchema] of Object.entries(schema.fields)) {
|
|
3681
|
-
if (!(field in defaults)) {
|
|
3682
|
-
defaults[field] = defaultForField(fieldSchema);
|
|
3683
|
-
}
|
|
3684
|
-
}
|
|
3685
|
-
}
|
|
3686
|
-
if (Object.keys(defaults).length === 0) return [];
|
|
3687
|
-
|
|
3688
|
-
const mutated: string[] = [];
|
|
3689
|
-
for (const noteId of noteIds) {
|
|
3690
|
-
const note = await store.getNote(noteId);
|
|
3691
|
-
if (!note) continue;
|
|
3692
|
-
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
3693
|
-
const missing: Record<string, unknown> = {};
|
|
3694
|
-
for (const [field, value] of Object.entries(defaults)) {
|
|
3695
|
-
if (!(field in existing)) missing[field] = value;
|
|
3696
|
-
}
|
|
3697
|
-
if (Object.keys(missing).length === 0) continue;
|
|
3698
|
-
await store.updateNote(noteId, {
|
|
3699
|
-
metadata: { ...existing, ...missing },
|
|
3700
|
-
skipUpdatedAt: true,
|
|
3701
|
-
});
|
|
3702
|
-
mutated.push(noteId);
|
|
3703
|
-
}
|
|
3704
|
-
return mutated;
|
|
3705
|
-
}
|
|
3706
|
-
|
|
3707
|
-
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
3708
|
-
if (field.enum && field.enum.length > 0) return field.enum[0];
|
|
3709
|
-
switch (field.type) {
|
|
3710
|
-
case "boolean": return false;
|
|
3711
|
-
case "integer": return 0;
|
|
3712
|
-
default: return "";
|
|
3713
|
-
}
|
|
3714
|
-
}
|
|
3750
|
+
// applySchemaDefaults lives in core/src/mcp.ts (vault#553) — REST used to
|
|
3751
|
+
// carry a byte-identical duplicate; importing it means REST and MCP can
|
|
3752
|
+
// never drift on this behavior again, and the cloud runtime (which imports
|
|
3753
|
+
// core directly) inherits it without any handler-side code.
|
|
3715
3754
|
|
|
3716
3755
|
function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
3717
3756
|
const escaped = targetPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|