@openparachute/vault 0.7.9-rc.7 → 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
+ }
@@ -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;
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.7",
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",
@@ -45,6 +45,24 @@ async function call(path: string, method = "GET", body?: unknown, verb = "admin"
45
45
  }
46
46
 
47
47
  import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
48
+ test("authenticated history REST redacts scoped provenance for read/write/admin tokens", async () => {
49
+ const note = await store.createNote("before", { tags: ["journal"] });
50
+ await store.updateNote(note.id, { content: "after", actor: "HISTORICAL_EDITOR", via: "HISTORICAL_INTERFACE" });
51
+ for (const verb of ["read", "write", "admin"]) {
52
+ for (const scoped of [false, true]) {
53
+ for (const suffix of ["/versions", "/versions/0"]) {
54
+ const got = await call(`/notes/${note.id}${suffix}`, "GET", undefined, verb, scoped);
55
+ expect(got.status).toBe(200);
56
+ const row = got.body.versions?.[0] ?? got.body;
57
+ expect(Object.hasOwn(row, "actor")).toBe(!scoped);
58
+ expect(Object.hasOwn(row, "via")).toBe(!scoped);
59
+ if (!scoped) expect(row.actor).toBe("HISTORICAL_EDITOR");
60
+ expect(row.version_ix).toBe(0);
61
+ }
62
+ }
63
+ }
64
+ expect((await store.getNoteVersion(note.id, 0))!.actor).toBe("HISTORICAL_EDITOR");
65
+ });
48
66
  test("imported REST references restore by ID or encoded path and preserve native keys", async () => {
49
67
  const note = await store.createNote("native", { path: "folder/imported", tags: ["journal"] });
50
68
  await store.updateNote(note.id, { content: "current" });
@@ -66,7 +84,11 @@ test("imported REST references restore by ID or encoded path and preserve native
66
84
  expect(list.body.versions.map((v: any) => v.version_ix ?? v.origin)).toEqual([0, "git-import"]);
67
85
  for (const body of [{ origin: "git-import" }, { import_ix: 0 }, { origin: "git-import", import_ix: 0, version_ix: 0 }, { origin: "git-import", import_ix: -1 }]) expect((await call(`/notes/${note.id}/restore`, "POST", body)).status).toBe(400);
68
86
  expect((await call(`/notes/${note.id}/versions/-1`)).status).toBe(400);
69
- const restored = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
87
+ const beforeRestore = await store.getNote(note.id);
88
+ const missingStamp = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
89
+ expect(missingStamp.status).toBe(428);
90
+ expect(await store.getNote(note.id)).toEqual(beforeRestore);
91
+ const restored = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0, if_updated_at: beforeRestore!.updatedAt });
70
92
  expect(restored.status).toBe(200);
71
93
  expect(restored.body.restored_from).toEqual({ origin: "git-import", import_ix: 0 });
72
94
  expect((await store.getNote(note.id))!.content).toBe("archive");
@@ -1,6 +1,6 @@
1
- import { test, expect } from "bun:test";
1
+ import { test, expect, spyOn } from "bun:test";
2
2
  import { createHash } from "node:crypto";
3
- import { Database } from "bun:sqlite";
3
+ import { Database, SQLiteError } from "bun:sqlite";
4
4
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { tmpdir } from "node:os";
@@ -119,6 +119,38 @@ test("ambiguous sidecars quarantine both identities; malformed YAML diagnostics
119
119
  } finally { stage.close(); rmSync(dir, { recursive: true, force: true }); }
120
120
  });
121
121
 
122
+ for (const format of ["inline", "sidecar"] as const) for (const cache of ["object_cache", "parsed_cache"] as const) {
123
+ test(`staging SQLite failure in ${format} ${cache} aborts without blaming source notes`, () => {
124
+ const dir = mkdtempSync(join(tmpdir(), "import-stage-failure-")), stage = new Database(":memory:");
125
+ const exec = stage.exec.bind(stage);
126
+ const setup = spyOn(stage, "exec").mockImplementation((sql) => {
127
+ const result = exec(sql);
128
+ if (sql.startsWith("CREATE TABLE selection_failures")) {
129
+ exec(`CREATE TRIGGER fail_cache BEFORE INSERT ON ${cache} BEGIN SELECT RAISE(ABORT, 'staging write failed'); END`);
130
+ }
131
+ return result;
132
+ });
133
+ try {
134
+ const repo = repoAt(join(dir, "repo"));
135
+ if (format === "inline") commit(repo, "valid body");
136
+ else {
137
+ mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
138
+ writeFileSync(join(repo, `.parachute/notes-meta/${noteId}.yaml`), `id: ${noteId}\npath: entry\nextension: json\n`);
139
+ writeFileSync(join(repo, "entry.json"), '{"valid":true}');
140
+ git(repo, "add", "."); git(repo, "commit", "-m", "valid sidecar");
141
+ }
142
+ let failure: unknown;
143
+ try { stageArchive(repo, git(repo, "rev-parse", "HEAD"), stage); }
144
+ catch (error) { failure = error; }
145
+ expect(failure).toBeInstanceOf(SQLiteError);
146
+ expect((failure as Error).message).toContain("staging write failed");
147
+ expect(stage.query("SELECT * FROM issues").all()).toEqual([]);
148
+ expect(stage.query("SELECT * FROM quarantine").all()).toEqual([]);
149
+ expect(stage.query("SELECT * FROM observations").all()).toEqual([]);
150
+ } finally { setup.mockRestore(); stage.close(); rmSync(dir, { recursive: true, force: true }); }
151
+ });
152
+ }
153
+
122
154
  test("running daemon refuses prepare before creating a pause marker or backup", async () => {
123
155
  const dir = mkdtempSync(join(tmpdir(), "import-busy-")), saved = process.env.PARACHUTE_HOME;
124
156
  process.env.PARACHUTE_HOME = dir;
@@ -1,5 +1,5 @@
1
1
  /** Offline git-history import. Git objects are data; no working tree is checked out. */
2
- import { Database } from "bun:sqlite";
2
+ import { Database, SQLiteError } from "bun:sqlite";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -182,6 +182,9 @@ export function stageArchive(repo: string, tip: string, stage: Database, inputSe
182
182
  collect(id, state, () => JSON.parse((parsedBody.get(cacheKey) as { row_json: string }).row_json), { git_path: entry.path, blob: entry.hash, sidecar });
183
183
  };
184
184
  const failure = (entry: typeof entries[number], error: unknown, id?: unknown) => {
185
+ // Staging/cache failures are operational, not evidence of malformed source.
186
+ // Abort so no diagnostic manifest can bless an incomplete archive walk.
187
+ if (error instanceof SQLiteError) throw error;
185
188
  const message = error instanceof Error ? error.message : "invalid_observation";
186
189
  const reason = /^[a-z_]+$/.test(message) ? message : "invalid_metadata_or_utf8";
187
190
  if (error instanceof GitArchiveError || message === "Archive byte limit exceeded" || message === "Archive note limit exceeded") throw error;
package/src/mcp-tools.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { generateMcpTools, resolveNote } from "../core/src/mcp.ts";
9
+ import { projectHistoryProvenance } from "../core/src/history-visibility.js";
9
10
  import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
10
11
  import { getNote, getNoteTags, getVaultMap } from "../core/src/notes.ts";
11
12
  import { narrowLinkWarningsForVisibility } from "../core/src/wikilinks.ts";
@@ -579,8 +580,13 @@ function applyTagScopeWrappers(
579
580
  )) {
580
581
  const id = (params as any).versions.note_id;
581
582
  const note = resolveNote(store.db, id);
582
- return note && noteWithinTagScope(note, allowed, rawTags)
583
- ? result : { error: "Note not found", error_type: "not_found", id };
583
+ if (!note || !noteWithinTagScope(note, allowed, rawTags)) {
584
+ return { error: "Note not found", error_type: "not_found", id };
585
+ }
586
+ if ("versions" in result && Array.isArray(result.versions)) {
587
+ return { ...result, versions: result.versions.map(row => projectHistoryProvenance(row, true)) };
588
+ }
589
+ return projectHistoryProvenance(result, true);
584
590
  }
585
591
  // Possible response shapes (vault#550 added the `warnings` variants):
586
592
  // - Array (legacy list, no cursor, no warnings)
package/src/routes.ts CHANGED
@@ -13,6 +13,7 @@ import { getImportedVersion, importStorageIndex, parseHistorySelector, projectHi
13
13
  */
14
14
 
15
15
  import { HistoryNotFoundError, latestTombstone } from "../core/src/history.js";
16
+ import { projectHistoryProvenance } from "../core/src/history-visibility.js";
16
17
  import { ULID_REGEX } from "../core/src/ulid.js";
17
18
  import type { Database } from "bun:sqlite";
18
19
  import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
@@ -268,6 +269,7 @@ function conflictResponse(e: any): Response | null {
268
269
  400,
269
270
  );
270
271
  }
272
+ if (e && e.code === "PRECONDITION_REQUIRED") return json({ error_type: "precondition_required", note_id: e.note_id, message: e.message }, 428);
271
273
  if (e && e.code === "HISTORY_NOT_FOUND") return json({ error: e.message, error_type: "not_found" }, 404);
272
274
  if (e && e.code === "HISTORY_OVERFLOW") return json({
273
275
  error_type: "history_overflow", note_id: e.note_id, byte_size: e.byte_size, limit: e.limit, message: e.message,
@@ -2942,7 +2944,7 @@ async function handleNotesInner(
2942
2944
  const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
2943
2945
  const versions = await store.listNoteVersions(id, { limit, offset });
2944
2946
  const total = await store.countNoteVersions(id);
2945
- return json({ versions: versions.map(projectHistoryRow), total });
2947
+ return json({ versions: versions.map(row => projectHistoryProvenance(projectHistoryRow(row), tagScope.allowed !== null)), total });
2946
2948
  }
2947
2949
  if (method === "DELETE") {
2948
2950
  const result = await store.eraseNoteHistory(id);
@@ -2953,7 +2955,7 @@ async function handleNotesInner(
2953
2955
  if (!/^\d+$/.test(importMatch[1]!) || !Number.isSafeInteger(index) || index < 0) return json({ error: "Invalid import_ix", error_type: "invalid_request" }, 400);
2954
2956
  try {
2955
2957
  const version = getImportedVersion(db, id, index);
2956
- return version ? json(projectHistoryRow(version)) : json({ error: "Not found", error_type: "not_found", origin: "git-import", import_ix: index }, 404);
2958
+ return version ? json(projectHistoryProvenance(projectHistoryRow(version), tagScope.allowed !== null)) : json({ error: "Not found", error_type: "not_found", origin: "git-import", import_ix: index }, 404);
2957
2959
  } catch (e) {
2958
2960
  const response = conflictResponse(e);
2959
2961
  if (response) return response;
@@ -2964,7 +2966,7 @@ async function handleNotesInner(
2964
2966
  if (!/^\d+$/.test(verMatch[1]!) || !Number.isInteger(ix) || ix < 0) return json({ error: "Invalid version_ix", error_type: "invalid_request" }, 400);
2965
2967
  try {
2966
2968
  const version = await store.getNoteVersion(id, ix);
2967
- return version ? json(version) : json({ error: "Not found", error_type: "not_found" }, 404);
2969
+ return version ? json(projectHistoryProvenance(version, tagScope.allowed !== null)) : json({ error: "Not found", error_type: "not_found" }, 404);
2968
2970
  } catch (e) {
2969
2971
  const response = conflictResponse(e);
2970
2972
  if (response) return response;
@@ -64,7 +64,7 @@ test("P9/P18 compacted REST and MCP versions roundtrip with exact keys", async (
64
64
  expect(mcp).toEqual(get.body);
65
65
  const ml = await query()({ versions: { note_id: n.id, limit: 2 } }) as any;
66
66
  expect(ml.total).toBe(list.body.total);
67
- const restore = await call(`/notes/${n.id}/restore`, "POST", { version_ix: 0 });
67
+ const restore = await call(`/notes/${n.id}/restore`, "POST", { version_ix: 0, if_updated_at: (await store.getNote(n.id))!.updatedAt });
68
68
  expect(restore.status).toBe(200);
69
69
  expect((await store.getNote(n.id))!.content).toBe(before.content);
70
70
  });
@@ -11,6 +11,8 @@ import {
11
11
  import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
12
12
  import { generateScopedMcpTools } from "./mcp-tools.ts";
13
13
  import { runDoctorScan } from "../core/src/doctor.ts";
14
+ import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
15
+ import { resolveHistoryPolicy } from "../core/src/history.ts";
14
16
  let taskDir: string,
15
17
  savedHome: string | undefined,
16
18
  store: ReturnType<typeof getVaultStore>;
@@ -78,9 +80,56 @@ async function fixture() {
78
80
  await store.updateNote(n.id, { content: "two" });
79
81
  return n;
80
82
  }
83
+ test("tag-scoped REST and MCP history omit provenance without changing stored rows", async () => {
84
+ const n = await store.createNote("original", { path: "history-visible", tags: ["journal"] });
85
+ await store.updateNote(n.id, { content: "current", actor: "PRIVATE_EDITOR", via: "PRIVATE_INTERFACE" });
86
+ const run = { run_id: "visibility", source_fingerprint: "source", tip: "tip", options_digest: "options" };
87
+ beginImportRun(store.db, run);
88
+ applyImportedNote(store.db, { run, noteId: n.id, targetDigest: importTargetDigest(store.db, n.id),
89
+ policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(),
90
+ observations: [{ content: "archive", path: "old/path", metadata: {}, extension: "md", created_at: null,
91
+ observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }] });
92
+ const before = await store.listNoteVersions(n.id);
93
+ const full = (await call(n.id, "/versions")).body;
94
+ expect(full.versions[0]).toMatchObject({ actor: "PRIVATE_EDITOR", via: "PRIVATE_INTERFACE" });
95
+ for (const scoped of [false, true]) {
96
+ const rest = (await call(n.id, "/versions", "GET", undefined, scoped)).body;
97
+ const mcp = await query(scoped ? ["journal"] : null)({ versions: { note_id: n.id } });
98
+ expect(mcp).toEqual(rest);
99
+ expect(rest.total).toBe(2);
100
+ expect(rest.versions[0].version_ix).toBe(0);
101
+ expect(rest.versions[1]).toMatchObject({ origin: "git-import", import_ix: 0 });
102
+ for (const row of rest.versions) {
103
+ expect(Object.hasOwn(row, "actor")).toBe(!scoped);
104
+ expect(Object.hasOwn(row, "via")).toBe(!scoped);
105
+ }
106
+ for (const [path, selector, content] of [
107
+ ["/versions/0", { version_ix: 0 }, "original"],
108
+ ["/imports/0", { origin: "git-import", import_ix: 0 }, "archive"],
109
+ ] as const) {
110
+ const one = await call(n.id, path, "GET", undefined, scoped);
111
+ expect(one.status).toBe(200);
112
+ expect(one.body.content).toBe(content);
113
+ expect(await query(scoped ? ["journal"] : null)({ versions: { note_id: n.id, ...selector } })).toEqual(one.body);
114
+ expect(Object.hasOwn(one.body, "actor")).toBe(!scoped);
115
+ expect(Object.hasOwn(one.body, "via")).toBe(!scoped);
116
+ }
117
+ }
118
+ expect(await store.listNoteVersions(n.id)).toEqual(before);
119
+ expect((await store.getNote(n.id))!.content).toBe("current");
120
+ // Empty tag lists mean unrestricted in the existing authorization contract.
121
+ expect(await query([])({ versions: { note_id: n.id } })).toEqual(full);
122
+ const denied = await query(["unrelated"])({ versions: { note_id: n.id } });
123
+ expect(denied).toMatchObject({ error_type: "not_found" });
124
+ });
81
125
  test("P5 REST restore rejects stale optimistic tokens without capture", async () => {
82
126
  const n = await fixture();
83
127
  const before = await store.listNoteVersions(n.id);
128
+ for (const extra of [{}, { force: true }]) {
129
+ const missing = await call(n.id, "/restore", "POST", { version_ix: 0, ...extra });
130
+ expect(missing.status).toBe(428);
131
+ expect(missing.body.error_type).toBe("precondition_required");
132
+ }
84
133
  const r = await call(n.id, "/restore", "POST", {
85
134
  version_ix: 0,
86
135
  if_updated_at: "1999-01-01T00:00:00.000Z",
@@ -393,7 +442,7 @@ test("P24 migration bypass restores, captures once, and emits the bypass log", a
393
442
  const n = await strictFixture();
394
443
  const log = spyOn(console, "warn").mockImplementation(() => {});
395
444
  try {
396
- const r = await call(n.id, "/restore", "POST", { version_ix: 0 }, false, {
445
+ const r = await call(n.id, "/restore", "POST", { version_ix: 0, if_updated_at: (await store.getNote(n.id))!.updatedAt }, false, {
397
446
  actor: "migrator",
398
447
  via: "api",
399
448
  bypassStrict: true,