@openparachute/vault 0.7.9-rc.7 → 0.7.9-rc.9
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/compact-state-rebuild.test.ts +46 -0
- package/core/src/compact-state.test.ts +129 -0
- package/core/src/compact.test.ts +3 -3
- package/core/src/doctor.ts +74 -2
- package/core/src/history-audit.test.ts +51 -0
- package/core/src/history-capture.test.ts +9 -1
- package/core/src/history-compact-state.ts +52 -0
- package/core/src/history-import.ts +3 -1
- package/core/src/history-visibility.test.ts +11 -0
- package/core/src/history-visibility.ts +9 -0
- package/core/src/history.ts +46 -19
- package/core/src/mcp-manifest.ts +6 -1
- package/core/src/mcp.ts +6 -2
- package/core/src/note-id-shape.test.ts +9 -0
- package/core/src/notes.ts +3 -0
- package/core/src/schema-v29-note-versions.test.ts +1 -1
- package/core/src/schema-v30-note-deltas.test.ts +1 -1
- package/core/src/schema-v31-history-import.test.ts +1 -1
- package/core/src/schema.ts +12 -1
- package/core/src/store.ts +18 -2
- package/core/src/ulid.ts +7 -0
- package/package.json +1 -1
- package/src/cli.ts +29 -0
- package/src/history-import-routes.test.ts +23 -1
- package/src/history-import.test.ts +121 -2
- package/src/history-import.ts +10 -7
- package/src/history-rebuild-cli.test.ts +38 -0
- package/src/mcp-tools.ts +9 -2
- package/src/routes.ts +19 -7
- package/src/tag-integrity-mcp.test.ts +18 -0
- package/src/vault-compact-routes.test.ts +1 -1
- package/src/vault-history-routes.test.ts +65 -1
package/src/history-import.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
|
+
import { isNoteIdShape } from "../core/src/ulid.ts";
|
|
8
9
|
import { applyImportedNote, beginImportRun, canonicalJson, importObservationDigest, importTargetDigest, readImportReceipt, type ImportedObservation, type ImportRun } from "../core/src/history-import.ts";
|
|
9
10
|
import { hashContent, resolveHistoryPolicy, VERSION_MAX_BYTES, type HistoryPolicy } from "../core/src/history.ts";
|
|
10
11
|
import { applyConnectionPragmas, initSchema } from "../core/src/schema.ts";
|
|
@@ -16,7 +17,6 @@ const MAX_OUTPUT = 32 * 1024 * 1024;
|
|
|
16
17
|
const MAX_COMMITS = 100_000;
|
|
17
18
|
const MAX_OBJECTS = 10_000_000;
|
|
18
19
|
const MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
19
|
-
const ID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
20
20
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
21
21
|
class GitArchiveError extends Error {}
|
|
22
22
|
function git(repo: string, args: string[]): Buffer {
|
|
@@ -47,7 +47,7 @@ function inline(raw: string): { meta: Record<string, unknown>; content: string }
|
|
|
47
47
|
return { meta: yaml(match[1]!), content: raw.slice(match[0].length) };
|
|
48
48
|
}
|
|
49
49
|
function observation(meta: Record<string, unknown>, content: string, commit: string, blob: string, time: string): { id: string; row: ImportedObservation } {
|
|
50
|
-
if (
|
|
50
|
+
if (!isNoteIdShape(meta.id)) throw new Error("invalid_id");
|
|
51
51
|
if (Buffer.byteLength(content) > VERSION_MAX_BYTES) throw new Error("oversized_body");
|
|
52
52
|
const path = meta.path ?? null, extension = meta.extension ?? "md", created = meta.created_at ?? null;
|
|
53
53
|
if (path !== null && (typeof path !== "string" || path.includes("\0") || path.split("/").includes(".."))) throw new Error("invalid_path");
|
|
@@ -88,7 +88,7 @@ export function normalizeHistorySelections(input: unknown): HistorySelections {
|
|
|
88
88
|
const seen = new Set<string>();
|
|
89
89
|
const selections = value.selections.map(raw => {
|
|
90
90
|
const entry = selectionObject(raw, ["note_id", "commit", "selected", "rejected", "reason"]);
|
|
91
|
-
if (
|
|
91
|
+
if (!isNoteIdShape(entry.note_id) || typeof entry.commit !== "string" || !OBJECT_ID.test(entry.commit) || !Array.isArray(entry.rejected) || !entry.rejected.length) throw new Error("Invalid selection entry");
|
|
92
92
|
const key = `${entry.note_id}:${entry.commit}`;
|
|
93
93
|
if (seen.has(key)) throw new Error("Duplicate selection note/commit");
|
|
94
94
|
seen.add(key);
|
|
@@ -182,10 +182,13 @@ 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;
|
|
188
|
-
if (
|
|
191
|
+
if (isNoteIdShape(id)) stage.prepare("INSERT OR REPLACE INTO quarantine VALUES(?,?)").run(id, reason);
|
|
189
192
|
else stage.prepare("INSERT INTO issues VALUES(?,?,?)").run(commit, entry.path, reason);
|
|
190
193
|
};
|
|
191
194
|
const format = files.get(".parachute/vault.yaml");
|
|
@@ -195,7 +198,7 @@ export function stageArchive(repo: string, tip: string, stage: Database, inputSe
|
|
|
195
198
|
try {
|
|
196
199
|
meta = yaml(read(entry));
|
|
197
200
|
const extension = meta.extension;
|
|
198
|
-
if (
|
|
201
|
+
if (!isNoteIdShape(meta.id) || typeof extension !== "string") throw new Error("invalid_sidecar");
|
|
199
202
|
const contentPath = `${meta.path ?? `_unpathed/${meta.id}`}.${extension}`;
|
|
200
203
|
const body = files.get(contentPath);
|
|
201
204
|
if (!body) throw new Error("missing_sidecar_body");
|
|
@@ -318,7 +321,7 @@ function project(vault: string, repo: string, tip: string, dir: string, opts: {
|
|
|
318
321
|
const selections = opts.selections ?? null;
|
|
319
322
|
const source = stageArchive(repo, tip, stage, selections), policy = resolveHistoryPolicy(config.history);
|
|
320
323
|
const waivers = opts.waivers ?? {};
|
|
321
|
-
for (const [id, reason] of Object.entries(waivers)) if (!
|
|
324
|
+
for (const [id, reason] of Object.entries(waivers)) if (!isNoteIdShape(id) || typeof reason !== "string" || !reason.trim()) throw new Error("Waivers require note IDs and reasons");
|
|
322
325
|
const options_digest = optionsDigest(policy, waivers, selections);
|
|
323
326
|
const run = { run_id: "projection", source_fingerprint: source.source_fingerprint, tip, options_digest };
|
|
324
327
|
beginImportRun(db, run);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { writeVaultConfig, vaultDbPath } from "./config.ts";
|
|
7
|
+
import { BunSqliteStore } from "../core/src/store.ts";
|
|
8
|
+
import { runDoctorScan } from "../core/src/doctor.ts";
|
|
9
|
+
|
|
10
|
+
test("history rebuild-state repairs hints only, without requiring a Git mirror", async () => {
|
|
11
|
+
const taskDir = mkdtempSync(join(tmpdir(), "history-rebuild-"));
|
|
12
|
+
const saved = process.env.PARACHUTE_HOME;
|
|
13
|
+
process.env.PARACHUTE_HOME = taskDir;
|
|
14
|
+
let db: Database | undefined;
|
|
15
|
+
try {
|
|
16
|
+
writeVaultConfig({ name: "repair", api_keys: [], created_at: new Date().toISOString() });
|
|
17
|
+
db = new Database(vaultDbPath("repair"), { create: true });
|
|
18
|
+
const store = new BunSqliteStore(db);
|
|
19
|
+
const n = await store.createNote("before");
|
|
20
|
+
await store.updateNote(n.id, { content: "after" });
|
|
21
|
+
db.exec("UPDATE history_compact_state SET stored=999,refused=1");
|
|
22
|
+
expect(runDoctorScan(db).findings.some(f=>f.type==="history_compact_state_drift")).toBe(true);
|
|
23
|
+
const notes = db.query("SELECT * FROM notes").all(), versions = db.query("SELECT * FROM note_versions").all(), blobs = db.query("SELECT * FROM note_blobs").all();
|
|
24
|
+
db.close(); db=undefined;
|
|
25
|
+
const proc = Bun.spawnSync({ cmd: [process.execPath, join(import.meta.dir,"cli.ts"),"history","rebuild-state","--vault","repair","--json"], env: {...process.env, PARACHUTE_HOME: taskDir}, stdout:"pipe",stderr:"pipe" });
|
|
26
|
+
expect(proc.exitCode, new TextDecoder().decode(proc.stderr)).toBe(0);
|
|
27
|
+
expect(JSON.parse(new TextDecoder().decode(proc.stdout))).toEqual({ vault: "repair", rebuilt_notes: 1 });
|
|
28
|
+
db = new Database(vaultDbPath("repair"));
|
|
29
|
+
expect(runDoctorScan(db).findings.some(f=>f.type==="history_compact_state_drift")).toBe(false);
|
|
30
|
+
expect(db.query("SELECT * FROM notes").all()).toEqual(notes);
|
|
31
|
+
expect(db.query("SELECT * FROM note_versions").all()).toEqual(versions);
|
|
32
|
+
expect(db.query("SELECT * FROM note_blobs").all()).toEqual(blobs);
|
|
33
|
+
} finally {
|
|
34
|
+
db?.close();
|
|
35
|
+
if(saved===undefined)delete process.env.PARACHUTE_HOME;else process.env.PARACHUTE_HOME=saved;
|
|
36
|
+
rmSync(taskDir,{recursive:true,force:true});
|
|
37
|
+
}
|
|
38
|
+
});
|
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
|
-
|
|
583
|
-
|
|
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)
|
|
@@ -905,6 +911,7 @@ function applyTagScopeWrappers(
|
|
|
905
911
|
wrapReadTool(tools, "doctor", async (orig, params) => {
|
|
906
912
|
const allowed = await getAllowed();
|
|
907
913
|
if (!allowed) return await orig(params);
|
|
914
|
+
if (params.deep) return forbidden("deep history audit requires an unrestricted session");
|
|
908
915
|
return runDoctorScan(store.db, { allowedTags: allowed });
|
|
909
916
|
});
|
|
910
917
|
}
|
package/src/routes.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { getImportedVersion, importStorageIndex, parseHistorySelector, projectHi
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { HistoryNotFoundError, latestTombstone } from "../core/src/history.js";
|
|
16
|
-
import {
|
|
16
|
+
import { projectHistoryProvenance } from "../core/src/history-visibility.js";
|
|
17
|
+
import { isNoteIdShape } 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";
|
|
19
20
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.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,
|
|
@@ -2930,7 +2932,7 @@ async function handleNotesInner(
|
|
|
2930
2932
|
if (note && !noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
2931
2933
|
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2932
2934
|
}
|
|
2933
|
-
if (!note && (tagScope.raw !== null || !
|
|
2935
|
+
if (!note && (tagScope.raw !== null || !isNoteIdShape(idOrPath) ||
|
|
2934
2936
|
!(await store.listNoteVersions(idOrPath, { limit: 1 })).length)) {
|
|
2935
2937
|
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2936
2938
|
}
|
|
@@ -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;
|
|
@@ -4451,11 +4453,21 @@ export function handleUnresolvedWikilinks(
|
|
|
4451
4453
|
* catalog.
|
|
4452
4454
|
*/
|
|
4453
4455
|
export async function handleDoctor(
|
|
4454
|
-
|
|
4456
|
+
req: Request,
|
|
4455
4457
|
store: Store,
|
|
4456
4458
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
4457
4459
|
): Promise<Response> {
|
|
4458
|
-
const
|
|
4460
|
+
const q = new URL(req.url).searchParams;
|
|
4461
|
+
if (q.has("deep") && !["true", "false"].includes(q.get("deep")!)) return json({ error: "deep must be true or false" }, 400);
|
|
4462
|
+
const deep = q.get("deep") === "true";
|
|
4463
|
+
if (deep && tagScope.allowed !== null) return json({ error: "deep history audit requires an unrestricted session" }, 403);
|
|
4464
|
+
const after = q.get("history_after") ?? undefined;
|
|
4465
|
+
const maxBlobs = q.has("history_max_blobs") ? Number(q.get("history_max_blobs")) : undefined;
|
|
4466
|
+
const budgetMs = q.has("history_budget_ms") ? Number(q.get("history_budget_ms")) : undefined;
|
|
4467
|
+
if ((after !== undefined && !/^[a-f0-9]{64}$/.test(after)) ||
|
|
4468
|
+
(maxBlobs !== undefined && (!Number.isInteger(maxBlobs) || maxBlobs < 1 || maxBlobs > 500)) ||
|
|
4469
|
+
(budgetMs !== undefined && (!Number.isInteger(budgetMs) || budgetMs < 1 || budgetMs > 1000))) return json({ error: "invalid history audit bounds or cursor" }, 400);
|
|
4470
|
+
const report = await store.doctor({ allowedTags: tagScope.allowed, deep, history_after: after, history_max_blobs: maxBlobs, history_budget_ms: budgetMs });
|
|
4459
4471
|
return json(report);
|
|
4460
4472
|
}
|
|
4461
4473
|
|
|
@@ -65,6 +65,24 @@ async function toolsFor(vaultName: string, scopedTags: string[] | null, verb: "r
|
|
|
65
65
|
return generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags, verb));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
test("deep doctor REST/MCP parity and scoped refusal", async () => {
|
|
69
|
+
seedVault("journal");
|
|
70
|
+
const store = getVaultStore("journal");
|
|
71
|
+
await store.upsertTagRecord("mine", {});
|
|
72
|
+
const doctor = (await toolsFor("journal", null, "read")).find(t => t.name === "doctor")!;
|
|
73
|
+
const mcp: any = await doctor.execute({ deep: true, history_max_blobs: 1 });
|
|
74
|
+
const rest = await handleDoctor(new Request("http://localhost/api/doctor?deep=true&history_max_blobs=1"), store);
|
|
75
|
+
expect(rest.status).toBe(200);
|
|
76
|
+
expect((await rest.json() as any).history_audit).toEqual(mcp.history_audit);
|
|
77
|
+
expect(mcp.history_audit.complete).toBe(true);
|
|
78
|
+
const scoped = (await toolsFor("journal", ["mine"], "admin")).find(t => t.name === "doctor")!;
|
|
79
|
+
expect((await scoped.execute({ deep: true }) as any).error_type).toBe("tag_scope_violation");
|
|
80
|
+
expect((await handleDoctor(new Request("http://localhost/api/doctor?deep=true"), store, { allowed: new Set(["mine"]), raw: ["mine"] })).status).toBe(403);
|
|
81
|
+
for (const q of ["deep=1", "deep=true&history_after=oops", "deep=true&history_max_blobs=501", "deep=true&history_budget_ms=0"]) {
|
|
82
|
+
expect((await handleDoctor(new Request("http://localhost/api/doctor?" + q), store)).status).toBe(400);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
68
86
|
describe("rename-tag / merge-tags — tag-scope gating (vault#552)", () => {
|
|
69
87
|
test("rename-tag: old_name or new_name outside the allowlist is refused before reaching core", async () => {
|
|
70
88
|
seedVault("journal");
|
|
@@ -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",
|
|
@@ -320,6 +369,21 @@ test("P19 overflow deletion remains possible but its tombstone cannot restore",
|
|
|
320
369
|
expect(r.status).toBe(409);
|
|
321
370
|
expect(r.body.error_type).toBe("history_unrecoverable");
|
|
322
371
|
});
|
|
372
|
+
test("legacy deleted IDs resolve exact history and restore without exposing scoped tombstones", async () => {
|
|
373
|
+
for (const id of ["2020-01-02-03-04-05", "legacy:abc123", "shortid"]) {
|
|
374
|
+
await store.createNote("original", { id, path: `legacy/${id}` });
|
|
375
|
+
await store.updateNote(id, { content: "current" });
|
|
376
|
+
await store.deleteNote(id);
|
|
377
|
+
expect((await call(id, "/versions", "GET", undefined, true)).status).toBe(404);
|
|
378
|
+
expect((await call(id, "/restore", "POST", { version_ix: 0 }, true)).status).toBe(404);
|
|
379
|
+
expect((await call(id, "/versions")).status).toBe(200);
|
|
380
|
+
const restored = await call(id, "/restore", "POST", { version_ix: 0 });
|
|
381
|
+
expect(restored.status).toBe(200);
|
|
382
|
+
expect(restored.body).toMatchObject({ id, content: "original", recreated: true });
|
|
383
|
+
}
|
|
384
|
+
expect((await call("not-a-tombstone", "/versions")).status).toBe(404);
|
|
385
|
+
expect((await call("not-a-tombstone", "/restore", "POST", { version_ix: 0 })).status).toBe(404);
|
|
386
|
+
});
|
|
323
387
|
test("P20 REST recreates untagged at the tombstone path and guards conflicts", async () => {
|
|
324
388
|
const n = await fixture();
|
|
325
389
|
await store.deleteNote(n.id);
|
|
@@ -393,7 +457,7 @@ test("P24 migration bypass restores, captures once, and emits the bypass log", a
|
|
|
393
457
|
const n = await strictFixture();
|
|
394
458
|
const log = spyOn(console, "warn").mockImplementation(() => {});
|
|
395
459
|
try {
|
|
396
|
-
const r = await call(n.id, "/restore", "POST", { version_ix: 0 }, false, {
|
|
460
|
+
const r = await call(n.id, "/restore", "POST", { version_ix: 0, if_updated_at: (await store.getNote(n.id))!.updatedAt }, false, {
|
|
397
461
|
actor: "migrator",
|
|
398
462
|
via: "api",
|
|
399
463
|
bypassStrict: true,
|