@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.
- package/core/src/history-capture.test.ts +9 -1
- package/core/src/history-visibility.test.ts +11 -0
- package/core/src/history-visibility.ts +9 -0
- package/core/src/history.test.ts +57 -0
- package/core/src/history.ts +12 -1
- package/core/src/store.ts +2 -0
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +49 -27
- package/src/auto-transcribe-integration.test.ts +82 -0
- package/src/auto-transcribe.test.ts +126 -374
- package/src/auto-transcribe.ts +43 -215
- package/src/history-import-routes.test.ts +23 -1
- package/src/history-import.test.ts +34 -2
- package/src/history-import.ts +4 -1
- package/src/mcp-tools.ts +8 -2
- package/src/routes.ts +5 -3
- package/src/server.ts +7 -7
- package/src/transcription-registry.ts +9 -7
- package/src/vault-compact-routes.test.ts +1 -1
- package/src/vault-history-routes.test.ts +50 -1
- package/src/vault.test.ts +40 -17
|
@@ -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
|
+
}
|
package/core/src/history.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/core/src/history.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
+
}
|