@openparachute/vault 0.6.4-rc.8 → 0.6.4
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 +6 -4
- package/core/src/bare-tag-guard.test.ts +246 -0
- package/core/src/conformance.test.ts +112 -0
- package/core/src/conformance.ts +214 -0
- package/core/src/mcp.ts +7 -2
- package/core/src/notes.ts +52 -10
- package/core/src/store.ts +65 -2
- package/core/src/tag-hierarchy.ts +26 -0
- package/core/src/types.ts +15 -0
- package/package.json +1 -1
- package/src/bare-tag-guard-routes.test.ts +72 -0
- package/src/cli.ts +6 -6
- package/src/first-boot-create.test.ts +155 -0
- package/src/routes.ts +190 -25
- package/src/routing.test.ts +125 -0
- package/src/routing.ts +10 -2
- package/src/self-register.test.ts +8 -8
- package/src/self-register.ts +9 -5
- package/src/server.ts +58 -38
- package/src/storage.test.ts +75 -8
- package/src/vault-remove.test.ts +4 -5
- package/web/ui/dist/assets/{index-UlvD8KHr.css → index-C5XqQM-c.css} +1 -1
- package/web/ui/dist/assets/index-TJ-XN4OZ.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DwYo23aY.js +0 -61
package/core/src/notes.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type QueryHashInputs,
|
|
20
20
|
} from "./cursor.js";
|
|
21
21
|
import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
22
|
+
import { stripTagHash } from "./tag-hierarchy.js";
|
|
22
23
|
|
|
23
24
|
let idCounter = 0;
|
|
24
25
|
|
|
@@ -686,8 +687,14 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
686
687
|
// rides idx_note_tags_tag, produces each note id at most once, and lets
|
|
687
688
|
// the whole query drop DISTINCT. See the 2026-06-10 perf measurements.
|
|
688
689
|
if (opts.tags && opts.tags.length > 0) {
|
|
690
|
+
// Canonical-bare-tag guard (vault#XXX) backstop for direct-core callers
|
|
691
|
+
// that bypass BunSqliteStore.normalizeQueryTags (the store normalizes +
|
|
692
|
+
// hierarchy-expands before reaching here; this protects the raw noteOps
|
|
693
|
+
// entry point and tests). `_tagsExpanded`, when present, was already built
|
|
694
|
+
// from bare names by the store, so prefer it; otherwise strip the literal
|
|
695
|
+
// tags. No-op on already-bare input.
|
|
689
696
|
const tagSets: string[][] = (opts as QueryOpts & { _tagsExpanded?: string[][] })._tagsExpanded
|
|
690
|
-
?? opts.tags.map((t) => [t]);
|
|
697
|
+
?? opts.tags.map((t) => [stripTagHash(t)]);
|
|
691
698
|
const match = opts.tagMatch ?? "all";
|
|
692
699
|
if (match === "any") {
|
|
693
700
|
// Flatten all expanded sets and dedupe — a note tagged with any one
|
|
@@ -710,9 +717,11 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
710
717
|
}
|
|
711
718
|
}
|
|
712
719
|
|
|
713
|
-
// Exclude tags
|
|
720
|
+
// Exclude tags — bare-tag guard backstop (see tags block above).
|
|
714
721
|
if (opts.excludeTags && opts.excludeTags.length > 0) {
|
|
715
|
-
for (const
|
|
722
|
+
for (const rawTag of opts.excludeTags) {
|
|
723
|
+
const tag = stripTagHash(rawTag);
|
|
724
|
+
if (tag === "") continue;
|
|
716
725
|
conditions.push(`NOT EXISTS (SELECT 1 FROM note_tags ex WHERE ex.note_id = n.id AND ex.tag_name = ?)`);
|
|
717
726
|
params.push(tag);
|
|
718
727
|
}
|
|
@@ -1189,11 +1198,18 @@ export function searchNotes(
|
|
|
1189
1198
|
const limit = typeof opts?.limit === "number" ? opts.limit : 50;
|
|
1190
1199
|
|
|
1191
1200
|
if (opts?.tags && opts.tags.length > 0) {
|
|
1201
|
+
// Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
|
|
1202
|
+
const searchTags = opts.tags.map(stripTagHash).filter((t) => t !== "");
|
|
1203
|
+
if (searchTags.length === 0) {
|
|
1204
|
+
// All tag filters collapsed to empty — fall through to the untagged
|
|
1205
|
+
// search path below (no tag constraint).
|
|
1206
|
+
opts = { ...opts, tags: undefined };
|
|
1207
|
+
} else {
|
|
1192
1208
|
try {
|
|
1193
1209
|
// Tag membership as a semijoin — same rationale as queryNotes: a
|
|
1194
1210
|
// `JOIN note_tags` multiplies rows for multi-tagged notes and forced
|
|
1195
1211
|
// DISTINCT over full rows. The FTS join itself is 1:1 on rowid.
|
|
1196
|
-
const tagPlaceholders =
|
|
1212
|
+
const tagPlaceholders = searchTags.map(() => "?").join(", ");
|
|
1197
1213
|
const rows = db.prepare(`
|
|
1198
1214
|
SELECT n.* FROM notes n
|
|
1199
1215
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
@@ -1201,11 +1217,12 @@ export function searchNotes(
|
|
|
1201
1217
|
AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
|
|
1202
1218
|
ORDER BY rank
|
|
1203
1219
|
LIMIT ?
|
|
1204
|
-
`).all(query, ...
|
|
1220
|
+
`).all(query, ...searchTags, limit) as NoteRow[];
|
|
1205
1221
|
return notesWithTags(db, rows);
|
|
1206
1222
|
} catch {
|
|
1207
1223
|
return [];
|
|
1208
1224
|
}
|
|
1225
|
+
}
|
|
1209
1226
|
}
|
|
1210
1227
|
|
|
1211
1228
|
try {
|
|
@@ -1236,7 +1253,15 @@ export function tagNote(db: Database, noteId: string, tags: string[]): void {
|
|
|
1236
1253
|
const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
|
|
1237
1254
|
const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
|
|
1238
1255
|
|
|
1239
|
-
|
|
1256
|
+
// Canonical-bare-tag guard (vault#XXX): strip any leading `#` so the
|
|
1257
|
+
// `#`-decorated form a client may pass (the agent module stored
|
|
1258
|
+
// `#agent/message/inbound` verbatim) lands on the same bare row everyone
|
|
1259
|
+
// else queries. This is the single write chokepoint — createNote /
|
|
1260
|
+
// updateNote / batch / MCP add-tags / REST / import / transcript all funnel
|
|
1261
|
+
// through store.tagNote → here.
|
|
1262
|
+
for (const raw of tags) {
|
|
1263
|
+
const tag = stripTagHash(raw);
|
|
1264
|
+
if (tag === "") continue;
|
|
1240
1265
|
insertTag.run(tag);
|
|
1241
1266
|
insertNoteTag.run(noteId, tag);
|
|
1242
1267
|
}
|
|
@@ -1244,7 +1269,10 @@ export function tagNote(db: Database, noteId: string, tags: string[]): void {
|
|
|
1244
1269
|
|
|
1245
1270
|
export function untagNote(db: Database, noteId: string, tags: string[]): void {
|
|
1246
1271
|
const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
|
|
1247
|
-
|
|
1272
|
+
// Mirror tagNote's normalization so removing `#tag` deletes the bare row.
|
|
1273
|
+
for (const raw of tags) {
|
|
1274
|
+
const tag = stripTagHash(raw);
|
|
1275
|
+
if (tag === "") continue;
|
|
1248
1276
|
stmt.run(noteId, tag);
|
|
1249
1277
|
}
|
|
1250
1278
|
}
|
|
@@ -1359,6 +1387,11 @@ export type RenameTagResult =
|
|
|
1359
1387
|
* after the cascade returns.
|
|
1360
1388
|
*/
|
|
1361
1389
|
export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
|
|
1390
|
+
// Normalize the TARGET so a rename can never create a `#`-prefixed tag. The
|
|
1391
|
+
// SOURCE (`oldName`) is left LITERAL on purpose — it's the transitional escape
|
|
1392
|
+
// hatch that lets the `#legacy/*` → `legacy/*` data migration find the
|
|
1393
|
+
// `#`-prefixed rows. (Renaming TO a `#`-name is the thing we're preventing.)
|
|
1394
|
+
newName = stripTagHash(newName);
|
|
1362
1395
|
if (oldName === newName) {
|
|
1363
1396
|
const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
|
|
1364
1397
|
return exists
|
|
@@ -1727,6 +1760,9 @@ export function mergeTags(
|
|
|
1727
1760
|
sources: string[],
|
|
1728
1761
|
target: string,
|
|
1729
1762
|
): { merged: Record<string, number>; target: string } {
|
|
1763
|
+
// Normalize the TARGET so a merge can never create a `#`-prefixed tag. SOURCES
|
|
1764
|
+
// stay LITERAL so `#legacy/*` rows can be merged away (same carve-out as rename).
|
|
1765
|
+
target = stripTagHash(target);
|
|
1730
1766
|
// Dedup + drop target-in-sources (self-merge is a no-op).
|
|
1731
1767
|
const uniqueSources = Array.from(new Set(sources)).filter((s) => s !== target);
|
|
1732
1768
|
|
|
@@ -1991,15 +2027,18 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
|
|
|
1991
2027
|
export function batchTag(db: Database, noteIds: string[], tags: string[]): number {
|
|
1992
2028
|
const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
|
|
1993
2029
|
const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
|
|
2030
|
+
// Canonical-bare-tag guard (vault#XXX) — batchTag has its own SQL (does NOT
|
|
2031
|
+
// funnel through tagNote), so it strips leading `#` independently.
|
|
2032
|
+
const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
|
|
1994
2033
|
let count = 0;
|
|
1995
2034
|
|
|
1996
2035
|
db.exec("BEGIN");
|
|
1997
2036
|
try {
|
|
1998
|
-
for (const tag of
|
|
2037
|
+
for (const tag of bareTags) {
|
|
1999
2038
|
insertTag.run(tag);
|
|
2000
2039
|
}
|
|
2001
2040
|
for (const noteId of noteIds) {
|
|
2002
|
-
for (const tag of
|
|
2041
|
+
for (const tag of bareTags) {
|
|
2003
2042
|
insertNoteTag.run(noteId, tag);
|
|
2004
2043
|
count++;
|
|
2005
2044
|
}
|
|
@@ -2015,12 +2054,15 @@ export function batchTag(db: Database, noteIds: string[], tags: string[]): numbe
|
|
|
2015
2054
|
|
|
2016
2055
|
export function batchUntag(db: Database, noteIds: string[], tags: string[]): number {
|
|
2017
2056
|
const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
|
|
2057
|
+
// Mirror batchTag's bare-tag normalization so removing `#tag` deletes the
|
|
2058
|
+
// bare row.
|
|
2059
|
+
const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
|
|
2018
2060
|
let count = 0;
|
|
2019
2061
|
|
|
2020
2062
|
db.exec("BEGIN");
|
|
2021
2063
|
try {
|
|
2022
2064
|
for (const noteId of noteIds) {
|
|
2023
|
-
for (const tag of
|
|
2065
|
+
for (const tag of bareTags) {
|
|
2024
2066
|
stmt.run(noteId, tag);
|
|
2025
2067
|
count++;
|
|
2026
2068
|
}
|
package/core/src/store.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { HookRegistry } from "./hooks.js";
|
|
|
16
16
|
import {
|
|
17
17
|
loadTagHierarchy,
|
|
18
18
|
getTagExpansion,
|
|
19
|
+
stripTagHash,
|
|
19
20
|
TAG_CONFIG_PREFIX,
|
|
20
21
|
DEFAULT_TAG_NAME,
|
|
21
22
|
DEFAULT_TAG_EXPAND_MODE,
|
|
@@ -28,6 +29,10 @@ import {
|
|
|
28
29
|
type ResolvedSchemas,
|
|
29
30
|
type ValidationStatus,
|
|
30
31
|
} from "./schema-defaults.js";
|
|
32
|
+
import {
|
|
33
|
+
countConformanceViolations,
|
|
34
|
+
type ConformanceReport,
|
|
35
|
+
} from "./conformance.js";
|
|
31
36
|
|
|
32
37
|
/**
|
|
33
38
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -247,8 +252,30 @@ export class BunSqliteStore implements Store {
|
|
|
247
252
|
);
|
|
248
253
|
}
|
|
249
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Canonical-bare-tag guard (vault#XXX) for the QUERY path. Strip any leading
|
|
257
|
+
* `#` from `tags` / `excludeTags` BEFORE hierarchy expansion so a
|
|
258
|
+
* `#agent/message`-form query matches a bare-stored `agent/message` row (and
|
|
259
|
+
* vice-versa). This is what makes the data migration non-breaking — every
|
|
260
|
+
* old `#`-decorated query keeps working, mapping onto the same bare rows.
|
|
261
|
+
* Runs before `expandQueryTags` so hierarchy resolution / `_default` collapse
|
|
262
|
+
* see the bare names. Empty-after-strip entries are dropped.
|
|
263
|
+
*/
|
|
264
|
+
private normalizeQueryTags(opts: QueryOpts): QueryOpts {
|
|
265
|
+
let next = opts;
|
|
266
|
+
if (opts.tags && opts.tags.length > 0) {
|
|
267
|
+
const tags = opts.tags.map(stripTagHash).filter((t) => t !== "");
|
|
268
|
+
next = { ...next, tags };
|
|
269
|
+
}
|
|
270
|
+
if (opts.excludeTags && opts.excludeTags.length > 0) {
|
|
271
|
+
const excludeTags = opts.excludeTags.map(stripTagHash).filter((t) => t !== "");
|
|
272
|
+
next = { ...next, excludeTags };
|
|
273
|
+
}
|
|
274
|
+
return next;
|
|
275
|
+
}
|
|
276
|
+
|
|
250
277
|
async queryNotes(opts: QueryOpts): Promise<Note[]> {
|
|
251
|
-
return noteOps.queryNotes(this.db, this.expandQueryTags(opts));
|
|
278
|
+
return noteOps.queryNotes(this.db, this.expandQueryTags(this.normalizeQueryTags(opts)));
|
|
252
279
|
}
|
|
253
280
|
|
|
254
281
|
async queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage> {
|
|
@@ -258,7 +285,11 @@ export class BunSqliteStore implements Store {
|
|
|
258
285
|
// descendant set → different rows match → caller should restart). The
|
|
259
286
|
// alternative — hash the expanded set — would silently keep returning
|
|
260
287
|
// stale results from a hierarchy snapshot the caller never saw.
|
|
261
|
-
|
|
288
|
+
//
|
|
289
|
+
// Bare-tag normalization runs first (before the hash is taken inside
|
|
290
|
+
// queryNotesPaged) so a `#tag`-form page-1 and a bare `tag`-form follow-up
|
|
291
|
+
// resolve to the same cursor query_hash.
|
|
292
|
+
return noteOps.queryNotesPaged(this.db, this.expandQueryTags(this.normalizeQueryTags(opts)));
|
|
262
293
|
}
|
|
263
294
|
|
|
264
295
|
/**
|
|
@@ -328,6 +359,11 @@ export class BunSqliteStore implements Store {
|
|
|
328
359
|
}
|
|
329
360
|
|
|
330
361
|
async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
|
|
362
|
+
// Canonical-bare-tag guard (vault#XXX): strip leading `#` from search tag
|
|
363
|
+
// filters before expansion, so `#manual` and `manual` resolve identically.
|
|
364
|
+
if (opts?.tags && opts.tags.length > 0) {
|
|
365
|
+
opts = { ...opts, tags: opts.tags.map(stripTagHash).filter((t) => t !== "") };
|
|
366
|
+
}
|
|
331
367
|
// Same tag-expansion treatment as queryNotes, along the SAME `expand` axis
|
|
332
368
|
// (vault tag `expand` axis) — searching `#manual` should match notes
|
|
333
369
|
// tagged with any descendant under "subtypes", any `manual/*` under
|
|
@@ -626,6 +662,18 @@ export class BunSqliteStore implements Store {
|
|
|
626
662
|
parent_names?: string[] | null;
|
|
627
663
|
},
|
|
628
664
|
) {
|
|
665
|
+
// Canonical-bare-tag guard (vault#XXX) for the SCHEMA path. Strip leading
|
|
666
|
+
// `#` from the tag NAME being upserted and from every `parent_names` entry,
|
|
667
|
+
// so the `tags` rows and the inheritance graph stay bare — matching the
|
|
668
|
+
// bare-stored note_tags. A `#foo` schema with `parent_names: ["#bar"]`
|
|
669
|
+
// becomes `foo` / `["bar"]`, so `tag:bar` still expands to `foo`.
|
|
670
|
+
tag = stripTagHash(tag);
|
|
671
|
+
if (patch.parent_names != null) {
|
|
672
|
+
patch = {
|
|
673
|
+
...patch,
|
|
674
|
+
parent_names: patch.parent_names.map(stripTagHash).filter((p) => p !== ""),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
629
677
|
// Snapshot the prior indexed-field set BEFORE the write so the diff below
|
|
630
678
|
// sees what this tag declared going in. Only needed when `fields` changes.
|
|
631
679
|
const priorRecord =
|
|
@@ -716,6 +764,21 @@ export class BunSqliteStore implements Store {
|
|
|
716
764
|
return result;
|
|
717
765
|
}
|
|
718
766
|
|
|
767
|
+
/**
|
|
768
|
+
* Conformance check (vault#283) — count how many EXISTING notes carrying
|
|
769
|
+
* `tag` (descendants included) would violate the PROPOSED field spec, so a
|
|
770
|
+
* tightening edit (strict / required / narrowed enum / changed type) can
|
|
771
|
+
* warn the operator BEFORE save. Pure read — no mutation. See
|
|
772
|
+
* core/src/conformance.ts.
|
|
773
|
+
*/
|
|
774
|
+
async countTagConformance(
|
|
775
|
+
tag: string,
|
|
776
|
+
proposedFields: Record<string, tagSchemaOps.TagFieldSchema>,
|
|
777
|
+
opts?: { sampleLimit?: number },
|
|
778
|
+
): Promise<ConformanceReport> {
|
|
779
|
+
return countConformanceViolations(this.db, tag, proposedFields, opts);
|
|
780
|
+
}
|
|
781
|
+
|
|
719
782
|
// ---- Batch Wikilink Sync ----
|
|
720
783
|
|
|
721
784
|
/**
|
|
@@ -27,6 +27,32 @@
|
|
|
27
27
|
|
|
28
28
|
import { Database } from "bun:sqlite";
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Canonical-bare-tag guard (vault#XXX). Strip any leading `#` (one or more)
|
|
32
|
+
* from a tag value so a `#`-prefixed tag can never be stored or queried
|
|
33
|
+
* out-of-band. Tags are stored BARE by convention (`agent/message/inbound`,
|
|
34
|
+
* not `#agent/message/inbound`); a client that passes the `#`-decorated form —
|
|
35
|
+
* as the agent module did, persisting `#agent/message/inbound` literally — must
|
|
36
|
+
* land on the same canonical row everyone else queries.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately MINIMAL: this is NOT `normalizeTagValue` (portable-md.ts). It
|
|
39
|
+
* does NOT lowercase, slug-validate, or otherwise transform the value — casing
|
|
40
|
+
* and structure are preserved. The ONLY job is to remove the stray leading `#`.
|
|
41
|
+
*
|
|
42
|
+
* Idempotent: `#tag` / `##tag` / `tag` all collapse to `tag`. Only LEADING `#`
|
|
43
|
+
* is stripped — a `#` mid-string (e.g. `c#`) is left intact. Whitespace is
|
|
44
|
+
* trimmed first so a `" #tag"` from a sloppy client also normalizes. Applied at
|
|
45
|
+
* the canonical chokepoints (note-tag write, query/exclude filters, tag-schema
|
|
46
|
+
* name + parent_names) so MCP, REST, and direct-core callers all converge.
|
|
47
|
+
*/
|
|
48
|
+
export function stripTagHash(tag: string): string {
|
|
49
|
+
// Strip any LEADING run of `#`/whitespace (handles `#tag`, `##tag`, ` #tag`,
|
|
50
|
+
// and `# tag` with a space after the hash), then trim the tail. A `#`
|
|
51
|
+
// mid-string (`c#`) is untouched; a degenerate `# #` collapses to "" (the
|
|
52
|
+
// write-path empty-tag gate drops it).
|
|
53
|
+
return tag.replace(/^[#\s]+/, "").trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
export interface TagHierarchy {
|
|
31
57
|
/** tag → set of immediate child tags (those that declared `tag` as a parent). */
|
|
32
58
|
childrenOf: Map<string, Set<string>>;
|
package/core/src/types.ts
CHANGED
|
@@ -3,12 +3,14 @@ import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } f
|
|
|
3
3
|
import type { PrunedField } from "./indexed-fields.js";
|
|
4
4
|
import type { TagExpandMode } from "./tag-hierarchy.js";
|
|
5
5
|
import type { ValidationStatus } from "./schema-defaults.js";
|
|
6
|
+
import type { ConformanceReport } from "./conformance.js";
|
|
6
7
|
|
|
7
8
|
// ---- Re-exports ----
|
|
8
9
|
|
|
9
10
|
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
10
11
|
export type { PrunedField } from "./indexed-fields.js";
|
|
11
12
|
export type { TagExpandMode } from "./tag-hierarchy.js";
|
|
13
|
+
export type { ConformanceReport } from "./conformance.js";
|
|
12
14
|
|
|
13
15
|
// ---- Note ----
|
|
14
16
|
|
|
@@ -413,6 +415,19 @@ export interface Store {
|
|
|
413
415
|
},
|
|
414
416
|
): Promise<TagRecord>;
|
|
415
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Conformance check (vault#283) — count existing notes carrying `tag`
|
|
420
|
+
* (descendants included) that would violate the PROPOSED field spec, so a
|
|
421
|
+
* tightening edit (strict / required / narrowed enum / changed type) can
|
|
422
|
+
* warn before save. Pure read. `proposedFields` is the full merged field
|
|
423
|
+
* map the operator intends to save; only those fields are checked.
|
|
424
|
+
*/
|
|
425
|
+
countTagConformance(
|
|
426
|
+
tag: string,
|
|
427
|
+
proposedFields: Record<string, TagFieldSchema>,
|
|
428
|
+
opts?: { sampleLimit?: number },
|
|
429
|
+
): Promise<ConformanceReport>;
|
|
430
|
+
|
|
416
431
|
// Schema validation (post-v17: backed by `tags.fields` only — the
|
|
417
432
|
// standalone note_schemas + schema_mappings subsystem retired in v17, see
|
|
418
433
|
// vault#267). Post vault#270 the resolver walks `parent_names` so a note's
|
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical-bare-tag guard (vault#XXX) — REST layer. Confirms the PUT
|
|
3
|
+
* /tags/:name route normalizes the upserted tag NAME (so the partial-merge read
|
|
4
|
+
* + write land on the bare row) and leaves the rename SOURCE-name lookup literal
|
|
5
|
+
* (the migration escape hatch — a #-prefixed legacy tag must still be
|
|
6
|
+
* rename-able away). The query + note-tag write paths normalize in the store and
|
|
7
|
+
* are covered by core/src/bare-tag-guard.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
13
|
+
import { initSchema } from "../core/src/schema.ts";
|
|
14
|
+
import { handleTags } from "./routes.ts";
|
|
15
|
+
|
|
16
|
+
let store: SqliteStore;
|
|
17
|
+
let db: Database;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
db = new Database(":memory:");
|
|
21
|
+
initSchema(db);
|
|
22
|
+
store = new SqliteStore(db);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function put(name: string, body: unknown): Promise<Response> {
|
|
26
|
+
const req = new Request(`http://localhost/api/tags/${encodeURIComponent(name)}`, {
|
|
27
|
+
method: "PUT",
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
});
|
|
30
|
+
return handleTags(req, store, `/${encodeURIComponent(name)}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("PUT /tags/:name — bare-tag guard", () => {
|
|
34
|
+
it("PUT /tags/%23foo with parent_names [#bar] stores the bare row + bare parents", async () => {
|
|
35
|
+
const res = await put("#foo", { parent_names: ["#bar"], description: "child" });
|
|
36
|
+
expect(res.status).toBe(200);
|
|
37
|
+
|
|
38
|
+
const record = await store.getTagRecord("foo");
|
|
39
|
+
expect(record).not.toBeNull();
|
|
40
|
+
expect(record!.parent_names).toEqual(["bar"]);
|
|
41
|
+
expect(await store.getTagRecord("#foo")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("a #-decorated PUT preserves the existing bare record's fields (merge correctness)", async () => {
|
|
45
|
+
await put("thing", { description: "first" });
|
|
46
|
+
await put("#thing", { parent_names: [] });
|
|
47
|
+
const record = await store.getTagRecord("thing");
|
|
48
|
+
expect(record!.description).toBe("first");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("POST /tags/:name/rename — source name stays literal", () => {
|
|
53
|
+
it("rename #agent/message → agent/message finds the literal #-prefixed row", async () => {
|
|
54
|
+
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('#agent/message')").run();
|
|
55
|
+
const note = await store.createNote("legacy inbound");
|
|
56
|
+
db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, '#agent/message')").run(note.id);
|
|
57
|
+
|
|
58
|
+
const sub = "/" + encodeURIComponent("#agent/message") + "/rename";
|
|
59
|
+
const req = new Request(`http://localhost/api/tags${sub}`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
body: JSON.stringify({ new_name: "agent/message" }),
|
|
62
|
+
});
|
|
63
|
+
const res = await handleTags(req, store, sub);
|
|
64
|
+
expect(res.status).toBe(200);
|
|
65
|
+
const body = (await res.json()) as { renamed?: number };
|
|
66
|
+
expect(body.renamed).toBeGreaterThan(0);
|
|
67
|
+
|
|
68
|
+
const tags = await store.listTags();
|
|
69
|
+
expect(tags.map((t) => t.name)).not.toContain("#agent/message");
|
|
70
|
+
expect(tags.map((t) => t.name)).toContain("agent/message");
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -1664,7 +1664,7 @@ function cmdRemove(args: string[]) {
|
|
|
1664
1664
|
configDirty = true;
|
|
1665
1665
|
console.log(
|
|
1666
1666
|
` Last vault removed — wrote auto_create: false to ${GLOBAL_CONFIG_PATH} so the` +
|
|
1667
|
-
` server won't auto-
|
|
1667
|
+
` server won't auto-create a first vault on next boot. Create one with:` +
|
|
1668
1668
|
` parachute-vault create <name>`,
|
|
1669
1669
|
);
|
|
1670
1670
|
}
|
|
@@ -1674,11 +1674,11 @@ function cmdRemove(args: string[]) {
|
|
|
1674
1674
|
// out of the parachute-vault row immediately — the same selfRegister
|
|
1675
1675
|
// refresh cmdCreate does (#208). Without this, the hub's well-known
|
|
1676
1676
|
// fan-out kept advertising the deleted vault until the next server boot.
|
|
1677
|
-
// Note: with zero vaults remaining, selfRegister
|
|
1678
|
-
//
|
|
1679
|
-
//
|
|
1680
|
-
//
|
|
1681
|
-
// ours.
|
|
1677
|
+
// Note: with zero vaults remaining, selfRegister emits paths: [] (#478) —
|
|
1678
|
+
// the row stays present (so the hub still sees the module as installed)
|
|
1679
|
+
// but advertises no /vault/<name> path. The hub must tolerate empty-paths
|
|
1680
|
+
// rows and skip them rather than resolving a phantom "default". Warnings
|
|
1681
|
+
// go to stderr; status lines stay ours.
|
|
1682
1682
|
selfRegister({
|
|
1683
1683
|
version: pkg.version,
|
|
1684
1684
|
warn: (msg) => console.error(`Warning: ${msg}`),
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the first-boot vault creation gate introduced in
|
|
3
|
+
* vault#478 Part 2 — server creates a vault ONLY when PARACHUTE_VAULT_NAME
|
|
4
|
+
* is explicitly set.
|
|
5
|
+
*
|
|
6
|
+
* Each test spawns a real server subprocess against a fresh PARACHUTE_HOME
|
|
7
|
+
* tmpdir (the stop-signal.test.ts pattern) and probes over HTTP so the
|
|
8
|
+
* actual Bun.serve boot path is exercised, not a mocked code path.
|
|
9
|
+
*
|
|
10
|
+
* Port assignments (chosen to avoid collisions with the running daemon on
|
|
11
|
+
* 1940 and stop-signal.test.ts on 19404):
|
|
12
|
+
* 19410 — test 1: PARACHUTE_VAULT_NAME unset → zero vaults
|
|
13
|
+
* 19411 — test 2: PARACHUTE_VAULT_NAME=testvault → vault created
|
|
14
|
+
*
|
|
15
|
+
* NOTE: the unit-level assertions for resolveFirstBootVaultName (source:
|
|
16
|
+
* "default" / "env" / "env-invalid") already live in vault-name.test.ts.
|
|
17
|
+
* These tests cover the SERVER-LEVEL behaviour — whether a vault is actually
|
|
18
|
+
* created on disk — so they are intentionally non-duplicative.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
22
|
+
import { mkdtempSync, rmSync, mkdirSync, existsSync } from "fs";
|
|
23
|
+
import { tmpdir } from "os";
|
|
24
|
+
import { join, resolve } from "path";
|
|
25
|
+
import { waitForHealthy } from "./health.ts";
|
|
26
|
+
import { listVaults } from "./config.ts";
|
|
27
|
+
|
|
28
|
+
const SERVER_PATH = resolve(import.meta.dir, "server.ts");
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Test 1 — PARACHUTE_VAULT_NAME unset
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const PORT_NO_ENV = 19_410;
|
|
35
|
+
let tmpHomeNoEnv: string;
|
|
36
|
+
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
tmpHomeNoEnv = mkdtempSync(join(tmpdir(), "vault-first-boot-no-env-"));
|
|
39
|
+
mkdirSync(join(tmpHomeNoEnv, "vault"), { recursive: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
rmSync(tmpHomeNoEnv, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("first-boot: PARACHUTE_VAULT_NAME unset → zero vaults", () => {
|
|
47
|
+
test("server is healthy AND no vault was auto-created", async () => {
|
|
48
|
+
const proc = Bun.spawn({
|
|
49
|
+
cmd: ["bun", SERVER_PATH],
|
|
50
|
+
env: {
|
|
51
|
+
...process.env,
|
|
52
|
+
PARACHUTE_HOME: tmpHomeNoEnv,
|
|
53
|
+
PORT: String(PORT_NO_ENV),
|
|
54
|
+
// Must be unset — do NOT inherit from the test runner's shell.
|
|
55
|
+
PARACHUTE_VAULT_NAME: undefined as unknown as string,
|
|
56
|
+
SCRIBE_URL: "",
|
|
57
|
+
},
|
|
58
|
+
stdout: "pipe",
|
|
59
|
+
stderr: "pipe",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const health = await waitForHealthy(PORT_NO_ENV, { totalMs: 15_000 });
|
|
64
|
+
expect(health.status).toBe("healthy");
|
|
65
|
+
|
|
66
|
+
// Verify zero vaults were created inside the tmp home.
|
|
67
|
+
const originalHome = process.env.PARACHUTE_HOME;
|
|
68
|
+
try {
|
|
69
|
+
process.env.PARACHUTE_HOME = tmpHomeNoEnv;
|
|
70
|
+
const vaults = listVaults();
|
|
71
|
+
expect(vaults).toHaveLength(0);
|
|
72
|
+
expect(vaults).not.toContain("default");
|
|
73
|
+
} finally {
|
|
74
|
+
if (originalHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
75
|
+
else process.env.PARACHUTE_HOME = originalHome;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Also assert no vault.yaml was written (belt-and-suspenders).
|
|
79
|
+
// Path structure: <PARACHUTE_HOME>/vault/data/<name>/vault.yaml
|
|
80
|
+
const defaultVaultConfig = join(tmpHomeNoEnv, "vault", "data", "default", "vault.yaml");
|
|
81
|
+
expect(existsSync(defaultVaultConfig)).toBe(false);
|
|
82
|
+
} finally {
|
|
83
|
+
if (!proc.killed) proc.kill();
|
|
84
|
+
await proc.exited;
|
|
85
|
+
}
|
|
86
|
+
}, 25_000);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Test 2 — PARACHUTE_VAULT_NAME=testvault → vault created
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
const PORT_WITH_ENV = 19_411;
|
|
94
|
+
let tmpHomeWithEnv: string;
|
|
95
|
+
|
|
96
|
+
// We need separate beforeAll/afterAll scopes for the two server lifetimes.
|
|
97
|
+
// Bun:test describe blocks share the outer beforeAll/afterAll, so we use
|
|
98
|
+
// module-level variables and a nested describe with its own lifecycle hooks.
|
|
99
|
+
describe("first-boot: PARACHUTE_VAULT_NAME=testvault → vault created", () => {
|
|
100
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
tmpHomeWithEnv = mkdtempSync(join(tmpdir(), "vault-first-boot-with-env-"));
|
|
104
|
+
mkdirSync(join(tmpHomeWithEnv, "vault"), { recursive: true });
|
|
105
|
+
|
|
106
|
+
proc = Bun.spawn({
|
|
107
|
+
cmd: ["bun", SERVER_PATH],
|
|
108
|
+
env: {
|
|
109
|
+
...process.env,
|
|
110
|
+
PARACHUTE_HOME: tmpHomeWithEnv,
|
|
111
|
+
PORT: String(PORT_WITH_ENV),
|
|
112
|
+
PARACHUTE_VAULT_NAME: "testvault",
|
|
113
|
+
SCRIBE_URL: "",
|
|
114
|
+
},
|
|
115
|
+
stdout: "pipe",
|
|
116
|
+
stderr: "pipe",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const health = await waitForHealthy(PORT_WITH_ENV, { totalMs: 15_000 });
|
|
120
|
+
if (health.status !== "healthy") {
|
|
121
|
+
proc.kill();
|
|
122
|
+
throw new Error(`server failed to become healthy: ${health.status} (${health.error ?? ""})`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterAll(async () => {
|
|
127
|
+
if (proc && !proc.killed) proc.kill();
|
|
128
|
+
await proc?.exited;
|
|
129
|
+
rmSync(tmpHomeWithEnv, { recursive: true, force: true });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('vault "testvault" was created (not "default")', () => {
|
|
133
|
+
const originalHome = process.env.PARACHUTE_HOME;
|
|
134
|
+
try {
|
|
135
|
+
process.env.PARACHUTE_HOME = tmpHomeWithEnv;
|
|
136
|
+
const vaults = listVaults();
|
|
137
|
+
expect(vaults).toContain("testvault");
|
|
138
|
+
expect(vaults).not.toContain("default");
|
|
139
|
+
expect(vaults).toHaveLength(1);
|
|
140
|
+
} finally {
|
|
141
|
+
if (originalHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
142
|
+
else process.env.PARACHUTE_HOME = originalHome;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("vault config dir exists at the correct path", () => {
|
|
147
|
+
// Path structure: <PARACHUTE_HOME>/vault/data/<name>/vault.yaml
|
|
148
|
+
const vaultConfigPath = join(tmpHomeWithEnv, "vault", "data", "testvault", "vault.yaml");
|
|
149
|
+
expect(existsSync(vaultConfigPath)).toBe(true);
|
|
150
|
+
|
|
151
|
+
// "default" must NOT have been created.
|
|
152
|
+
const defaultConfigPath = join(tmpHomeWithEnv, "vault", "data", "default", "vault.yaml");
|
|
153
|
+
expect(existsSync(defaultConfigPath)).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|