@openparachute/vault 0.7.9-rc.5 → 0.7.9-rc.7

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.
@@ -1,247 +1,75 @@
1
- /**
2
- * Auto-transcribe trigger decision (vault#353, design 2026-05-21 Part 2).
3
- *
4
- * One pure function: given an attachment's mime-type + the operator's
5
- * settings + whether scribe is reachable, decide whether to enqueue the
6
- * attachment for the transcription worker. Lives in its own module so the
7
- * attachment-write code path (`routes.ts`) and the retry endpoint share the
8
- * same gate without duplicating logic.
9
- */
10
-
1
+ /** Automatic uploads follow the selected, wired transcription worker (#751). */
11
2
  import { readGlobalConfig } from "./config.ts";
12
- import { getCachedScribeUrl } from "./scribe-discovery.ts";
13
3
  import { buildTranscriptionSnapshot } from "./transcription-routes.ts";
4
+ import { getTranscriptionWorkerProvider } from "./transcription-registry.ts";
14
5
 
15
- /**
16
- * The name of a local provider that is INSTALLED on this box, or null.
17
- *
18
- * Delegates to the same snapshot `transcription status` and the admin SPA
19
- * answer from, so these surfaces share one implementation and cannot drift.
20
- *
21
- * Keyed on `binary.path`, deliberately, NOT on `snap.ready`. Readiness also
22
- * demands ffmpeg, so it varies box to box in a way this message shouldn't: a
23
- * machine with the provider installed but ffmpeg missing is still a machine
24
- * where "no transcription provider configured — set TRANSCRIPTION_PROVIDER to
25
- * a local provider" is false. `installed` answers the question the sentence
26
- * actually asks, and answers it the same way everywhere.
27
- * (Caught by running the real probe rather than the injected one — the unit
28
- * tests inject `localProviderImpl` and would never have shown the difference.)
29
- *
30
- * Errors swallow to null: a broken probe must degrade to the old, flat message,
31
- * never take down an attachment upload.
32
- */
33
- function installedLocalProvider(): string | null {
6
+ type ProviderState = { ready: boolean; localProvider: string | null };
7
+ function providerState(): ProviderState {
34
8
  try {
35
- const snap = buildTranscriptionSnapshot();
36
- if (snap.provider === "scribe-http") return null;
37
- return snap.binary.path ? snap.provider : null;
9
+ const snapshot = buildTranscriptionSnapshot();
10
+ return {
11
+ ready: snapshot.active && snapshot.ready &&
12
+ getTranscriptionWorkerProvider() === snapshot.provider,
13
+ // The snapshot's binary field describes whisper.cpp tools, not every
14
+ // provider. Do not use it to claim a Python/transcribe-cpp install exists.
15
+ localProvider: snapshot.provider === "whisper-cpp" && snapshot.binary.path
16
+ ? snapshot.provider : null,
17
+ };
38
18
  } catch {
39
- return null;
19
+ // A failed readiness probe must not break attachment upload.
20
+ return { ready: false, localProvider: null };
40
21
  }
41
22
  }
42
-
43
- /**
44
- * Pre-vault#353 callers passed `transcribe: true` explicitly on the
45
- * attachment POST. The auto-transcribe path inlines the decision: if the
46
- * upload is an audio mime-type AND the toggle is on AND scribe is reachable,
47
- * the worker is enqueued. This function is the single decision site.
48
- *
49
- * Returns `true` only when ALL three conditions hold:
50
- * 1. mime-type starts with `audio/` (case-insensitive).
51
- * 2. The resolved auto-transcribe toggle is not `false`. Resolution is
52
- * **per-vault → global → true**:
53
- * - `perVaultEnabled` (the owning vault's own `auto_transcribe.enabled`)
54
- * wins when set — this is what makes scribe's "link to vault X" affect
55
- * only X, not the whole server.
56
- * - else the server-wide `globalConfig.auto_transcribe?.enabled`.
57
- * - else `true` (default ON — once scribe is reachable, audio
58
- * transcribes without a separate config step). Operators who want it
59
- * OFF set `auto_transcribe.enabled: false` explicitly (per-vault or
60
- * globally).
61
- * `enabledOverride`, when present, hard-overrides the whole chain (used
62
- * by the explicit caller-opt-in path).
63
- * 3. Scribe is discoverable (services.json entry OR SCRIBE_URL env).
64
- *
65
- * The three conditions are independent guards: a single `false` is sufficient
66
- * to skip enqueuing. The audio stays as a regular attachment in that case.
67
- */
68
- export function shouldAutoTranscribe(
69
- mimeType: string,
70
- opts: {
71
- /** Injection seam for tests — defaults to live globals. */
72
- readGlobalConfigImpl?: typeof readGlobalConfig;
73
- getCachedScribeUrlImpl?: () => string | undefined;
74
- /**
75
- * The owning vault's per-vault `auto_transcribe.enabled` (vault.yaml).
76
- * Takes precedence over the global toggle when set, so enabling/disabling
77
- * one vault doesn't move the rest. `undefined` (the vault left it unset)
78
- * falls through to the global toggle.
79
- */
80
- perVaultEnabled?: boolean;
81
- /**
82
- * Hard override of the entire per-vault→global→true chain. Used by the
83
- * explicit caller-opt-in path; not part of the normal precedence ladder.
84
- */
85
- enabledOverride?: boolean;
86
- } = {},
87
- ): boolean {
88
- if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
89
- return false;
90
- }
91
- return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
92
- }
93
-
94
- /**
95
- * Why an audio attachment is (or isn't) being transcribed. The three outcomes
96
- * are NOT interchangeable, which is the whole point of this type:
97
- *
98
- * - `transcribe` — enqueue it.
99
- * - `disabled` — the operator turned auto-transcribe off. Silence is
100
- * correct: they asked for nothing to happen.
101
- * - `unavailable` — auto-transcribe is ON, but no provider is reachable.
102
- * This is a MISCONFIGURATION, and silence is wrong.
103
- *
104
- * vault#643: `shouldAutoTranscribe` collapsed the last two into `false`, so a
105
- * box with transcription enabled and no reachable provider accepted audio,
106
- * transcribed nothing, wrote no marker, logged nothing, and left an attachment
107
- * indistinguishable from a plain upload. Observed on a fresh install: the
108
- * provider resolves to `scribe-http` by default, nothing sets `SCRIBE_URL`, and
109
- * voice memos silently never transcribe.
110
- *
111
- * The hosted door already gets this right — `workers/vault/src/vault-do.ts`
112
- * marks a terminal state ("voice not enabled for this plan", "monthly voice
113
- * limit reached") rather than skipping quietly, precisely so the operator never
114
- * faces an eternal spinner. This brings self-host to the same posture.
115
- */
23
+ type AutoTranscribeOptions = {
24
+ readGlobalConfigImpl?: typeof readGlobalConfig;
25
+ /** Test seam; production uses the registered worker and shared status probe. */
26
+ providerStateImpl?: () => ProviderState;
27
+ /** Per-vault → global → true; explicit caller overrides still win. */
28
+ perVaultEnabled?: boolean;
29
+ enabledOverride?: boolean;
30
+ };
116
31
  export type AutoTranscribeDecision =
117
32
  | { kind: "transcribe" }
118
33
  | { kind: "not-audio" }
119
34
  | { kind: "disabled" }
120
- /**
121
- * `localProvider` names a local provider INSTALLED on this box (its binary
122
- * resolves), or null when there is nothing local here.
123
- *
124
- * Installation, deliberately, and not the configured provider NAME. The name
125
- * is a trap: `resolveTranscriptionProviderName` returns `whisper-cpp` on a
126
- * box where nothing at all is set up, because that is the default when no
127
- * scribe URL resolves. Keying the message off the name would tell a
128
- * fresh-install operator their whisper-cpp install is present when they have
129
- * never installed one — trading one false sentence for another.
130
- *
131
- * It does NOT change the decision: `unavailable` is `unavailable` either way,
132
- * because this path resolves a scribe URL and nothing else. It exists purely
133
- * so the error can be true. See `noProviderErrorFor`.
134
- */
135
35
  | { kind: "unavailable"; localProvider: string | null };
136
36
 
137
- /** The full decision behind `shouldAutoTranscribe`. Same inputs, more answer. */
138
37
  export function classifyAutoTranscribe(
139
38
  mimeType: string,
140
- opts: {
141
- readGlobalConfigImpl?: typeof readGlobalConfig;
142
- getCachedScribeUrlImpl?: () => string | undefined;
143
- perVaultEnabled?: boolean;
144
- enabledOverride?: boolean;
145
- /**
146
- * Injectable, same shape as the two impls above. Returns the name of a
147
- * local provider installed here, or null. Defaults to the shared
148
- * transcription snapshot.
149
- */
150
- localProviderImpl?: () => string | null;
151
- } = {},
39
+ opts: AutoTranscribeOptions = {},
152
40
  ): AutoTranscribeDecision {
153
41
  if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
154
42
  return { kind: "not-audio" };
155
43
  }
156
- const enabled = opts.enabledOverride
157
- ?? opts.perVaultEnabled
158
- ?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
159
- ?? true;
44
+ const enabled = opts.enabledOverride ?? opts.perVaultEnabled ??
45
+ (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled ?? true;
160
46
  if (!enabled) return { kind: "disabled" };
161
- const url = (opts.getCachedScribeUrlImpl ?? getCachedScribeUrl)();
162
- if (!url || !url.trim()) {
163
- // Still unavailable — this path reads a scribe URL and nothing else, and
164
- // that is deliberately unchanged here. We only ask whether something local
165
- // would run, so the message can name the real situation instead of telling
166
- // an operator to do what they have already done.
167
- return {
168
- kind: "unavailable",
169
- localProvider: (opts.localProviderImpl ?? installedLocalProvider)(),
170
- };
171
- }
172
- return { kind: "transcribe" };
47
+ const state = (opts.providerStateImpl ?? providerState)();
48
+ return state.ready
49
+ ? { kind: "transcribe" }
50
+ : { kind: "unavailable", localProvider: state.localProvider };
51
+ }
52
+ export function shouldAutoTranscribe(mimeType: string, opts: AutoTranscribeOptions = {}): boolean {
53
+ return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
173
54
  }
174
-
175
- /**
176
- * The `transcribe_error` written when auto-transcribe is on but no provider is
177
- * reachable AND nothing local is configured. Deliberately actionable — the
178
- * operator needs to know which of the two things to do, not just that something
179
- * went wrong.
180
- *
181
- * Correct only when the resolved provider is `scribe-http`. On a box that HAS a
182
- * local provider this sentence is false in the most expensive way — see
183
- * `noProviderErrorFor`.
184
- */
185
55
  export const NO_PROVIDER_ERROR =
186
- "no transcription provider configured — set TRANSCRIPTION_PROVIDER to a local " +
187
- "provider (see `parachute-vault transcription install`), or point SCRIBE_URL at " +
188
- "a transcription service";
189
-
190
- /**
191
- * The `transcribe_error` for an `unavailable` decision, told truthfully.
192
- *
193
- * Found live: a box with `TRANSCRIPTION_PROVIDER=whisper-cpp`, `parakeet-cli` on
194
- * PATH, the model downloaded, and `[transcribe] worker started → whisper-cpp` in
195
- * the same boot was writing `NO_PROVIDER_ERROR` onto its attachments — telling
196
- * the operator to set the variable they had set and install the provider that
197
- * was already running. Same failure mode as the `transcription status` bug
198
- * (#643): a diagnostic that lies during the exact task it exists for, and this
199
- * one sends someone to re-download a 400 MB model to fix nothing.
200
- *
201
- * The asymmetry is real and worth naming in the message rather than hiding:
202
- * `server.ts` starts a local worker with no scribe URL anywhere in the branch,
203
- * but this path resolves ONLY a scribe URL. So a local provider genuinely does
204
- * serve explicit `transcribe: true` requests while genuinely being invisible
205
- * here. An operator who is not told that will reasonably conclude their install
206
- * is broken.
207
- *
208
- * Deliberately NOT a fix for the asymmetry itself — teaching this path to see
209
- * local providers changes what gets transcribed, which is a behaviour question
210
- * and not this diff's to answer.
211
- */
56
+ "no transcription provider configured or ready — check `parachute-vault transcription status` " +
57
+ "and TRANSCRIPTION_PROVIDER. For local transcription use `parachute-vault transcription install`. " +
58
+ "For an explicitly selected legacy remote provider, check its SCRIBE_URL and worker status. " +
59
+ "The standalone Scribe service is retired.";
212
60
  export function noProviderErrorFor(localProvider: string | null): string {
213
61
  if (!localProvider) return NO_PROVIDER_ERROR;
214
- return (
215
- `auto-transcribe found no reachable provider: it resolves only a scribe URL ` +
216
- `(SCRIBE_URL, or a \`parachute-scribe\` entry in services.json), and neither is set. ` +
217
- `A local ${localProvider} install IS present here and explicit transcription requests ` +
218
- `are routed to it — this path does not consult it. Reinstalling ${localProvider} will ` +
219
- `not change this. To auto-transcribe uploads, point SCRIBE_URL at a transcription ` +
220
- `service; otherwise request transcription explicitly on the attachment. ` +
221
- `\`parachute-vault transcription status\` reports the local install's own state.`
222
- );
62
+ return `auto-transcribe is unavailable: a local ${localProvider} binary is installed, ` +
63
+ "but the selected worker is not active and ready. Check `parachute-vault transcription status` " +
64
+ "for missing model/runtime dependencies or a required server restart. " +
65
+ "A binary alone does not establish readiness; reinstalling it is not necessarily the fix.";
223
66
  }
224
-
225
- /** Throttle for {@link warnNoTranscriptionProvider}. */
226
67
  const NO_PROVIDER_WARN_INTERVAL_MS = 60_000;
227
68
  let lastNoProviderWarnAt = 0;
228
-
229
- /**
230
- * Warn (at most once a minute) that audio is arriving with nowhere to send it.
231
- *
232
- * Throttled because it fires per-upload: a bulk import of a hundred voice
233
- * memos should produce one actionable line, not a hundred. Silence was the old
234
- * behaviour and it is what made this invisible — a box can accept audio for
235
- * months and never say that transcription isn't wired up.
236
- */
237
69
  export function warnNoTranscriptionProvider(now: () => number = Date.now): void {
238
70
  const t = now();
239
71
  if (t - lastNoProviderWarnAt < NO_PROVIDER_WARN_INTERVAL_MS) return;
240
72
  lastNoProviderWarnAt = t;
241
- console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}.`);
242
- }
243
-
244
- /** Test seam: forget the throttle window. */
245
- export function _resetNoProviderWarnForTest(): void {
246
- lastNoProviderWarnAt = 0;
73
+ console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}`);
247
74
  }
75
+ export function _resetNoProviderWarnForTest(): void { lastNoProviderWarnAt = 0; }
package/src/cli.ts CHANGED
@@ -296,6 +296,11 @@ switch (command) {
296
296
  case "backup":
297
297
  await cmdBackup(cmdArgs);
298
298
  break;
299
+ case "history-import": {
300
+ const { runHistoryImport } = await import("./history-import.ts");
301
+ console.log(JSON.stringify(await runHistoryImport(cmdArgs), null, 2));
302
+ break;
303
+ }
299
304
  case "import":
300
305
  await cmdImport(cmdArgs);
301
306
  break;
@@ -4815,7 +4820,7 @@ async function createVault(
4815
4820
  // git-less box leaves the config written but inactive + logs an actionable
4816
4821
  // hint — it must NEVER fail the vault create. Create-time ONLY: existing
4817
4822
  // vaults are never retroactively migrated.
4818
- if (shouldEnableCreateTimeMirror(opts)) {
4823
+ if (shouldEnableCreateTimeMirror(opts) && (await import("./mirror-config.ts")).readHistoryMirrorPhase(name) === "active") {
4819
4824
  const mirrorConfig = historyPresetMirrorConfig();
4820
4825
  writeMirrorConfigForVault(name, mirrorConfig);
4821
4826
  const mirrorPath = resolveMirrorPath(vaultDir(name), mirrorConfig);
@@ -5213,6 +5218,10 @@ Import/Export:
5213
5218
  --git-push to push after commit)
5214
5219
 
5215
5220
  History:
5221
+ parachute-vault history-import plan|prepare|apply|retire|cancel --vault <name>
5222
+ Import a local git archive into note history;
5223
+ plan/prepare: --selections <file> for audited duplicates;
5224
+ see UPGRADING.md for offline cutover and recovery.
5216
5225
  parachute-vault history [--vault <name>] Show the vault's git write history (the
5217
5226
  mirror is git-backed — one file per note,
5218
5227
  so git log IS a tamper-evident history)
@@ -0,0 +1,94 @@
1
+ import { test, expect } from "bun:test";
2
+ import { handleMcp } from "./mcp-http.ts";
3
+ import type { McpToolDef } from "../core/src/mcp.ts";
4
+
5
+ async function errorEnvelope(fields: Record<string, unknown>) {
6
+ const tool: McpToolDef = {
7
+ name: "query-notes", description: "serializer boundary fixture", requiredVerb: "read",
8
+ inputSchema: { type: "object" },
9
+ execute() { throw Object.assign(new Error("Unrecoverable history"), fields); },
10
+ };
11
+ const request = new Request("http://localhost/vault/import-test/mcp", {
12
+ method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
13
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool.name, arguments: {} } }),
14
+ });
15
+ const response = await handleMcp(request, () => [tool], "import-test", "import-test", {
16
+ permission: "full", scopes: ["vault:import-test:read"], legacyDerived: false,
17
+ scoped_tags: null, vault_name: null, caller_jti: null, actor: "test", via: "api",
18
+ }, "");
19
+ return await response.json() as any;
20
+ }
21
+ test("HTTP MCP forwards valid imported history references only on the dedicated error branch", async () => {
22
+ const fields = { code: "HISTORY_UNRECOVERABLE", error_type: "history_unrecoverable", origin: "git-import", import_ix: 0, version_ix: -1 };
23
+ const rpc = await errorEnvelope(fields);
24
+ expect(rpc.result).toBeUndefined();
25
+ expect(rpc.error.code).toBe(-32602);
26
+ expect(rpc.error.data).toEqual({ error_type: "history_unrecoverable", origin: "git-import", import_ix: 0 });
27
+ for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "0", null]) {
28
+ const bad = await errorEnvelope({ ...fields, import_ix: invalid });
29
+ expect(bad.result).toBeUndefined();
30
+ expect(bad.error.data).toEqual({ error_type: "history_unrecoverable" });
31
+ }
32
+ const generic = await errorEnvelope({ ...fields, code: "OTHER" });
33
+ expect(generic.error.data).toEqual({ error_type: "history_unrecoverable" });
34
+ const native = await errorEnvelope({ code: "HISTORY_UNRECOVERABLE", error_type: "history_unrecoverable", version_ix: 0, note_id: "note" });
35
+ expect(native.error.data).toEqual({ error_type: "history_unrecoverable" });
36
+ });
37
+
38
+ import { mkdtempSync, rmSync } from "node:fs";
39
+ import { tmpdir } from "node:os";
40
+ import { join } from "node:path";
41
+ import { writeVaultConfig } from "./config.ts";
42
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
43
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
44
+ import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
45
+ import { resolveHistoryPolicy } from "../core/src/history.ts";
46
+
47
+ test("HTTP MCP imported get/list/error keeps discriminator and rejects mixed selectors", async () => {
48
+ const saved = process.env.PARACHUTE_HOME;
49
+ const dir = mkdtempSync(join(tmpdir(), "history-import-mcp-"));
50
+ process.env.PARACHUTE_HOME = dir;
51
+ try {
52
+ writeVaultConfig({ name: "import-test", api_keys: [], created_at: new Date().toISOString() });
53
+ const store = getVaultStore("import-test");
54
+ const note = await store.createNote("live");
55
+ const run = { run_id: "test", source_fingerprint: "source", tip: "tip", options_digest: "options" };
56
+ beginImportRun(store.db, run);
57
+ applyImportedNote(store.db, {
58
+ run, noteId: note.id, policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(),
59
+ targetDigest: importTargetDigest(store.db, note.id),
60
+ observations: [{ content: "archive", path: null, metadata: {}, extension: "md", created_at: null, observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }],
61
+ });
62
+ const auth = { permission: "full" as const, scopes: ["vault:import-test:read"], legacyDerived: false, scoped_tags: null, vault_name: null, caller_jti: null, actor: "test", via: "api" };
63
+ async function call(selector: Record<string, unknown>) {
64
+ const req = new Request("http://localhost/vault/import-test/mcp", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "query-notes", arguments: { versions: { note_id: note.id, ...selector } } } }) });
65
+ return await (await handleMcp(req, () => generateScopedMcpTools("import-test", auth), "test", "import-test", auth, "")).json() as any;
66
+ }
67
+ const got = await call({ origin: "git-import", import_ix: 0 });
68
+ const row = JSON.parse(got.result.content[0].text);
69
+ expect(row.content).toBe("archive");
70
+ expect(row.origin).toBe("git-import");
71
+ expect(row.import_ix).toBe(0);
72
+ expect(row.version_ix).toBeUndefined();
73
+ const listed = JSON.parse((await call({})).result.content[0].text);
74
+ expect(listed.versions[0].origin).toBe("git-import");
75
+ for (const selector of [{ origin: "git-import" }, { import_ix: 0 }, { version_ix: 0, origin: "git-import", import_ix: 0 }, { version_ix: -1 }]) {
76
+ const invalid = await call(selector);
77
+ expect(invalid.error).toBeDefined();
78
+ expect(invalid.result).toBeUndefined();
79
+ }
80
+ store.db.exec("UPDATE note_blobs SET encoding='unknown'");
81
+ const broken = await call({ origin: "git-import", import_ix: 0 });
82
+ expect(broken.result).toBeUndefined();
83
+ expect(broken.error.data).toEqual({ error_type: "history_unrecoverable", origin: "git-import", import_ix: 0 });
84
+ expect(broken.error.message).not.toContain("@-1");
85
+ const scoped = generateScopedMcpTools("import-test", { ...auth, scoped_tags: ["journal"] }).find(t => t.name === "query-notes")!;
86
+ const denied = await scoped.execute({ versions: { note_id: note.id, origin: "git-import", import_ix: 0 } });
87
+ expect(denied).toEqual({ error: "Note not found", error_type: "not_found", id: note.id });
88
+
89
+ } finally {
90
+ clearVaultStoreCache();
91
+ if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved;
92
+ rmSync(dir, { recursive: true, force: true });
93
+ }
94
+ });
@@ -0,0 +1,91 @@
1
+ import { test, expect, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { generateKeyPair, exportJWK, SignJWT } from "jose";
6
+ import { handleMcp } from "./mcp-http.ts";
7
+ import { route } from "./routing.ts";
8
+ import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
9
+ import { writeVaultConfig, readVaultConfig, vaultConfigPath } from "./config.ts";
10
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
11
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
12
+ import { BunSqliteStore } from "../core/src/store.ts";
13
+ import * as history from "../core/src/history.ts";
14
+
15
+ let taskDir: string, savedHome: string | undefined, savedOrigin: string | undefined, savedJwks: string | undefined, origin: string;
16
+ let store: ReturnType<typeof getVaultStore>, server: ReturnType<typeof Bun.serve>, privateKey: CryptoKey;
17
+ beforeEach(async () => {
18
+ savedHome = process.env.PARACHUTE_HOME;
19
+ savedOrigin = process.env.PARACHUTE_HUB_ORIGIN;
20
+ savedJwks = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
21
+ taskDir = mkdtempSync(join(tmpdir(), "vault-compact-"));
22
+ process.env.PARACHUTE_HOME = taskDir;
23
+ writeVaultConfig({ name: "compact", api_keys: [], created_at: new Date().toISOString() });
24
+ store = getVaultStore("compact");
25
+ const keys = await generateKeyPair("RS256", { extractable: true });
26
+ privateKey = keys.privateKey;
27
+ const jwk = await exportJWK(keys.publicKey);
28
+ server = Bun.serve({ port: 0, fetch(req) { return new URL(req.url).pathname === "/.well-known/jwks.json" ? Response.json({ keys: [{ ...jwk, kid: "compact-test", alg: "RS256", use: "sig" }] }) : Response.json({ generated_at: new Date().toISOString(), jtis: [] }); } });
29
+ origin = `http://127.0.0.1:${server.port}`;
30
+ process.env.PARACHUTE_HUB_ORIGIN = origin;
31
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = origin;
32
+ resetJwksCache();
33
+ resetRevocationCache();
34
+ });
35
+ afterEach(() => { server.stop(true); clearVaultStoreCache(); rmSync(taskDir, { recursive: true, force: true }); for (const [key, value] of [["PARACHUTE_HOME", savedHome], ["PARACHUTE_HUB_ORIGIN", savedOrigin], ["PARACHUTE_HUB_JWKS_ORIGIN", savedJwks]] as const) {
36
+ if (value === undefined)
37
+ delete process.env[key];
38
+ else
39
+ process.env[key] = value;
40
+ } resetJwksCache(); resetRevocationCache(); });
41
+ async function call(path: string, method = "GET", body?: unknown, verb = "admin", scoped = false) {
42
+ const token = await new SignJWT({ scope: `vault:compact:${verb}`, client_id: "compact-test", ...(scoped ? { permissions: { scoped_tags: ["journal"] } } : {}) }).setProtectedHeader({ alg: "RS256", kid: "compact-test" }).setIssuer(origin).setSubject("tester").setAudience("vault.compact").setIssuedAt().setExpirationTime("5m").setJti(crypto.randomUUID()).sign(privateKey);
43
+ const full = `/vault/compact/api${path}`, response = await route(new Request(`http://localhost${full}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }), new URL(`http://localhost${full}`).pathname);
44
+ return { status: response.status, body: await response.json() as any };
45
+ }
46
+
47
+ import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
48
+ test("imported REST references restore by ID or encoded path and preserve native keys", async () => {
49
+ const note = await store.createNote("native", { path: "folder/imported", tags: ["journal"] });
50
+ await store.updateNote(note.id, { content: "current" });
51
+ const run = { run_id: "rest", source_fingerprint: "source", tip: "tip", options_digest: "options" };
52
+ beginImportRun(store.db, run);
53
+ applyImportedNote(store.db, { run, noteId: note.id, targetDigest: importTargetDigest(store.db, note.id), policy: history.resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(), observations: [{ content: "archive", path: "old/path", metadata: { old: true }, extension: "md", created_at: null, observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }] });
54
+ for (const ref of [note.id, encodeURIComponent("folder/imported")]) {
55
+ const got = await call(`/notes/${ref}/imports/0`);
56
+ expect(got.status).toBe(200);
57
+ expect(got.body.content).toBe("archive");
58
+ expect(got.body.origin).toBe("git-import");
59
+ expect(got.body.import_ix).toBe(0);
60
+ expect(got.body.version_ix).toBeUndefined();
61
+ }
62
+ const native = await call(`/notes/${note.id}/versions/0`);
63
+ expect(native.body.version_ix).toBe(0);
64
+ expect(native.body.origin).toBeUndefined();
65
+ const list = await call(`/notes/${note.id}/versions`);
66
+ expect(list.body.versions.map((v: any) => v.version_ix ?? v.origin)).toEqual([0, "git-import"]);
67
+ 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
+ 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 });
70
+ expect(restored.status).toBe(200);
71
+ expect(restored.body.restored_from).toEqual({ origin: "git-import", import_ix: 0 });
72
+ expect((await store.getNote(note.id))!.content).toBe("archive");
73
+ expect((await store.getNote(note.id))!.path).toBe("folder/imported");
74
+ const missing = await call(`/notes/${note.id}/imports/99`);
75
+ expect(missing.status).toBe(404);
76
+ expect(missing.body).toMatchObject({ origin: "git-import", import_ix: 99 });
77
+ await store.deleteNote(note.id);
78
+ const recreated = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
79
+ expect(recreated.status).toBe(200);
80
+ expect(recreated.body.recreated).toBe(true);
81
+ expect((await store.getNote(note.id))!.content).toBe("archive");
82
+ const hash = store.db.query("SELECT content_hash FROM note_versions WHERE note_id=? AND version_ix=-1").get(note.id) as { content_hash: string };
83
+ store.db.query("UPDATE note_blobs SET encoding='unknown' WHERE hash=?").run(hash.content_hash);
84
+ const broken = await call(`/notes/${note.id}/imports/0`);
85
+ expect(broken.status).toBe(409);
86
+ expect(broken.body).toMatchObject({ origin: "git-import", import_ix: 0, error_type: "history_unrecoverable" });
87
+ expect(JSON.stringify(broken.body)).not.toContain("version_ix");
88
+ expect(JSON.stringify(broken.body)).not.toContain("@-1");
89
+ const privateNote = await store.createNote("private", { tags: ["secret"] });
90
+ expect((await call(`/notes/${privateNote.id}/imports/0`, "GET", undefined, "read", true)).status).toBe(404);
91
+ });