@openparachute/vault 0.6.4-rc.12 → 0.6.4-rc.14
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/bare-tag-guard.test.ts +246 -0
- package/core/src/mcp.ts +7 -2
- package/core/src/notes.ts +52 -10
- package/core/src/store.ts +46 -2
- package/core/src/tag-hierarchy.ts +26 -0
- package/package.json +1 -1
- package/src/bare-tag-guard-routes.test.ts +72 -0
- package/src/cli.ts +1 -1
- package/src/routes.ts +12 -4
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { SqliteStore } from "./store.js";
|
|
4
|
+
import { generateMcpTools } from "./mcp.js";
|
|
5
|
+
import { stripTagHash } from "./tag-hierarchy.js";
|
|
6
|
+
|
|
7
|
+
// Canonical-bare-tag guard (PR #516). A `#`-prefixed tag must never be stored
|
|
8
|
+
// or mismatched: the agent module stored `#agent/message/inbound` literally and
|
|
9
|
+
// consumers querying the bare `agent/message/inbound` convention found nothing.
|
|
10
|
+
// The guard strips a leading `#` (idempotently) on BOTH the write path and the
|
|
11
|
+
// query path, while preserving casing + structure (it is NOT normalizeTagValue).
|
|
12
|
+
|
|
13
|
+
let store: SqliteStore;
|
|
14
|
+
let db: Database;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
db = new Database(":memory:");
|
|
18
|
+
store = new SqliteStore(db);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** Raw read of a note's stored tag rows (bypasses any hydration). */
|
|
22
|
+
function storedTags(noteId: string): string[] {
|
|
23
|
+
return (
|
|
24
|
+
db
|
|
25
|
+
.prepare("SELECT tag_name FROM note_tags WHERE note_id = ? ORDER BY tag_name")
|
|
26
|
+
.all(noteId) as { tag_name: string }[]
|
|
27
|
+
).map((r) => r.tag_name);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("stripTagHash helper", () => {
|
|
31
|
+
it("strips a single leading #", () => {
|
|
32
|
+
expect(stripTagHash("#agent/message")).toBe("agent/message");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("is idempotent across one or more leading # (##x → x, x → x)", () => {
|
|
36
|
+
expect(stripTagHash("##x")).toBe("x");
|
|
37
|
+
expect(stripTagHash("###deep")).toBe("deep");
|
|
38
|
+
expect(stripTagHash("x")).toBe("x");
|
|
39
|
+
expect(stripTagHash(stripTagHash("#x"))).toBe("x");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does NOT lowercase or otherwise transform — casing + structure preserved", () => {
|
|
43
|
+
expect(stripTagHash("#Agent/Message/Inbound")).toBe("Agent/Message/Inbound");
|
|
44
|
+
expect(stripTagHash("CamelCase")).toBe("CamelCase");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("only strips LEADING # — a mid-string # (e.g. c#) is left intact", () => {
|
|
48
|
+
expect(stripTagHash("c#")).toBe("c#");
|
|
49
|
+
expect(stripTagHash("#c#")).toBe("c#");
|
|
50
|
+
expect(stripTagHash("a#b")).toBe("a#b");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("trims surrounding whitespace before stripping", () => {
|
|
54
|
+
expect(stripTagHash(" #tag ")).toBe("tag");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("write path — tags stored bare", () => {
|
|
59
|
+
it("createNote: #agent/message stored as agent/message", async () => {
|
|
60
|
+
const note = await store.createNote("inbound", { tags: ["#agent/message"] });
|
|
61
|
+
expect(storedTags(note.id)).toEqual(["agent/message"]);
|
|
62
|
+
expect(note.tags).toEqual(["agent/message"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("##x stored as x; a bare foo is unchanged", async () => {
|
|
66
|
+
const note = await store.createNote("memo", { tags: ["##x", "foo"] });
|
|
67
|
+
expect(storedTags(note.id).sort()).toEqual(["foo", "x"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a # mid-string (c#) is NOT mangled", async () => {
|
|
71
|
+
const note = await store.createNote("memo", { tags: ["c#"] });
|
|
72
|
+
expect(storedTags(note.id)).toEqual(["c#"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("casing is preserved through the write path", async () => {
|
|
76
|
+
const note = await store.createNote("memo", { tags: ["#Agent/Inbound"] });
|
|
77
|
+
expect(storedTags(note.id)).toEqual(["Agent/Inbound"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("tagNote: #-prefixed add lands on the bare row", async () => {
|
|
81
|
+
const note = await store.createNote("memo");
|
|
82
|
+
await store.tagNote(note.id, ["#later"]);
|
|
83
|
+
expect(storedTags(note.id)).toEqual(["later"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("untagNote: removing #tag deletes the bare row", async () => {
|
|
87
|
+
const note = await store.createNote("memo", { tags: ["later"] });
|
|
88
|
+
await store.untagNote(note.id, ["#later"]);
|
|
89
|
+
expect(storedTags(note.id)).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("batchTag/batchUntag normalize independently of tagNote", async () => {
|
|
93
|
+
const a = await store.createNote("a");
|
|
94
|
+
const b = await store.createNote("b");
|
|
95
|
+
await store.batchTag([a.id, b.id], ["#batch"]);
|
|
96
|
+
expect(storedTags(a.id)).toEqual(["batch"]);
|
|
97
|
+
expect(storedTags(b.id)).toEqual(["batch"]);
|
|
98
|
+
await store.batchUntag([a.id], ["#batch"]);
|
|
99
|
+
expect(storedTags(a.id)).toEqual([]);
|
|
100
|
+
expect(storedTags(b.id)).toEqual(["batch"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("create-note via MCP also stores bare", async () => {
|
|
104
|
+
const tools = generateMcpTools(store);
|
|
105
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
106
|
+
const res = (await createNote.execute({
|
|
107
|
+
content: "mcp inbound",
|
|
108
|
+
tags: ["#agent/message/inbound"],
|
|
109
|
+
})) as { id: string };
|
|
110
|
+
expect(storedTags(res.id)).toEqual(["agent/message/inbound"]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("query path — # and bare forms equivalent", () => {
|
|
115
|
+
it("query tag:#agent/message and tag:agent/message both match the same bare-stored note", async () => {
|
|
116
|
+
const note = await store.createNote("inbound", { tags: ["agent/message"] });
|
|
117
|
+
|
|
118
|
+
const byHash = await store.queryNotes({ tags: ["#agent/message"] });
|
|
119
|
+
expect(byHash.map((n) => n.id)).toContain(note.id);
|
|
120
|
+
|
|
121
|
+
const byBare = await store.queryNotes({ tags: ["agent/message"] });
|
|
122
|
+
expect(byBare.map((n) => n.id)).toContain(note.id);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("an old #-form query reaches a #-decorated WRITE (both normalize to the same bare row)", async () => {
|
|
126
|
+
// The non-breaking guarantee: a client that writes AND queries with the
|
|
127
|
+
// legacy #-form still works — both sides converge on the bare row. (Truly
|
|
128
|
+
// pre-fix #-STORED data is migrated away via renameTag, not auto-reached.)
|
|
129
|
+
const note = await store.createNote("inbound", { tags: ["#agent/message"] });
|
|
130
|
+
const byHash = await store.queryNotes({ tags: ["#agent/message"] });
|
|
131
|
+
expect(byHash.map((n) => n.id)).toContain(note.id);
|
|
132
|
+
const byBare = await store.queryNotes({ tags: ["agent/message"] });
|
|
133
|
+
expect(byBare.map((n) => n.id)).toContain(note.id);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("exclude_tags #x excludes x-tagged notes", async () => {
|
|
137
|
+
const kept = await store.createNote("kept", { tags: ["keep"] });
|
|
138
|
+
const dropped = await store.createNote("dropped", { tags: ["keep", "x"] });
|
|
139
|
+
|
|
140
|
+
const results = await store.queryNotes({ tags: ["keep"], excludeTags: ["#x"] });
|
|
141
|
+
const ids = results.map((n) => n.id);
|
|
142
|
+
expect(ids).toContain(kept.id);
|
|
143
|
+
expect(ids).not.toContain(dropped.id);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("searchNotes treats #tag and bare tag identically", async () => {
|
|
147
|
+
const note = await store.createNote("findable text here", { tags: ["agent/message"] });
|
|
148
|
+
const hits = await store.searchNotes("findable", { tags: ["#agent/message"] });
|
|
149
|
+
expect(hits.map((n) => n.id)).toContain(note.id);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("query-notes via MCP: #-form matches bare-stored note", async () => {
|
|
153
|
+
const note = await store.createNote("mcp query", { tags: ["agent/message/inbound"] });
|
|
154
|
+
const tools = generateMcpTools(store);
|
|
155
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
156
|
+
// query-notes returns a plain array when no cursor is requested.
|
|
157
|
+
const res = (await query.execute({ tag: "#agent/message/inbound" })) as { id: string }[];
|
|
158
|
+
expect(res.map((n) => n.id)).toContain(note.id);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("tag schema path — update-tag name + parent_names stored bare", () => {
|
|
163
|
+
it("update-tag with name #foo + parent_names [#bar] → stored bare; inheritance works", async () => {
|
|
164
|
+
const tools = generateMcpTools(store);
|
|
165
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
166
|
+
|
|
167
|
+
// Establish `bar` as a parent, then `foo` as its child via #-decorated input.
|
|
168
|
+
await updateTag.execute({ tag: "#bar", description: "parent" });
|
|
169
|
+
await updateTag.execute({ tag: "#foo", parent_names: ["#bar"] });
|
|
170
|
+
|
|
171
|
+
const fooRecord = await store.getTagRecord("foo");
|
|
172
|
+
expect(fooRecord).not.toBeNull();
|
|
173
|
+
expect(fooRecord!.parent_names).toEqual(["bar"]);
|
|
174
|
+
// The #-decorated upsert must NOT have created a phantom `#foo` row.
|
|
175
|
+
expect(await store.getTagRecord("#foo")).toBeNull();
|
|
176
|
+
|
|
177
|
+
// Inheritance: tag:bar expands to foo (foo's parent is bar).
|
|
178
|
+
const fooNote = await store.createNote("a foo note", { tags: ["foo"] });
|
|
179
|
+
const expanded = await store.queryNotes({ tags: ["bar"] });
|
|
180
|
+
expect(expanded.map((n) => n.id)).toContain(fooNote.id);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("update-tag with #-name preserves the existing bare record's fields (merge correctness)", async () => {
|
|
184
|
+
const tools = generateMcpTools(store);
|
|
185
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
186
|
+
|
|
187
|
+
await updateTag.execute({ tag: "thing", description: "first" });
|
|
188
|
+
// A #-decorated follow-up upsert must read the EXISTING bare record so the
|
|
189
|
+
// partial merge doesn't clobber the prior description.
|
|
190
|
+
await updateTag.execute({ tag: "#thing", parent_names: [] });
|
|
191
|
+
|
|
192
|
+
const record = await store.getTagRecord("thing");
|
|
193
|
+
expect(record!.description).toBe("first");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("rename carve-out — source name stays literal (migration escape hatch)", () => {
|
|
198
|
+
it("renameTag(#agent/message, agent/message) finds + renames the #-prefixed row", async () => {
|
|
199
|
+
// Plant a legacy #-prefixed tag row + a note carrying it (simulating the
|
|
200
|
+
// pre-fix agent data the migration must rename away).
|
|
201
|
+
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('#agent/message')").run();
|
|
202
|
+
const note = await store.createNote("legacy inbound");
|
|
203
|
+
db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, '#agent/message')").run(note.id);
|
|
204
|
+
|
|
205
|
+
const result = await store.renameTag("#agent/message", "agent/message");
|
|
206
|
+
expect("renamed" in result).toBe(true);
|
|
207
|
+
|
|
208
|
+
// The literal #-row is gone; the note now carries the bare tag.
|
|
209
|
+
expect(storedTags(note.id)).toEqual(["agent/message"]);
|
|
210
|
+
const tags = await store.listTags();
|
|
211
|
+
expect(tags.map((t) => t.name)).not.toContain("#agent/message");
|
|
212
|
+
expect(tags.map((t) => t.name)).toContain("agent/message");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("renameTag normalizes the TARGET — renaming TO a #-name stores bare (can't re-introduce #)", async () => {
|
|
216
|
+
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('plainsrc')").run();
|
|
217
|
+
const note = await store.createNote("x");
|
|
218
|
+
db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, 'plainsrc')").run(note.id);
|
|
219
|
+
|
|
220
|
+
await store.renameTag("plainsrc", "#shiny");
|
|
221
|
+
|
|
222
|
+
// The target #-prefix is stripped — no `#shiny` row, the note carries `shiny`.
|
|
223
|
+
expect(storedTags(note.id)).toEqual(["shiny"]);
|
|
224
|
+
expect((await store.listTags()).map((t) => t.name)).not.toContain("#shiny");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("mergeTags normalizes the TARGET — merging INTO a #-name stores bare", async () => {
|
|
228
|
+
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('m1')").run();
|
|
229
|
+
const note = await store.createNote("y");
|
|
230
|
+
db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, 'm1')").run(note.id);
|
|
231
|
+
|
|
232
|
+
await store.mergeTags(["m1"], "#dest");
|
|
233
|
+
|
|
234
|
+
expect(storedTags(note.id)).toEqual(["dest"]);
|
|
235
|
+
expect((await store.listTags()).map((t) => t.name)).not.toContain("#dest");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("regression — existing bare-tag behavior unchanged", () => {
|
|
240
|
+
it("bare write + bare query still works end to end", async () => {
|
|
241
|
+
const note = await store.createNote("plain", { tags: ["alpha", "beta"] });
|
|
242
|
+
expect(storedTags(note.id).sort()).toEqual(["alpha", "beta"]);
|
|
243
|
+
const results = await store.queryNotes({ tags: ["alpha"] });
|
|
244
|
+
expect(results.map((n) => n.id)).toContain(note.id);
|
|
245
|
+
});
|
|
246
|
+
});
|
package/core/src/mcp.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Store, Note } from "./types.js";
|
|
|
3
3
|
import * as noteOps from "./notes.js";
|
|
4
4
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
5
5
|
import { QueryError } from "./query-operators.js";
|
|
6
|
-
import { TAG_EXPAND_MODES, type TagExpandMode } from "./tag-hierarchy.js";
|
|
6
|
+
import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "./tag-hierarchy.js";
|
|
7
7
|
import * as linkOps from "./links.js";
|
|
8
8
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
9
9
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -1453,7 +1453,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1453
1453
|
required: ["tag"],
|
|
1454
1454
|
},
|
|
1455
1455
|
execute: async (params) => {
|
|
1456
|
-
|
|
1456
|
+
// Canonical-bare-tag guard (PR #516): normalize the tag NAME up front
|
|
1457
|
+
// so the existing-record lookup (and the field/cross-tag merge that
|
|
1458
|
+
// depends on it) reads the bare `foo` row, not a phantom `#foo` miss.
|
|
1459
|
+
// store.upsertTagRecord normalizes again (idempotent) for non-MCP
|
|
1460
|
+
// callers; doing it here keeps the merge correct.
|
|
1461
|
+
const tag = stripTagHash(params.tag as string);
|
|
1457
1462
|
const existing = tagSchemaOps.getTagRecord(db, tag);
|
|
1458
1463
|
|
|
1459
1464
|
// ---- fields: three-way semantics, distinguishing `null` from
|
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,
|
|
@@ -251,8 +252,30 @@ export class BunSqliteStore implements Store {
|
|
|
251
252
|
);
|
|
252
253
|
}
|
|
253
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
|
+
|
|
254
277
|
async queryNotes(opts: QueryOpts): Promise<Note[]> {
|
|
255
|
-
return noteOps.queryNotes(this.db, this.expandQueryTags(opts));
|
|
278
|
+
return noteOps.queryNotes(this.db, this.expandQueryTags(this.normalizeQueryTags(opts)));
|
|
256
279
|
}
|
|
257
280
|
|
|
258
281
|
async queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage> {
|
|
@@ -262,7 +285,11 @@ export class BunSqliteStore implements Store {
|
|
|
262
285
|
// descendant set → different rows match → caller should restart). The
|
|
263
286
|
// alternative — hash the expanded set — would silently keep returning
|
|
264
287
|
// stale results from a hierarchy snapshot the caller never saw.
|
|
265
|
-
|
|
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)));
|
|
266
293
|
}
|
|
267
294
|
|
|
268
295
|
/**
|
|
@@ -332,6 +359,11 @@ export class BunSqliteStore implements Store {
|
|
|
332
359
|
}
|
|
333
360
|
|
|
334
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
|
+
}
|
|
335
367
|
// Same tag-expansion treatment as queryNotes, along the SAME `expand` axis
|
|
336
368
|
// (vault tag `expand` axis) — searching `#manual` should match notes
|
|
337
369
|
// tagged with any descendant under "subtypes", any `manual/*` under
|
|
@@ -630,6 +662,18 @@ export class BunSqliteStore implements Store {
|
|
|
630
662
|
parent_names?: string[] | null;
|
|
631
663
|
},
|
|
632
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
|
+
}
|
|
633
677
|
// Snapshot the prior indexed-field set BEFORE the write so the diff below
|
|
634
678
|
// sees what this tag declared going in. Only needed when `fields` changes.
|
|
635
679
|
const priorRecord =
|
|
@@ -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/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
|
}
|
package/src/routes.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
|
-
import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
15
|
+
import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
16
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
17
17
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
18
18
|
import {
|
|
@@ -2086,7 +2086,15 @@ export async function handleTags(
|
|
|
2086
2086
|
// of { description, fields, relationships, parent_names }; omitted keys
|
|
2087
2087
|
// are preserved, explicit null clears. See patterns/tag-data-model.md.
|
|
2088
2088
|
if (req.method === "PUT") {
|
|
2089
|
-
|
|
2089
|
+
// Canonical-bare-tag guard (vault#XXX): normalize the upserted tag NAME so
|
|
2090
|
+
// the existing-field merge read (store.getTagSchema below) and the upsert
|
|
2091
|
+
// both target the bare row. store.upsertTagRecord re-normalizes (idempotent)
|
|
2092
|
+
// for the write; this keeps the partial-merge read correct for a
|
|
2093
|
+
// `#`-decorated PUT path. (GET/DELETE/rename keep their literal lookups —
|
|
2094
|
+
// rename's source-name must still match a `#`-prefixed legacy row so the
|
|
2095
|
+
// data migration can rename it away.)
|
|
2096
|
+
const putTagName = stripTagHash(tagName);
|
|
2097
|
+
if (tagScope.allowed && !tagScope.allowed.has(putTagName)) {
|
|
2090
2098
|
return tagScopeForbidden(tagScope.raw ?? []);
|
|
2091
2099
|
}
|
|
2092
2100
|
const body = (await req.json()) as {
|
|
@@ -2157,7 +2165,7 @@ export async function handleTags(
|
|
|
2157
2165
|
const full = body.fields as Record<string, tagSchemaOps.TagFieldSchema>;
|
|
2158
2166
|
fieldsPatch = Object.keys(full).length > 0 ? full : null;
|
|
2159
2167
|
} else {
|
|
2160
|
-
const existing = await store.getTagSchema(
|
|
2168
|
+
const existing = await store.getTagSchema(putTagName);
|
|
2161
2169
|
const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
|
|
2162
2170
|
...(existing?.fields ?? {}),
|
|
2163
2171
|
...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
|
|
@@ -2172,7 +2180,7 @@ export async function handleTags(
|
|
|
2172
2180
|
// unchanged on failure (no orphan/lying index). vault#478.
|
|
2173
2181
|
let result;
|
|
2174
2182
|
try {
|
|
2175
|
-
result = await store.upsertTagRecord(
|
|
2183
|
+
result = await store.upsertTagRecord(putTagName, {
|
|
2176
2184
|
...(body.description !== undefined ? { description: body.description } : {}),
|
|
2177
2185
|
...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
|
|
2178
2186
|
...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
|