@openparachute/vault 0.3.3 → 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 (79) hide show
  1. package/.parachute/module.json +15 -0
  2. package/core/src/core.test.ts +2252 -7
  3. package/core/src/links.ts +1 -1
  4. package/core/src/mcp.ts +801 -67
  5. package/core/src/note-schemas.ts +232 -0
  6. package/core/src/notes.ts +313 -35
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +287 -0
  11. package/core/src/schema.ts +393 -9
  12. package/core/src/store.ts +248 -6
  13. package/core/src/tag-hierarchy.ts +137 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +100 -6
  16. package/core/src/wikilinks.ts +3 -3
  17. package/package.json +13 -3
  18. package/src/admin-spa.test.ts +161 -0
  19. package/src/admin-spa.ts +161 -0
  20. package/src/auth-hub-jwt.test.ts +231 -0
  21. package/src/auth-status.ts +84 -0
  22. package/src/auth.test.ts +135 -23
  23. package/src/auth.ts +144 -15
  24. package/src/backup.ts +4 -7
  25. package/src/cli.ts +322 -57
  26. package/src/config.test.ts +44 -0
  27. package/src/config.ts +68 -40
  28. package/src/hub-jwt.test.ts +296 -0
  29. package/src/hub-jwt.ts +79 -0
  30. package/src/init.test.ts +216 -0
  31. package/src/mcp-http.ts +30 -28
  32. package/src/mcp-install.ts +1 -1
  33. package/src/mcp-tools.ts +294 -6
  34. package/src/module-config.ts +1 -1
  35. package/src/oauth.test.ts +345 -0
  36. package/src/oauth.ts +85 -14
  37. package/src/owner-auth.ts +57 -1
  38. package/src/prompt.ts +6 -5
  39. package/src/routes.ts +686 -58
  40. package/src/routing.test.ts +466 -1
  41. package/src/routing.ts +108 -24
  42. package/src/scopes.test.ts +66 -8
  43. package/src/scopes.ts +163 -37
  44. package/src/server.ts +24 -2
  45. package/src/services-manifest.test.ts +20 -0
  46. package/src/services-manifest.ts +9 -2
  47. package/src/stop-signal.test.ts +85 -0
  48. package/src/storage.test.ts +92 -0
  49. package/src/tag-scope.ts +118 -0
  50. package/src/token-store.test.ts +47 -0
  51. package/src/token-store.ts +128 -13
  52. package/src/tokens-routes.test.ts +720 -0
  53. package/src/tokens-routes.ts +392 -0
  54. package/src/transcription-worker.test.ts +5 -0
  55. package/src/triggers.ts +1 -1
  56. package/src/two-factor.ts +2 -2
  57. package/src/vault-create.test.ts +193 -0
  58. package/src/vault-name.test.ts +123 -0
  59. package/src/vault-name.ts +80 -0
  60. package/src/vault.test.ts +868 -3
  61. package/tsconfig.json +8 -1
  62. package/.claude/settings.local.json +0 -8
  63. package/.dockerignore +0 -8
  64. package/.env.example +0 -9
  65. package/CHANGELOG.md +0 -175
  66. package/CLAUDE.md +0 -125
  67. package/Caddyfile +0 -3
  68. package/Dockerfile +0 -22
  69. package/bun.lock +0 -219
  70. package/bunfig.toml +0 -2
  71. package/deploy/parachute-vault.service +0 -20
  72. package/docker-compose.yml +0 -50
  73. package/docs/HTTP_API.md +0 -434
  74. package/docs/auth-model.md +0 -340
  75. package/fly.toml +0 -24
  76. package/package/package.json +0 -32
  77. package/railway.json +0 -14
  78. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  79. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
  import { rebuildIndexes } from "./indexed-fields.js";
4
4
 
5
- export const SCHEMA_VERSION = 12;
5
+ export const SCHEMA_VERSION = 16;
6
6
 
7
7
  export const SCHEMA_SQL = `
8
8
  -- Notes: the universal record
@@ -15,9 +15,27 @@ CREATE TABLE IF NOT EXISTS notes (
15
15
  updated_at TEXT
16
16
  );
17
17
 
18
- -- Tags: flat labels
18
+ -- Tags: first-class identity carrying schema, hierarchy, and typed-link
19
+ -- declarations. One row per tag; no notes-as-config sidecars for these
20
+ -- concerns. See parachute-patterns/patterns/tag-data-model.md.
21
+ --
22
+ -- description — human-readable blurb (markdown).
23
+ -- fields — JSON: indexed metadata field declarations per
24
+ -- query-operators.md. Replaces v6-era tag_schemas.fields.
25
+ -- relationships — JSON: typed-link declarations
26
+ -- ({ "rel": { target_tag, cardinality, description? } }).
27
+ -- Cardinality vocabulary: one | optional | many | many-required.
28
+ -- Phase 1 informational — declared but not enforced at write.
29
+ -- parent_names — JSON array of parent tag names. Replaces the v6-era
30
+ -- _tags/NAME config-note hierarchy.
19
31
  CREATE TABLE IF NOT EXISTS tags (
20
- name TEXT PRIMARY KEY
32
+ name TEXT PRIMARY KEY,
33
+ description TEXT,
34
+ fields TEXT,
35
+ relationships TEXT,
36
+ parent_names TEXT,
37
+ created_at TEXT,
38
+ updated_at TEXT
21
39
  );
22
40
 
23
41
  -- Note-Tag join
@@ -47,11 +65,40 @@ CREATE TABLE IF NOT EXISTS links (
47
65
  UNIQUE(source_id, target_id, relationship)
48
66
  );
49
67
 
50
- -- Tag schemas: optional metadata schema per tag
51
- CREATE TABLE IF NOT EXISTS tag_schemas (
52
- tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
68
+ -- tag_schemas (v6) was retired in v14; description + fields lifted onto the
69
+ -- tags row directly. The CREATE TABLE was removed from SCHEMA_SQL after the
70
+ -- v14 data migration drops the table; existing v6+ vaults pick up the
71
+ -- migration on next boot. See migrateToV14.
72
+
73
+ -- Note schemas (v15): schema definitions used to validate notes by path
74
+ -- prefix or tag. Replaces the v6-era _schemas/NAME notes-as-config
75
+ -- convention. Validation is non-blocking — schemas surface warnings on
76
+ -- create/update responses, never reject the write. See
77
+ -- core/src/schema-defaults.ts and patterns/tag-data-model.md §Note schemas.
78
+ --
79
+ -- name — primary key; the schema identifier referenced by mappings.
80
+ -- description — human-readable blurb (markdown).
81
+ -- fields — JSON: { fieldName: { type?, enum?, description? } }.
82
+ -- required — JSON: string[] of required field names.
83
+ CREATE TABLE IF NOT EXISTS note_schemas (
84
+ name TEXT PRIMARY KEY,
53
85
  description TEXT,
54
- fields TEXT -- JSON: { "field_name": { "type": "string", "description": "..." }, ... }
86
+ fields TEXT,
87
+ required TEXT,
88
+ created_at TEXT,
89
+ updated_at TEXT
90
+ );
91
+
92
+ -- Schema mappings (v15): replaces the singleton _schema_defaults note. One
93
+ -- row per match rule; the resolver walks the table at note-write time.
94
+ -- match_kind is constrained to 'path_prefix' or 'tag'. Composite PK so
95
+ -- (schema, kind, value) is naturally unique without an extra surrogate id.
96
+ -- ON DELETE CASCADE: dropping a schema cleans up its mappings.
97
+ CREATE TABLE IF NOT EXISTS schema_mappings (
98
+ schema_name TEXT NOT NULL REFERENCES note_schemas(name) ON DELETE CASCADE,
99
+ match_kind TEXT NOT NULL CHECK (match_kind IN ('path_prefix', 'tag')),
100
+ match_value TEXT NOT NULL,
101
+ PRIMARY KEY (schema_name, match_kind, match_value)
55
102
  );
56
103
 
57
104
  -- Indexed fields: SSOT for generated columns and indexes on notes derived
@@ -73,6 +120,19 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
73
120
  -- one-release-cycle back-compat window and will be dropped in a future
74
121
  -- migration.
75
122
  --
123
+ -- scoped_tags is a JSON-encoded array of root tag names that constrain the
124
+ -- token's effective access (intersection with the scopes column). NULL
125
+ -- means unscoped — full vault access per scopes. Introduced in v13 per
126
+ -- patterns/tag-scoped-tokens.md. Hierarchy expansion is applied at auth
127
+ -- time via getTagDescendants; the column stores root names only.
128
+ --
129
+ -- vault_name (v16) binds the token to a single vault. NULL means the
130
+ -- token is server-wide / legacy (pre-v16 rows backfill to NULL on the
131
+ -- migration; auth treats NULL as accept-any-vault for back-compat).
132
+ -- New tokens minted via per-vault routes write the column explicitly so
133
+ -- cross-vault presentation rejects in authenticateVaultRequest. See
134
+ -- vault#257.
135
+ --
76
136
  -- scope_tag / scope_path_prefix are deprecated Phase-0 columns — never
77
137
  -- enforced at runtime, kept only for schema stability.
78
138
  CREATE TABLE IF NOT EXISTS tokens (
@@ -80,11 +140,13 @@ CREATE TABLE IF NOT EXISTS tokens (
80
140
  label TEXT NOT NULL,
81
141
  permission TEXT NOT NULL DEFAULT 'admin',
82
142
  scopes TEXT,
143
+ scoped_tags TEXT,
83
144
  scope_tag TEXT,
84
145
  scope_path_prefix TEXT,
85
146
  expires_at TEXT,
86
147
  created_at TEXT NOT NULL,
87
- last_used_at TEXT
148
+ last_used_at TEXT,
149
+ vault_name TEXT
88
150
  );
89
151
 
90
152
  -- OAuth: registered clients (Dynamic Client Registration)
@@ -147,6 +209,13 @@ CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_name, note_id);
147
209
  CREATE INDEX IF NOT EXISTS idx_attachments_note ON attachments(note_id);
148
210
  CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_id);
149
211
  CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_id);
212
+ CREATE INDEX IF NOT EXISTS idx_schema_mappings_match ON schema_mappings(match_kind, match_value);
213
+ -- idx_tokens_vault_name is created in migrateToV16, not here. SCHEMA_SQL
214
+ -- runs BEFORE migrations; an upgrading v15 vault doesn't yet have the
215
+ -- vault_name column when this section evaluates, so the index has to
216
+ -- live downstream of the ALTER TABLE that adds the column. Fresh vaults
217
+ -- (column already present from this CREATE TABLE) still get the index
218
+ -- because migrateToV16 also runs the unconditional CREATE INDEX path.
150
219
  `;
151
220
 
152
221
  /**
@@ -194,6 +263,28 @@ export function initSchema(db: Database): void {
194
263
  // Migrate v11 → v12: add `scopes` column to tokens for Phase 2 enforcement.
195
264
  migrateToV12(db);
196
265
 
266
+ // Migrate v12 → v13: add `scoped_tags` column to tokens for tag-scoped tokens.
267
+ migrateToV13(db);
268
+
269
+ // Migrate v13 → v14: tag-data-model reshape. Augment `tags` row with
270
+ // description/fields/relationships/parent_names/timestamps; copy data
271
+ // from the v6-era tag_schemas sidecar and from `_tags/<name>` config
272
+ // notes; drop tag_schemas after copy. See patterns/tag-data-model.md.
273
+ migrateToV14(db);
274
+
275
+ // Migrate v14 → v15: retire the `_schemas/<name>` and `_schema_defaults`
276
+ // notes-as-config sidecars. Copy each `_schemas/<name>` note into the
277
+ // new `note_schemas` table and the `_schema_defaults` mappings into
278
+ // `schema_mappings`. The legacy notes are LEFT IN PLACE — they are
279
+ // inert post-v15 (no resolver reads them) and serve as audit trail.
280
+ migrateToV15(db);
281
+
282
+ // Migrate v15 → v16: add `vault_name` column to tokens. Existing rows
283
+ // backfill to NULL ("server-wide / legacy" semantic) — auth accepts
284
+ // NULL for any vault, so today's pvt_* tokens keep working unchanged.
285
+ // New mints via per-vault routes write the column explicitly. See vault#257.
286
+ migrateToV16(db);
287
+
197
288
  // Rebuild any generated columns + indexes declared in indexed_fields.
198
289
  // No-op for a fresh vault; idempotent on existing vaults.
199
290
  rebuildIndexes(db);
@@ -258,7 +349,7 @@ function migrateToV5(db: Database): void {
258
349
  // Keep first, rename the rest
259
350
  for (let i = 1; i < ids.length; i++) {
260
351
  const newPath = `${dupe.path}-${i}`;
261
- db.prepare("UPDATE notes SET path = ? WHERE id = ?").run(newPath, ids[i]);
352
+ db.prepare("UPDATE notes SET path = ? WHERE id = ?").run(newPath, ids[i]!);
262
353
  }
263
354
  }
264
355
 
@@ -337,6 +428,299 @@ function migrateToV12(db: Database): void {
337
428
  }
338
429
  }
339
430
 
431
+ /**
432
+ * Migrate v12 → v13: add `scoped_tags` column to tokens. NULL means unscoped
433
+ * (current full-vault behavior); a JSON array of root tag names narrows the
434
+ * token's access to notes carrying one of those tags or a sub-tag thereof
435
+ * (hierarchy expansion via getTagDescendants at auth time). See
436
+ * parachute-patterns/patterns/tag-scoped-tokens.md.
437
+ */
438
+ function migrateToV13(db: Database): void {
439
+ if (hasTable(db, "tokens") && !hasColumn(db, "tokens", "scoped_tags")) {
440
+ db.exec("ALTER TABLE tokens ADD COLUMN scoped_tags TEXT");
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Migrate v13 → v14: tag-data-model reshape (patterns/tag-data-model.md).
446
+ *
447
+ * Augments the `tags` table with five new columns and one timestamp pair,
448
+ * then copies pre-existing data from two notes-as-config sidecars:
449
+ *
450
+ * - tag_schemas (v6 sidecar) → tags.{description,fields}
451
+ * - notes at path `_tags/<name>` → tags.parent_names (from metadata.parents)
452
+ *
453
+ * After the copy lands, `tag_schemas` is dropped. The `_tags/<name>` notes
454
+ * are LEFT IN PLACE — they're harmless historical record and a user might
455
+ * have other content there. Future writes go to the tags row directly.
456
+ *
457
+ * Wrapped in BEGIN IMMEDIATE / COMMIT (with a try/catch ROLLBACK) so a
458
+ * crash mid-migration leaves the DB in either pre-v14 or post-v14 state,
459
+ * never half-migrated. Each step remains individually idempotent — the
460
+ * transaction wrap is belt-and-suspenders, not load-bearing — so a future
461
+ * reader who removes the `hasColumn` / `hasTable` guards still gets correct
462
+ * behavior on retry.
463
+ */
464
+ function migrateToV14(db: Database): void {
465
+ if (!hasTable(db, "tags")) return;
466
+
467
+ db.exec("BEGIN IMMEDIATE");
468
+ try {
469
+ // 1. ALTER TABLE — additive, idempotent.
470
+ const cols: [string, string][] = [
471
+ ["description", "TEXT"],
472
+ ["fields", "TEXT"],
473
+ ["relationships", "TEXT"],
474
+ ["parent_names", "TEXT"],
475
+ ["created_at", "TEXT"],
476
+ ["updated_at", "TEXT"],
477
+ ];
478
+ for (const [col, type] of cols) {
479
+ if (!hasColumn(db, "tags", col)) {
480
+ db.exec(`ALTER TABLE tags ADD COLUMN ${col} ${type}`);
481
+ }
482
+ }
483
+
484
+ const now = new Date().toISOString();
485
+ let copiedSchemas = 0;
486
+ let copiedHierarchy = 0;
487
+
488
+ // 2. Copy tag_schemas → tags.{description,fields}.
489
+ if (hasTable(db, "tag_schemas")) {
490
+ const rows = db.prepare(
491
+ "SELECT tag_name, description, fields FROM tag_schemas",
492
+ ).all() as { tag_name: string; description: string | null; fields: string | null }[];
493
+ const upsert = db.prepare(
494
+ "INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
495
+ );
496
+ const update = db.prepare(
497
+ "UPDATE tags SET description = ?, fields = ?, updated_at = ? WHERE name = ?",
498
+ );
499
+ for (const row of rows) {
500
+ upsert.run(row.tag_name, now, now);
501
+ update.run(row.description, row.fields, now, row.tag_name);
502
+ copiedSchemas++;
503
+ }
504
+ }
505
+
506
+ // 3. Copy `_tags/<name>` notes' metadata.parents → tags.parent_names.
507
+ // Only runs if the notes table exists (it always does post-SCHEMA_SQL,
508
+ // but stay defensive — initSchema runs SCHEMA_SQL first so this is true).
509
+ if (hasTable(db, "notes")) {
510
+ const tagNotes = db.prepare(
511
+ "SELECT path, metadata FROM notes WHERE path GLOB '_tags/*'",
512
+ ).all() as { path: string; metadata: string | null }[];
513
+ const upsert = db.prepare(
514
+ "INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
515
+ );
516
+ const update = db.prepare(
517
+ "UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?",
518
+ );
519
+ for (const note of tagNotes) {
520
+ const tagName = note.path.slice("_tags/".length);
521
+ if (!tagName) continue;
522
+ let parents: string[] | null = null;
523
+ try {
524
+ const meta = note.metadata ? JSON.parse(note.metadata) : {};
525
+ const raw = meta?.parents;
526
+ if (Array.isArray(raw) && raw.length > 0) {
527
+ const cleaned = raw.filter((p: unknown): p is string => typeof p === "string" && p.length > 0);
528
+ if (cleaned.length > 0) parents = cleaned;
529
+ }
530
+ } catch {
531
+ // Malformed metadata — skip; the note is left untouched.
532
+ continue;
533
+ }
534
+ if (!parents) continue;
535
+ upsert.run(tagName, now, now);
536
+ update.run(JSON.stringify(parents), now, tagName);
537
+ copiedHierarchy++;
538
+ }
539
+ }
540
+
541
+ // 4. Backfill timestamps for rows the copies didn't touch.
542
+ db.exec(`UPDATE tags SET created_at = '${now}' WHERE created_at IS NULL`);
543
+
544
+ // 5. Drop the sidecar after the copy is complete.
545
+ if (hasTable(db, "tag_schemas")) {
546
+ db.exec("DROP TABLE tag_schemas");
547
+ }
548
+
549
+ db.exec("COMMIT");
550
+
551
+ if (copiedSchemas > 0 || copiedHierarchy > 0) {
552
+ console.log(
553
+ `[vault] migrated to schema v14: copied ${copiedSchemas} tag_schemas + ${copiedHierarchy} _tags/* hierarchies onto tags rows`,
554
+ );
555
+ }
556
+ } catch (err) {
557
+ db.exec("ROLLBACK");
558
+ throw err;
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Migrate v14 → v15: retire `_schemas/<name>` + `_schema_defaults` notes
564
+ * as the canonical source for schema definitions and mapping rules. After
565
+ * this migration the resolver reads from `note_schemas` and
566
+ * `schema_mappings` tables. The legacy notes are LEFT IN PLACE — they're
567
+ * harmless historical record and a user might have other content there.
568
+ *
569
+ * Idempotent: SCHEMA_SQL creates the tables before this runs (CREATE TABLE
570
+ * IF NOT EXISTS); the data copy uses INSERT OR IGNORE so re-running on a
571
+ * post-v15 DB is a no-op. Wrapped in BEGIN/COMMIT so a crash mid-migration
572
+ * leaves the DB in either pre-v15 or post-v15 state, never partial.
573
+ */
574
+ function migrateToV15(db: Database): void {
575
+ if (!hasTable(db, "note_schemas") || !hasTable(db, "notes")) return;
576
+
577
+ // Short-circuit: if either destination table already has data, the
578
+ // migration has run before. `||` not `&&` — a vault with schemas but zero
579
+ // mappings (or mappings but zero schemas) is a valid post-v15 state, and
580
+ // re-scanning notes on every boot would be wasted I/O.
581
+ const hasSchemas = (db.prepare(
582
+ "SELECT 1 FROM note_schemas LIMIT 1",
583
+ ).get()) !== null;
584
+ const hasMappings = (db.prepare(
585
+ "SELECT 1 FROM schema_mappings LIMIT 1",
586
+ ).get()) !== null;
587
+ if (hasSchemas || hasMappings) return;
588
+
589
+ db.exec("BEGIN IMMEDIATE");
590
+ try {
591
+ const now = new Date().toISOString();
592
+ let copiedSchemas = 0;
593
+ let copiedMappings = 0;
594
+
595
+ // 1. Copy `_schemas/<name>` notes → note_schemas.
596
+ const defRows = db.prepare(
597
+ "SELECT path, metadata FROM notes WHERE path GLOB '_schemas/*'",
598
+ ).all() as { path: string; metadata: string | null }[];
599
+ const insertSchema = db.prepare(
600
+ "INSERT OR IGNORE INTO note_schemas (name, description, fields, required, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
601
+ );
602
+ for (const row of defRows) {
603
+ const name = row.path.slice("_schemas/".length);
604
+ if (!name) continue;
605
+ let description: string | null = null;
606
+ let fields: string | null = null;
607
+ let required: string | null = null;
608
+ try {
609
+ const meta = row.metadata ? JSON.parse(row.metadata) : {};
610
+ if (typeof meta?.description === "string") description = meta.description;
611
+ if (meta?.fields && typeof meta.fields === "object" && !Array.isArray(meta.fields)) {
612
+ fields = JSON.stringify(meta.fields);
613
+ }
614
+ if (Array.isArray(meta?.required)) {
615
+ const cleaned = meta.required.filter((x: unknown): x is string => typeof x === "string");
616
+ if (cleaned.length > 0) required = JSON.stringify(cleaned);
617
+ }
618
+ } catch {
619
+ // Malformed metadata — skip; the note is left alone.
620
+ continue;
621
+ }
622
+ insertSchema.run(name, description, fields, required, now, now);
623
+ copiedSchemas++;
624
+ }
625
+
626
+ // 2. Copy `_schema_defaults` note → schema_mappings.
627
+ const mappingNote = db.prepare(
628
+ "SELECT metadata FROM notes WHERE path = '_schema_defaults'",
629
+ ).get() as { metadata: string | null } | undefined;
630
+ if (mappingNote?.metadata) {
631
+ const insertMapping = db.prepare(
632
+ "INSERT OR IGNORE INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
633
+ );
634
+ const ensureSchemaRow = db.prepare(
635
+ "INSERT OR IGNORE INTO note_schemas (name, created_at, updated_at) VALUES (?, ?, ?)",
636
+ );
637
+ try {
638
+ const meta = JSON.parse(mappingNote.metadata);
639
+ const pathPrefixes = meta?.path_prefixes;
640
+ if (pathPrefixes && typeof pathPrefixes === "object" && !Array.isArray(pathPrefixes)) {
641
+ for (const [prefix, schema] of Object.entries(pathPrefixes as Record<string, unknown>)) {
642
+ if (typeof schema === "string" && schema.length > 0 && prefix.length > 0) {
643
+ // Foreign key requires the schema row to exist; create a stub
644
+ // if the user mapped to a name with no _schemas/<name> note.
645
+ ensureSchemaRow.run(schema, now, now);
646
+ insertMapping.run(schema, "path_prefix", prefix);
647
+ copiedMappings++;
648
+ }
649
+ }
650
+ }
651
+ const tags = meta?.tags;
652
+ if (tags && typeof tags === "object" && !Array.isArray(tags)) {
653
+ for (const [tag, schema] of Object.entries(tags as Record<string, unknown>)) {
654
+ if (typeof schema === "string" && schema.length > 0 && tag.length > 0) {
655
+ ensureSchemaRow.run(schema, now, now);
656
+ insertMapping.run(schema, "tag", tag);
657
+ copiedMappings++;
658
+ }
659
+ }
660
+ }
661
+ } catch {
662
+ // Malformed _schema_defaults — leave both note and table empty;
663
+ // user can fix and re-run.
664
+ }
665
+ }
666
+
667
+ db.exec("COMMIT");
668
+
669
+ if (copiedSchemas > 0 || copiedMappings > 0) {
670
+ console.log(
671
+ `[vault] migrated to schema v15: copied ${copiedSchemas} _schemas/* + ${copiedMappings} _schema_defaults mappings into note_schemas/schema_mappings`,
672
+ );
673
+ }
674
+ } catch (err) {
675
+ db.exec("ROLLBACK");
676
+ throw err;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Migrate v15 → v16: per-vault token storage (vault#257).
682
+ *
683
+ * Adds `tokens.vault_name TEXT` (nullable). Existing rows stay NULL —
684
+ * "server-wide / legacy" semantic — and `authenticateVaultRequest`
685
+ * accepts NULL for any vault, so today's pvt_* tokens keep working
686
+ * unchanged. New mints via `/vault/<name>/tokens` write the column
687
+ * explicitly; cross-vault presentation rejects on the row's vault_name
688
+ * mismatch. The complementary index speeds the per-vault listTokens
689
+ * filter in the admin SPA.
690
+ *
691
+ * Wrapped in BEGIN IMMEDIATE / COMMIT (with try/catch ROLLBACK) per the
692
+ * v14/v15 wrap pattern from vault#251 — the column add and index create
693
+ * are individually idempotent (`hasColumn` / IF NOT EXISTS), but the
694
+ * transaction means a crash mid-migration leaves either pre-v16 or
695
+ * post-v16 state, never partial.
696
+ */
697
+ function migrateToV16(db: Database): void {
698
+ if (!hasTable(db, "tokens")) return;
699
+
700
+ // Two responsibilities, separated so the index lands for both fresh
701
+ // vaults (SCHEMA_SQL already created the column) and upgrading v15
702
+ // vaults (column missing). Index creation lives outside the wrapped
703
+ // ALTER block so a fresh vault — where the column exists but the index
704
+ // doesn't — still gets it.
705
+ if (!hasColumn(db, "tokens", "vault_name")) {
706
+ db.exec("BEGIN IMMEDIATE");
707
+ try {
708
+ db.exec("ALTER TABLE tokens ADD COLUMN vault_name TEXT");
709
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
710
+ db.exec("COMMIT");
711
+ } catch (err) {
712
+ db.exec("ROLLBACK");
713
+ throw err;
714
+ }
715
+ return;
716
+ }
717
+
718
+ // Column exists (fresh vault from SCHEMA_SQL, or post-upgrade vault).
719
+ // Make sure the index exists too. IF NOT EXISTS makes this a no-op on
720
+ // the steady-state path.
721
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
722
+ }
723
+
340
724
  function hasTable(db: Database, name: string): boolean {
341
725
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
342
726
  return !!row;