@openparachute/vault 0.7.9-rc.6 → 0.7.9-rc.8

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.
@@ -86,9 +86,17 @@ it("P5 restore reads its source before a one-version prune", async () => {
86
86
  });
87
87
  const n = await store.createNote("one");
88
88
  await store.updateNote(n.id, { content: "two" });
89
- expect((await store.restoreNoteVersion(n.id, 0, {})).content).toBe("one");
89
+ expect((await store.restoreNoteVersion(n.id, 0, { if_updated_at: (await store.getNote(n.id))!.updatedAt })).content).toBe("one");
90
90
  expect(await store.listNoteVersions(n.id)).toHaveLength(1);
91
91
  });
92
+ it("live restore requires a precondition and leaves current note and history unchanged without one", async () => {
93
+ const n = await store.createNote("old");
94
+ await store.updateNote(n.id, { content: "new" });
95
+ const before = await store.getNote(n.id), versions = await store.listNoteVersions(n.id);
96
+ await expect(store.restoreNoteVersion(n.id, 0, {})).rejects.toMatchObject({ code: "PRECONDITION_REQUIRED" });
97
+ expect(await store.getNote(n.id)).toEqual(before);
98
+ expect(await store.listNoteVersions(n.id)).toEqual(versions);
99
+ });
92
100
  it("P8a tag rename captures only rewritten notes", async () => {
93
101
  await store.upsertTagRecord("old", {});
94
102
  const a = await store.createNote("#old a"),
@@ -0,0 +1,11 @@
1
+ import { test, expect } from "bun:test";
2
+ import { projectHistoryProvenance } from "./history-visibility.ts";
3
+
4
+ test("history redaction removes only transport provenance without mutating stored data", () => {
5
+ const row = Object.freeze({ actor: "editor", via: "api", content: "editor mentioned in text",
6
+ metadata: { actor: "user-authored value" }, origin: "git-import", import_ix: 0 });
7
+ expect(projectHistoryProvenance(row, false)).toBe(row);
8
+ expect(projectHistoryProvenance(row, true)).toEqual({ content: row.content, metadata: row.metadata,
9
+ origin: "git-import", import_ix: 0 });
10
+ expect(row.actor).toBe("editor");
11
+ });
@@ -0,0 +1,9 @@
1
+ /** Response-only redaction; never remove stored provenance or rewrite note content.
2
+ * Tag-restricted sessions do not receive historical editor/interface identities.
3
+ * The transport must establish note visibility before applying this projection.
4
+ */
5
+ export function projectHistoryProvenance<T extends object>(row: T, restricted: boolean): T | Omit<T, "actor" | "via"> {
6
+ if (!restricted) return row;
7
+ const { actor: _actor, via: _via, ...visible } = row as T & { actor?: unknown; via?: unknown };
8
+ return visible;
9
+ }
@@ -227,3 +227,60 @@ describe("vault#524 history retention and blob integrity", () => {
227
227
  expect(await store.getNote(n.id)).toBeNull();
228
228
  });
229
229
  });
230
+
231
+ describe("vault#743 whole-blob integrity", () => {
232
+ it("reads valid whole blobs including empty and multibyte text", async () => {
233
+ const empty = await store.createNote("placeholder");
234
+ await store.updateNote(empty.id, { content: "" });
235
+ await store.updateNote(empty.id, { content: "final" });
236
+ expect((await store.getNoteVersion(empty.id, 1))!.content).toBe("");
237
+
238
+ const multi = await store.createNote("é".repeat(1000));
239
+ await store.updateNote(multi.id, { content: "next" });
240
+ expect((await store.getNoteVersion(multi.id, 0))!.content).toBe("é".repeat(1000));
241
+
242
+ const plain = await store.createNote("whole body");
243
+ await store.updateNote(plain.id, { content: "updated" });
244
+ expect((await store.getNoteVersion(plain.id, 0))!.content).toBe("whole body");
245
+ });
246
+ it("rejects a corrupted whole blob on direct read", async () => {
247
+ const n = await store.createNote("original");
248
+ await store.updateNote(n.id, { content: "live" });
249
+ const hash = (await store.listNoteVersions(n.id))[0]!.content_hash;
250
+ db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
251
+ "tampered",
252
+ hash,
253
+ );
254
+ expect(() => history!.readBlobContent(db, hash!)).toThrow(
255
+ history!.HistoryDeltaOrphanError,
256
+ );
257
+ });
258
+ it("rejects a corrupted whole blob through version read", async () => {
259
+ const n = await store.createNote("original");
260
+ await store.updateNote(n.id, { content: "live" });
261
+ const hash = (await store.listNoteVersions(n.id))[0]!.content_hash;
262
+ db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
263
+ "tampered",
264
+ hash,
265
+ );
266
+ await expect(store.getNoteVersion(n.id, 0)).rejects.toMatchObject({
267
+ code: "HISTORY_UNRECOVERABLE",
268
+ reason: "delta_orphan",
269
+ });
270
+ });
271
+ it("rejects restoring a corrupted whole blob and leaves live note and history unchanged", async () => {
272
+ const n = await store.createNote("original");
273
+ await store.updateNote(n.id, { content: "live" });
274
+ const versionsBefore = await store.listNoteVersions(n.id);
275
+ const hash = versionsBefore[0]!.content_hash;
276
+ db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
277
+ "tampered",
278
+ hash,
279
+ );
280
+ await expect(store.restoreNoteVersion(n.id, 0, {})).rejects.toMatchObject({
281
+ code: "HISTORY_UNRECOVERABLE",
282
+ });
283
+ expect((await store.getNote(n.id))!.content).toBe("live");
284
+ expect(await store.listNoteVersions(n.id)).toEqual(versionsBefore);
285
+ });
286
+ });
@@ -119,6 +119,14 @@ export class HistoryUnrecoverableError extends Error {
119
119
  this.name = "HistoryUnrecoverableError";
120
120
  }
121
121
  }
122
+ export class HistoryPreconditionRequiredError extends Error {
123
+ readonly code = "PRECONDITION_REQUIRED";
124
+ readonly error_type = "precondition_required";
125
+ constructor(readonly note_id: string) {
126
+ super("Restoring an existing note requires if_updated_at from the note you reviewed.");
127
+ }
128
+ }
129
+
122
130
  export class HistoryNotFoundError extends Error {
123
131
  code = "HISTORY_NOT_FOUND" as const;
124
132
  note_id: string;
@@ -438,8 +446,11 @@ interface BlobReadRow {
438
446
  function materialise(db: Database, hash: string, row: BlobReadRow): string | null {
439
447
  if (row.content === null)
440
448
  return null;
441
- if (row.blob_encoding === null)
449
+ if (row.blob_encoding === null) {
450
+ if (hashContent(row.content) !== hash)
451
+ throw new HistoryDeltaOrphanError(hash, row.delta_of, "identity_mismatch");
442
452
  return row.content;
453
+ }
443
454
  if (row.blob_encoding !== "fossil-delta")
444
455
  throw new HistoryDeltaOrphanError(hash, row.delta_of, "unknown_encoding");
445
456
  const base = row.delta_of ? db.prepare("SELECT content, encoding FROM note_blobs WHERE hash = ?").get(row.delta_of) as {
package/core/src/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { HistoryPreconditionRequiredError } from "./history.js";
2
3
  import type { Store, Note, Link, Attachment, QueryOpts, QueryNotesPage, AggregateRow, SemanticSearchResult } from "./types.js";
3
4
  import { compactNote, compactVault, countNoteVersions, type CompactResult, type CompactSummary, captureVersion, readPriorNoteRow, resolveHistoryPolicy, appendRestoreMarker, listVersions, getVersion, latestTombstone, eraseHistory, sweepDeletedHistory, deletedHistoryStats, HistoryNotFoundError, HistoryOverflowError, HistoryUnrecoverableError, DEFAULT_HISTORY_POLICY, VERSION_MAX_BYTES, type VersionRow, type HistoryPolicy, type HistoryOp } from "./history.js";
4
5
  import { initSchema } from "./schema.js";
@@ -834,6 +835,7 @@ export class BunSqliteStore implements Store {
834
835
  const v = getVersion(this.db, id, versionIx);
835
836
  if (!v) throw new HistoryNotFoundError(id, versionIx);
836
837
  if (v.encoding === "overflow") throw new HistoryUnrecoverableError(id, versionIx);
838
+ if (opts.if_updated_at === undefined) throw new HistoryPreconditionRequiredError(id);
837
839
  return await this.updateNote(id, {
838
840
  content: v.content!, metadata: v.metadata, extension: v.extension ?? undefined,
839
841
  actor: opts.actor ?? null, via: opts.via ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.9-rc.6",
3
+ "version": "0.7.9-rc.8",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -29,6 +29,7 @@ const { handleScopedMcp } = await import("./mcp-http.ts");
29
29
  const { getServerInstruction } = await import("./mcp-tools.ts");
30
30
  const { writeVaultConfig } = await import("./config.ts");
31
31
  const { getVaultStore } = await import("./vault-store.ts");
32
+ const { clearScribeUrlCache } = await import("./scribe-discovery.ts");
32
33
  const {
33
34
  getSharedAttachmentTicketProvider,
34
35
  InProcessAttachmentTicketProvider,
@@ -275,33 +276,54 @@ describe("attachment tickets — upload lifecycle", () => {
275
276
  // exactly what a new box looks like. The upload must not come back looking
276
277
  // like an ordinary file: it carries a `failed` status and an actionable
277
278
  // reason, so the state is visible in the API and the admin SPA.
278
- test("audio + auto-transcribe on + NO provider → attachment records the failure, not silence", async () => {
279
- const vaultName = freshVault("tickets-no-provider");
280
- const store = getVaultStore(vaultName);
281
- const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
282
-
283
- const mint = await callTool(vaultName, "request-attachment-upload", {
284
- note: note.id,
285
- filename: "memo.webm",
286
- size_bytes: 4,
287
- // NO `transcribe: true` — this is the AUTO path, the one that used to
288
- // silently do nothing.
289
- });
290
- const res = await routeReq(
291
- new Request(mint.url, {
292
- method: "PUT",
293
- headers: { "content-type": "audio/webm" },
294
- body: new Uint8Array([1, 2, 3, 4]),
295
- }),
296
- );
297
- expect(res.status).toBe(201);
298
- const attachment = (await res.json()) as any;
299
- expect(attachment.metadata.transcribe_status).toBe("failed");
300
- expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
301
- // The reason has to be actionable — naming both routes out of it.
302
- expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
303
- expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
304
- expect(attachment.metadata.transcribe_origin).toBe("auto");
279
+ //
280
+ // Fixture: select the remote provider so the presence/absence of a local
281
+ // whisper-cpp install on the host cannot change the message. With no
282
+ // SCRIBE_URL configured, auto-transcribe is still unavailable and reports
283
+ // the same actionable no-provider error on every box.
284
+ test("audio + auto-transcribe on + scribe-http without URL → attachment records the failure, not silence", async () => {
285
+ const prevProvider = process.env.TRANSCRIPTION_PROVIDER;
286
+ const prevScribeUrl = process.env.SCRIBE_URL;
287
+ process.env.TRANSCRIPTION_PROVIDER = "scribe-http";
288
+ delete process.env.SCRIBE_URL;
289
+ clearScribeUrlCache();
290
+ try {
291
+ const vaultName = freshVault("tickets-no-provider");
292
+ const store = getVaultStore(vaultName);
293
+ const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
294
+
295
+ const mint = await callTool(vaultName, "request-attachment-upload", {
296
+ note: note.id,
297
+ filename: "memo.webm",
298
+ size_bytes: 4,
299
+ // NO `transcribe: true` — this is the AUTO path, the one that used to
300
+ // silently do nothing.
301
+ });
302
+ const res = await routeReq(
303
+ new Request(mint.url, {
304
+ method: "PUT",
305
+ headers: { "content-type": "audio/webm" },
306
+ body: new Uint8Array([1, 2, 3, 4]),
307
+ }),
308
+ );
309
+ expect(res.status).toBe(201);
310
+ const attachment = (await res.json()) as any;
311
+ expect(attachment.metadata.transcribe_status).toBe("failed");
312
+ expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
313
+ // The reason has to be actionable — naming both routes out of it.
314
+ expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
315
+ expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
316
+ expect(attachment.metadata.transcribe_origin).toBe("auto");
317
+ const unchanged = await store.getNote(note.id);
318
+ expect(unchanged!.content).toBe("# Voice memo\n");
319
+ expect((unchanged!.metadata as any)?.transcribe_stub).toBeUndefined();
320
+ } finally {
321
+ if (prevProvider === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
322
+ else process.env.TRANSCRIPTION_PROVIDER = prevProvider;
323
+ if (prevScribeUrl === undefined) delete process.env.SCRIBE_URL;
324
+ else process.env.SCRIBE_URL = prevScribeUrl;
325
+ clearScribeUrlCache();
326
+ }
305
327
  });
306
328
 
307
329
  // The counter-case: turning auto-transcribe OFF must stay silent. The
@@ -0,0 +1,82 @@
1
+ import { test, expect } from "bun:test";
2
+ import { writeVaultConfig } from "./config.ts";
3
+ import { getVaultStore, defaultHookRegistry } from "./vault-store.ts";
4
+ import { handleNotes } from "./routes.ts";
5
+ import { handleScopedMcp } from "./mcp-http.ts";
6
+ import { route } from "./routing.ts";
7
+ import { registerTranscriptionHook } from "./transcription-worker.ts";
8
+ import { getTranscriptionWorker, getTranscriptionWorkerProvider, setTranscriptionWorker } from "./transcription-registry.ts";
9
+
10
+ // Fake the wired worker, not module imports or real ASR. Exercise both upload
11
+ // doors and the actual attachment-created hook without network/subprocess work.
12
+ for (const door of ["rest", "ticket"] as const) {
13
+ for (const mode of ["local", "remote", "missing", "mismatch", "unready", "disabled", "explicit", "non-audio"] as const) {
14
+ test(`${door}: ${mode} automatic worker eligibility`, async () => {
15
+ const previous = { provider: process.env.TRANSCRIPTION_PROVIDER, url: process.env.SCRIBE_URL, model: process.env.TRANSCRIPTION_MODEL, assets: process.env.ASSETS_DIR };
16
+ const oldWorker = getTranscriptionWorker();
17
+ const oldProvider = getTranscriptionWorkerProvider();
18
+ const vault = `auto-worker-${crypto.randomUUID()}`;
19
+ const kicks: string[] = [];
20
+ const worker = { stop: async () => {}, tick: async () => 0,
21
+ kick: async (_vault: string, attachment: { id: string }) => { kicks.push(attachment.id); } };
22
+ writeVaultConfig({ name: vault, api_keys: [], created_at: new Date().toISOString(),
23
+ auto_transcribe: { enabled: mode !== "disabled" && mode !== "explicit" } });
24
+ const store = getVaultStore(vault);
25
+ const unregister = registerTranscriptionHook(defaultHookRegistry, worker, (s) => s === store ? vault : undefined);
26
+ try {
27
+ // Sibling files may set a process-global storage root. These uploads
28
+ // belong only to this unique vault, not that shared test directory.
29
+ delete process.env.ASSETS_DIR;
30
+ process.env.TRANSCRIPTION_PROVIDER = mode === "remote" ? "scribe-http" : mode === "unready" ? "whisper-cpp" : "transcribe-cpp";
31
+ // A stale URL must never make an absent/mismatched local worker ready.
32
+ if (mode === "local") delete process.env.SCRIBE_URL;
33
+ else process.env.SCRIBE_URL = "http://unused.invalid";
34
+ if (mode === "unready") process.env.TRANSCRIPTION_MODEL = "not-a-real-model";
35
+ setTranscriptionWorker(mode === "missing" ? null : worker,
36
+ mode === "mismatch" ? "scribe-http" : process.env.TRANSCRIPTION_PROVIDER);
37
+ const note = await store.createNote("original body", { path: "memo" });
38
+ const mime = mode === "non-audio" ? "image/png" : "audio/wav";
39
+ let response: Response;
40
+ if (door === "rest") {
41
+ response = await handleNotes(new Request(`http://localhost/notes/${note.id}/attachments`, {
42
+ method: "POST", headers: { "content-type": "application/json" },
43
+ body: JSON.stringify({ path: "memos/audio.wav", mimeType: mime,
44
+ ...(mode === "explicit" ? { transcribe: true } : {}) }),
45
+ }), store, `/${note.id}/attachments`, vault);
46
+ } else {
47
+ const mint = await handleScopedMcp(new Request(`http://localhost/vault/${vault}/mcp`, {
48
+ method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
49
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: {
50
+ name: "request-attachment-upload", arguments: { note: note.id, filename: "memo.wav", size_bytes: 4,
51
+ mime_type: mime, ...(mode === "explicit" ? { transcribe: true } : {}) },
52
+ } }),
53
+ }), vault, { permission: "full", scopes: ["vault:read", "vault:write"], legacyDerived: false, scoped_tags: null } as any);
54
+ const rpc = await mint.json() as any;
55
+ expect(rpc.error).toBeUndefined();
56
+ const ticket = JSON.parse(rpc.result.content[0].text);
57
+ const req = new Request(ticket.url, { method: "PUT", headers: { "content-type": mime }, body: new Uint8Array([1, 2, 3, 4]) });
58
+ response = await route(req, new URL(req.url).pathname);
59
+ }
60
+ expect(response.status).toBe(201);
61
+ const attachment = await response.json() as any;
62
+ await defaultHookRegistry.drain();
63
+ const eligible = ["local", "remote", "explicit"].includes(mode);
64
+ const unavailable = ["missing", "mismatch", "unready"].includes(mode);
65
+ expect(attachment.metadata?.transcribe_status).toBe(eligible ? "pending" : unavailable ? "failed" : undefined);
66
+ expect(kicks).toEqual(eligible ? [attachment.id] : []);
67
+ if (unavailable) expect(attachment.metadata.transcribe_error).toContain("transcription status");
68
+ if (eligible || unavailable) expect(attachment.metadata.transcribe_origin).toBe(mode === "explicit" ? "legacy" : "auto");
69
+ const current = await store.getNote(note.id);
70
+ expect(current!.content).toBe("original body");
71
+ if (mode !== "explicit") expect((current!.metadata as any)?.transcribe_stub).toBeUndefined();
72
+ } finally {
73
+ await defaultHookRegistry.drain();
74
+ unregister();
75
+ setTranscriptionWorker(oldWorker, oldProvider);
76
+ for (const [key, value] of [["TRANSCRIPTION_PROVIDER", previous.provider], ["SCRIBE_URL", previous.url], ["TRANSCRIPTION_MODEL", previous.model], ["ASSETS_DIR", previous.assets]] as const) {
77
+ if (value === undefined) delete process.env[key]; else process.env[key] = value;
78
+ }
79
+ }
80
+ });
81
+ }
82
+ }