@openparachute/vault 0.3.1 → 0.4.0

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.
Files changed (82) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +9 -5
  3. package/core/src/core.test.ts +2252 -7
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +801 -67
  6. package/core/src/note-schemas.ts +232 -0
  7. package/core/src/notes.ts +313 -35
  8. package/core/src/obsidian.ts +3 -3
  9. package/core/src/paths.ts +1 -1
  10. package/core/src/query-operators.ts +23 -7
  11. package/core/src/schema-defaults.ts +287 -0
  12. package/core/src/schema.ts +393 -9
  13. package/core/src/store.ts +248 -6
  14. package/core/src/tag-hierarchy.ts +137 -0
  15. package/core/src/tag-schemas.ts +242 -42
  16. package/core/src/types.ts +100 -6
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +231 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +144 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +384 -78
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +296 -0
  30. package/src/hub-jwt.ts +79 -0
  31. package/src/init-summary.test.ts +133 -0
  32. package/src/init-summary.ts +90 -0
  33. package/src/init.test.ts +216 -0
  34. package/src/mcp-http.ts +30 -28
  35. package/src/mcp-install.ts +1 -1
  36. package/src/mcp-tools.ts +294 -6
  37. package/src/module-config.ts +1 -1
  38. package/src/oauth.test.ts +345 -0
  39. package/src/oauth.ts +85 -14
  40. package/src/owner-auth.ts +57 -1
  41. package/src/prompt.ts +31 -14
  42. package/src/routes.ts +686 -58
  43. package/src/routing.test.ts +466 -1
  44. package/src/routing.ts +108 -24
  45. package/src/scopes.test.ts +66 -8
  46. package/src/scopes.ts +163 -37
  47. package/src/server.ts +24 -2
  48. package/src/services-manifest.test.ts +20 -0
  49. package/src/services-manifest.ts +9 -2
  50. package/src/stop-signal.test.ts +85 -0
  51. package/src/storage.test.ts +92 -0
  52. package/src/tag-scope.ts +118 -0
  53. package/src/token-store.test.ts +47 -0
  54. package/src/token-store.ts +128 -13
  55. package/src/tokens-routes.test.ts +720 -0
  56. package/src/tokens-routes.ts +392 -0
  57. package/src/transcription-worker.test.ts +5 -0
  58. package/src/triggers.ts +1 -1
  59. package/src/two-factor.ts +2 -2
  60. package/src/vault-create.test.ts +193 -0
  61. package/src/vault-name.test.ts +123 -0
  62. package/src/vault-name.ts +80 -0
  63. package/src/vault.test.ts +868 -3
  64. package/tsconfig.json +8 -1
  65. package/.claude/settings.local.json +0 -8
  66. package/.dockerignore +0 -8
  67. package/.env.example +0 -9
  68. package/CHANGELOG.md +0 -175
  69. package/CLAUDE.md +0 -125
  70. package/Caddyfile +0 -3
  71. package/Dockerfile +0 -22
  72. package/bun.lock +0 -219
  73. package/bunfig.toml +0 -2
  74. package/deploy/parachute-vault.service +0 -20
  75. package/docker-compose.yml +0 -50
  76. package/docs/HTTP_API.md +0 -434
  77. package/docs/auth-model.md +0 -340
  78. package/fly.toml +0 -24
  79. package/package/package.json +0 -32
  80. package/railway.json +0 -14
  81. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  82. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -1,499 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * scripts/migrate-audio-to-opus.ts
4
- *
5
- * NOTE: This script requires `@openparachute/narrate` which is no longer a
6
- * vault dependency (removed in the webhook trigger refactor). If you need to
7
- * re-run this migration, install it manually: `bun add @openparachute/narrate`
8
- *
9
- * One-shot migration: convert existing WAV / MP3 audio attachments into
10
- * OGG Opus in-place. Resolves the second half of issue #43 — the first
11
- * half (new TTS output encoded as Opus) ships in the tts-provider.ts hook
12
- * change; this script rewrites everything already on disk.
13
- *
14
- * What it does
15
- * ------------
16
- * For each vault (all vaults by default, or `--vault <name>` for one):
17
- * 1. Opens the vault SQLite DB.
18
- * 2. Finds every `attachments` row whose mime_type starts with `audio/`
19
- * and whose path ends in .wav or .mp3.
20
- * 3. Runs ffmpeg on the file on disk to produce a sibling .ogg.
21
- * 4. Updates the attachment row's `path` and `mime_type` using raw SQL
22
- * (NOT via store.updateNote — we don't want to touch updated_at on
23
- * the parent note, since this is a pure storage-format migration).
24
- * 5. Unlinks the original WAV/MP3.
25
- *
26
- * Idempotency
27
- * -----------
28
- * If a sibling .ogg already exists AND the DB row already points at it,
29
- * we skip the row entirely. Re-running the script is safe.
30
- *
31
- * Dry run
32
- * -------
33
- * `--dry-run` reports what would change without touching anything (no
34
- * ffmpeg, no DB writes, no unlinks).
35
- *
36
- * Error handling
37
- * --------------
38
- * Per-attachment errors are logged and the script continues with the next
39
- * attachment. The per-vault summary at the end reports converted / skipped
40
- * / errors + total bytes saved.
41
- *
42
- * Not run automatically
43
- * ---------------------
44
- * Aaron invokes this by hand when he's ready:
45
- * bun scripts/migrate-audio-to-opus.ts # all vaults
46
- * bun scripts/migrate-audio-to-opus.ts --dry-run
47
- * bun scripts/migrate-audio-to-opus.ts --vault default
48
- */
49
-
50
- import { Database } from "bun:sqlite";
51
- import { existsSync, statSync, unlinkSync, readdirSync } from "fs";
52
- import { join, dirname } from "path";
53
- import { homedir } from "os";
54
- import { encodeOggOpus } from "@openparachute/narrate";
55
- import { readFileSync, writeFileSync } from "fs";
56
-
57
- // ---------------------------------------------------------------------------
58
- // Config resolution — keep this script self-contained so it can run against
59
- // any machine with a `~/.parachute/` (or $PARACHUTE_HOME) without pulling in
60
- // server startup side effects.
61
- // ---------------------------------------------------------------------------
62
-
63
- // Read PARACHUTE_HOME lazily so tests can override it
64
- // via process.env after the module has loaded.
65
- function configDir(): string {
66
- return process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
67
- }
68
-
69
- function vaultsDir(): string {
70
- return join(configDir(), "vaults");
71
- }
72
-
73
- function vaultDir(name: string): string {
74
- return join(vaultsDir(), name);
75
- }
76
-
77
- function vaultDbPath(name: string): string {
78
- return join(vaultDir(name), "vault.db");
79
- }
80
-
81
- function vaultAssetsDir(name: string): string {
82
- return process.env.ASSETS_DIR ?? join(vaultDir(name), "assets");
83
- }
84
-
85
- function listVaultNames(): string[] {
86
- const root = vaultsDir();
87
- if (!existsSync(root)) return [];
88
- return readdirSync(root, { withFileTypes: true })
89
- .filter((d) => d.isDirectory())
90
- .map((d) => d.name);
91
- }
92
-
93
- // ---------------------------------------------------------------------------
94
- // CLI args
95
- // ---------------------------------------------------------------------------
96
-
97
- interface Args {
98
- vault?: string;
99
- dryRun: boolean;
100
- help: boolean;
101
- }
102
-
103
- function parseArgs(argv: string[]): Args {
104
- const out: Args = { dryRun: false, help: false };
105
- for (let i = 0; i < argv.length; i++) {
106
- const a = argv[i];
107
- if (a === "--dry-run") out.dryRun = true;
108
- else if (a === "--help" || a === "-h") out.help = true;
109
- else if (a === "--vault") {
110
- out.vault = argv[i + 1];
111
- i++;
112
- } else if (a.startsWith("--vault=")) {
113
- out.vault = a.slice("--vault=".length);
114
- }
115
- }
116
- return out;
117
- }
118
-
119
- function printHelp(): void {
120
- console.log(
121
- `Usage: bun scripts/migrate-audio-to-opus.ts [options]
122
-
123
- Options:
124
- --vault <name> Migrate only the named vault (default: all vaults)
125
- --dry-run Report what would change without touching anything
126
- --help, -h Show this help
127
-
128
- Converts existing audio attachments (.wav / .mp3) into OGG Opus in-place.
129
- Idempotent: safe to re-run. Does not bump updated_at on parent notes.
130
- `,
131
- );
132
- }
133
-
134
- // ---------------------------------------------------------------------------
135
- // Attachment row shape
136
- // ---------------------------------------------------------------------------
137
-
138
- interface AttachmentRow {
139
- id: string;
140
- note_id: string;
141
- path: string;
142
- mime_type: string;
143
- metadata: string | null;
144
- created_at: string;
145
- }
146
-
147
- // ---------------------------------------------------------------------------
148
- // Per-vault migration
149
- // ---------------------------------------------------------------------------
150
-
151
- interface VaultSummary {
152
- vault: string;
153
- converted: number;
154
- skipped: number;
155
- errors: number;
156
- bytesBefore: number;
157
- bytesAfter: number;
158
- dryRunCandidates: number;
159
- }
160
-
161
- function shouldMigrate(mime: string, path: string): boolean {
162
- if (!mime.toLowerCase().startsWith("audio/")) return false;
163
- const p = path.toLowerCase();
164
- return p.endsWith(".wav") || p.endsWith(".mp3");
165
- }
166
-
167
- function inputMimeFromPath(path: string, stored: string): string {
168
- const lower = path.toLowerCase();
169
- if (lower.endsWith(".wav")) return "audio/wav";
170
- if (lower.endsWith(".mp3")) return "audio/mpeg";
171
- return stored;
172
- }
173
-
174
- async function migrateVault(
175
- vault: string,
176
- dryRun: boolean,
177
- ): Promise<VaultSummary> {
178
- const summary: VaultSummary = {
179
- vault,
180
- converted: 0,
181
- skipped: 0,
182
- errors: 0,
183
- bytesBefore: 0,
184
- bytesAfter: 0,
185
- dryRunCandidates: 0,
186
- };
187
-
188
- const dbPath = vaultDbPath(vault);
189
- if (!existsSync(dbPath)) {
190
- console.error(`[${vault}] vault db not found at ${dbPath}, skipping`);
191
- return summary;
192
- }
193
-
194
- const assetsBase = vaultAssetsDir(vault);
195
- const db = new Database(dbPath);
196
-
197
- // Count the rows up-front so per-row logs can show [N/total] progress.
198
- // Matches the SELECT below so the denominator is meaningful even when
199
- // most rows turn out to be already-migrated and get fast-skipped.
200
- let total = 0;
201
- try {
202
- const row = db
203
- .prepare(
204
- "SELECT COUNT(*) AS c FROM attachments WHERE mime_type LIKE 'audio/%'",
205
- )
206
- .get() as { c: number } | undefined;
207
- total = row?.c ?? 0;
208
- } catch {
209
- total = 0;
210
- }
211
-
212
- // Running count of rows we've touched in any terminal way (converted,
213
- // errored, or skipped). Used to prefix per-row logs with [N/total].
214
- // Only emitted when total > 0 — for empty vaults the prefix is noise.
215
- let processed = 0;
216
- const progress = (): string =>
217
- total > 0 ? `[${processed + 1}/${total}] ` : "";
218
-
219
- // One-shot notice about missing original_size_bytes metadata on fixup
220
- // rows (see the fixup branch below). We log this at most once per vault
221
- // so partially-migrated vaults don't spam the summary.
222
- let warnedUnknownOriginalSize = false;
223
-
224
- // Caveat: the fixup branch (DB stale, .ogg already exists on disk) can
225
- // only credit bytesBefore from the attachment's metadata
226
- // (`original_size_bytes`, written by tts-provider.ts). Rows encoded by an
227
- // earlier pass of this migration script never had that metadata written,
228
- // so their pre-migration size is unknowable and the "saved X%" summary
229
- // will undercount on those rows. The summary is cosmetic; the data is
230
- // correct.
231
-
232
- try {
233
- const rows = db
234
- .prepare(
235
- "SELECT id, note_id, path, mime_type, metadata, created_at FROM attachments WHERE mime_type LIKE 'audio/%'",
236
- )
237
- .all() as AttachmentRow[];
238
-
239
- const updateStmt = db.prepare(
240
- "UPDATE attachments SET path = ?, mime_type = ? WHERE id = ?",
241
- );
242
-
243
- for (const row of rows) {
244
- // Already-migrated rows (audio/ogg + .ogg path) count as skipped so
245
- // re-runs produce a meaningful "nothing to do" summary.
246
- if (
247
- row.mime_type.toLowerCase() === "audio/ogg" &&
248
- row.path.toLowerCase().endsWith(".ogg")
249
- ) {
250
- summary.skipped++;
251
- processed++;
252
- continue;
253
- }
254
-
255
- if (!shouldMigrate(row.mime_type, row.path)) {
256
- processed++;
257
- continue;
258
- }
259
-
260
- const absIn = join(assetsBase, row.path);
261
- // Build the target path: same directory, same stem, .ogg extension.
262
- const lastDot = row.path.lastIndexOf(".");
263
- const stem = lastDot === -1 ? row.path : row.path.slice(0, lastDot);
264
- const relOut = `${stem}.ogg`;
265
- const absOut = join(assetsBase, relOut);
266
-
267
- // Idempotency: DB row already points to .ogg and file exists.
268
- if (row.path === relOut && existsSync(absOut)) {
269
- summary.skipped++;
270
- processed++;
271
- continue;
272
- }
273
-
274
- // If a sibling .ogg already exists AND the DB is stale (still points
275
- // to the WAV/MP3), fix up the DB row and drop the original source
276
- // file — don't re-run ffmpeg.
277
- if (existsSync(absOut) && row.path !== relOut) {
278
- if (dryRun) {
279
- console.log(
280
- `${progress()}[${vault}] DRY-RUN fixup (ogg exists, db stale): ${row.path} -> ${relOut}`,
281
- );
282
- summary.dryRunCandidates++;
283
- processed++;
284
- continue;
285
- }
286
- try {
287
- updateStmt.run(relOut, "audio/ogg", row.id);
288
- if (existsSync(absIn) && absIn !== absOut) {
289
- unlinkSync(absIn);
290
- }
291
- summary.converted++;
292
- let outSize = 0;
293
- try {
294
- outSize = statSync(absOut).size;
295
- summary.bytesAfter += outSize;
296
- } catch {
297
- // ignore
298
- }
299
- // Credit bytesBefore from the attachment's metadata if the
300
- // encoding path recorded it. Historically the old in-process
301
- // encoder wrote `original_size_bytes` alongside each OGG
302
- // attachment; the narrate-era hook (tts-hook.ts) does NOT —
303
- // narrate doesn't surface the pre-encode size. So going forward,
304
- // this field will be absent on all new rows; it only exists on
305
- // legacy rows from before the narrate swap. For rows without it
306
- // we log a one-time notice per vault and skip the credit. The
307
- // summary's "saved X%" will undercount; treat it as a lower bound.
308
- let originalBytes: number | undefined;
309
- if (row.metadata) {
310
- try {
311
- const meta = JSON.parse(row.metadata) as Record<string, unknown>;
312
- const v = meta.original_size_bytes;
313
- if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
314
- originalBytes = v;
315
- }
316
- } catch {
317
- // malformed metadata JSON — treat as unknown
318
- }
319
- }
320
- if (originalBytes !== undefined) {
321
- summary.bytesBefore += originalBytes;
322
- } else if (!warnedUnknownOriginalSize) {
323
- warnedUnknownOriginalSize = true;
324
- console.log(
325
- `[${vault}] note: one or more fixup rows have unknown original size (no original_size_bytes in metadata); summary savings will undercount`,
326
- );
327
- }
328
- console.log(
329
- `${progress()}[${vault}] fixup ${row.path} -> ${relOut}${
330
- originalBytes !== undefined
331
- ? ` (${fmtBytes(originalBytes)} -> ${fmtBytes(outSize)})`
332
- : ` (${fmtBytes(outSize)}, original size unknown)`
333
- }`,
334
- );
335
- } catch (err) {
336
- console.error(`[${vault}] fixup failed for ${row.id}:`, err);
337
- summary.errors++;
338
- }
339
- processed++;
340
- continue;
341
- }
342
-
343
- if (!existsSync(absIn)) {
344
- console.error(
345
- `${progress()}[${vault}] source file missing for attachment ${row.id}: ${absIn} — skipping`,
346
- );
347
- summary.errors++;
348
- processed++;
349
- continue;
350
- }
351
-
352
- if (dryRun) {
353
- let inSize = 0;
354
- try {
355
- inSize = statSync(absIn).size;
356
- } catch {
357
- // ignore
358
- }
359
- summary.bytesBefore += inSize;
360
- summary.dryRunCandidates++;
361
- console.log(
362
- `${progress()}[${vault}] DRY-RUN convert: ${row.path} (${fmtBytes(inSize)}, ${row.mime_type}) -> ${relOut}`,
363
- );
364
- processed++;
365
- continue;
366
- }
367
-
368
- try {
369
- const inBytes = readFileSync(absIn);
370
- const beforeSize = inBytes.byteLength;
371
- const ogg = await encodeOggOpus(
372
- Buffer.from(inBytes),
373
- inputMimeFromPath(row.path, row.mime_type),
374
- );
375
- writeFileSync(absOut, ogg);
376
-
377
- // Raw SQL update — deliberately bypasses store.updateNote so we do
378
- // NOT bump updated_at on the parent note. This is a pure storage
379
- // format change; the note content hasn't changed. See parachute-
380
- // vault#44 for the related "hooks shouldn't bump updated_at" work.
381
- updateStmt.run(relOut, "audio/ogg", row.id);
382
-
383
- if (absIn !== absOut) {
384
- try {
385
- unlinkSync(absIn);
386
- } catch (err) {
387
- console.error(
388
- `[${vault}] converted but failed to unlink original ${absIn}:`,
389
- err,
390
- );
391
- }
392
- }
393
-
394
- summary.converted++;
395
- summary.bytesBefore += beforeSize;
396
- summary.bytesAfter += ogg.byteLength;
397
-
398
- console.log(
399
- `${progress()}[${vault}] converted ${row.path} -> ${relOut} (${fmtBytes(beforeSize)} -> ${fmtBytes(ogg.byteLength)})`,
400
- );
401
- } catch (err) {
402
- console.error(
403
- `${progress()}[${vault}] error converting attachment ${row.id} (${row.path}):`,
404
- err,
405
- );
406
- summary.errors++;
407
- }
408
- processed++;
409
- }
410
- } finally {
411
- db.close();
412
- }
413
-
414
- return summary;
415
- }
416
-
417
- // ---------------------------------------------------------------------------
418
- // Formatting helpers
419
- // ---------------------------------------------------------------------------
420
-
421
- function fmtBytes(n: number): string {
422
- if (n === 0) return "0B";
423
- const units = ["B", "KB", "MB", "GB"];
424
- let i = 0;
425
- let val = n;
426
- while (val >= 1024 && i < units.length - 1) {
427
- val /= 1024;
428
- i++;
429
- }
430
- return `${val.toFixed(val < 10 && i > 0 ? 2 : 0)}${units[i]}`;
431
- }
432
-
433
- function printSummary(s: VaultSummary, dryRun: boolean): void {
434
- if (dryRun) {
435
- console.log(
436
- `[${s.vault}] DRY-RUN summary: ${s.dryRunCandidates} candidate(s), ${fmtBytes(
437
- s.bytesBefore,
438
- )} current total, ${s.errors} errors`,
439
- );
440
- return;
441
- }
442
- const savedBytes = s.bytesBefore - s.bytesAfter;
443
- const savedLabel =
444
- s.bytesBefore > 0
445
- ? ` (saved ${fmtBytes(savedBytes)}, ${((savedBytes / s.bytesBefore) * 100).toFixed(1)}%)`
446
- : "";
447
- console.log(
448
- `[${s.vault}] done: ${s.converted} converted, ${s.skipped} skipped, ${s.errors} errors, ${fmtBytes(
449
- s.bytesBefore,
450
- )} -> ${fmtBytes(s.bytesAfter)}${savedLabel}`,
451
- );
452
- }
453
-
454
- // ---------------------------------------------------------------------------
455
- // Entrypoint
456
- // ---------------------------------------------------------------------------
457
-
458
- export async function runMigration(argv: string[]): Promise<VaultSummary[]> {
459
- const args = parseArgs(argv);
460
- if (args.help) {
461
- printHelp();
462
- return [];
463
- }
464
-
465
- const vaults = args.vault ? [args.vault] : listVaultNames();
466
- if (vaults.length === 0) {
467
- console.error(`No vaults found under ${vaultsDir()}`);
468
- return [];
469
- }
470
-
471
- console.log(
472
- `Migrating audio attachments to OGG Opus${args.dryRun ? " (DRY RUN)" : ""}`,
473
- );
474
- console.log(`Vaults: ${vaults.join(", ")}`);
475
-
476
- const summaries: VaultSummary[] = [];
477
- for (const v of vaults) {
478
- try {
479
- const s = await migrateVault(v, args.dryRun);
480
- summaries.push(s);
481
- printSummary(s, args.dryRun);
482
- } catch (err) {
483
- console.error(`[${v}] fatal error:`, err);
484
- }
485
- }
486
-
487
- return summaries;
488
- }
489
-
490
- // Only auto-run when invoked directly (`bun scripts/migrate-audio-to-opus.ts`),
491
- // not when imported from a test.
492
- if (import.meta.main) {
493
- runMigration(process.argv.slice(2))
494
- .then(() => process.exit(0))
495
- .catch((err) => {
496
- console.error("fatal:", err);
497
- process.exit(1);
498
- });
499
- }