@openparachute/vault 0.3.3 → 0.4.3

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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  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 +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  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 +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -82,8 +82,8 @@ export function parseFrontmatter(raw: string): {
82
82
  // Key: value pair — keys must be YAML-valid (word chars and hyphens, no spaces)
83
83
  const kvMatch = line.match(/^([\w][\w-]*):\s*(.*)/);
84
84
  if (kvMatch) {
85
- const key = kvMatch[1];
86
- const value = kvMatch[2].trim();
85
+ const key = kvMatch[1]!;
86
+ const value = kvMatch[2]!.trim();
87
87
 
88
88
  if (value === "[]") {
89
89
  frontmatter[key] = [];
@@ -143,7 +143,7 @@ export function extractInlineTags(content: string): string[] {
143
143
  const regex = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
144
144
  let match: RegExpExecArray | null;
145
145
  while ((match = regex.exec(stripped)) !== null) {
146
- tags.add(match[1].toLowerCase());
146
+ tags.add(match[1]!.toLowerCase());
147
147
  }
148
148
  return [...tags];
149
149
  }
package/core/src/paths.ts CHANGED
@@ -39,7 +39,7 @@ export function normalizePath(path: string | null | undefined): string | null {
39
39
  */
40
40
  export function pathTitle(path: string): string {
41
41
  const segments = path.split("/");
42
- return segments[segments.length - 1];
42
+ return segments[segments.length - 1]!;
43
43
  }
44
44
 
45
45
  /**
@@ -15,7 +15,7 @@
15
15
  * See `Parachute/Decisions/2026-04-19-metadata-indexing-via-tag-schemas`.
16
16
  */
17
17
 
18
- import { Database } from "bun:sqlite";
18
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
19
19
  import { getIndexedField, type IndexedField } from "./indexed-fields.js";
20
20
 
21
21
  export const SUPPORTED_OPS = [
@@ -68,6 +68,22 @@ function validateOperatorObject(field: string, obj: Record<string, unknown>): vo
68
68
  }
69
69
  }
70
70
 
71
+ function toBinding(field: string, op: string, value: unknown): SQLQueryBindings {
72
+ if (
73
+ value === null ||
74
+ typeof value === "string" ||
75
+ typeof value === "number" ||
76
+ typeof value === "boolean" ||
77
+ typeof value === "bigint"
78
+ ) {
79
+ return value;
80
+ }
81
+ throw new QueryError(
82
+ `operator "${op}" on metadata field "${field}" expects a primitive value (string, number, boolean, bigint, or null), got ${typeof value}`,
83
+ "INVALID_OPERATOR_VALUE",
84
+ );
85
+ }
86
+
71
87
  /**
72
88
  * Look up `field` in `indexed_fields` or throw a loud error suggesting the
73
89
  * caller declare it via `update-tag` with `indexed: true`.
@@ -91,14 +107,14 @@ export function requireIndexedField(db: Database, field: string): IndexedField {
91
107
  export function buildOperatorClause(
92
108
  field: string,
93
109
  opObj: Record<string, unknown>,
94
- ): { sql: string; params: unknown[] } {
110
+ ): { sql: string; params: SQLQueryBindings[] } {
95
111
  validateOperatorObject(field, opObj);
96
112
  // `field` came from indexed_fields (which validated it via FIELD_NAME_RE
97
113
  // when the declaration was recorded), so interpolating it into the column
98
114
  // name is safe.
99
115
  const col = `"meta_${field}"`;
100
116
  const parts: string[] = [];
101
- const params: unknown[] = [];
117
+ const params: SQLQueryBindings[] = [];
102
118
 
103
119
  for (const [op, value] of Object.entries(opObj)) {
104
120
  switch (op as QueryOp) {
@@ -107,7 +123,7 @@ export function buildOperatorClause(
107
123
  parts.push(`${col} IS NULL`);
108
124
  } else {
109
125
  parts.push(`${col} = ?`);
110
- params.push(value);
126
+ params.push(toBinding(field, op, value));
111
127
  }
112
128
  break;
113
129
  case "ne":
@@ -119,7 +135,7 @@ export function buildOperatorClause(
119
135
  // that has no value for the field would be silently excluded. Be
120
136
  // explicit: either the column is null, or the values differ.
121
137
  parts.push(`(${col} IS NULL OR ${col} <> ?)`);
122
- params.push(value);
138
+ params.push(toBinding(field, op, value));
123
139
  }
124
140
  break;
125
141
  case "gt":
@@ -128,7 +144,7 @@ export function buildOperatorClause(
128
144
  case "lte": {
129
145
  const sym = op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=";
130
146
  parts.push(`${col} ${sym} ?`);
131
- params.push(value);
147
+ params.push(toBinding(field, op, value));
132
148
  break;
133
149
  }
134
150
  case "in":
@@ -152,7 +168,7 @@ export function buildOperatorClause(
152
168
  } else {
153
169
  parts.push(`(${col} IS NULL OR ${col} NOT IN (${placeholders}))`);
154
170
  }
155
- for (const v of value) params.push(v);
171
+ for (const v of value) params.push(toBinding(field, op, v));
156
172
  break;
157
173
  }
158
174
  case "exists":
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Schema validation: walk the tags carried by a note (plus their ancestors via
3
+ * `parent_names`), look up each ancestor's `fields` declaration, merge them
4
+ * (first-in-walk wins on conflict), and validate the note's metadata against
5
+ * the merged field map. Writes are never blocked — schemas are guidance. The
6
+ * MCP/REST layer surfaces a `validation_status` block on create/update
7
+ * responses with any warnings the agent can act on (type mismatch, enum
8
+ * mismatch, schema conflict).
9
+ *
10
+ * Storage: schemas live as `fields` columns on the `tags` table — same row
11
+ * that already carries description, relationships, and parent_names. Authoring
12
+ * is via `update-tag` (MCP) or PATCH /api/tags/:name (REST).
13
+ *
14
+ * This was a two-table subsystem (`note_schemas` + `schema_mappings`) prior
15
+ * to v17 — see vault#267. Removed in v17 because zero operator vaults used
16
+ * the path-prefix mapping kind, and tag-mapped schemas were fully redundant
17
+ * with `tags.fields`. The single-axis tag-driven validation lives here.
18
+ *
19
+ * Inheritance (vault#270, 2026-05-09):
20
+ * - A note's effective ancestor set = union of {tag ∪ ancestors(tag)} for each
21
+ * tag on the note, walking `parent_names` recursively (cycle-safe).
22
+ * - `_default` is an implicit universal parent: if a tag named `_default`
23
+ * exists in the tags table, it's appended to every note's effective ancestor
24
+ * set (including untagged notes). The `tags.parent_names` column is never
25
+ * auto-mutated — the magic lives at resolve time only.
26
+ * - Conflict resolution: first-in-walk wins. The walk visits each note tag
27
+ * in order, then DFS through its `parent_names` array in declaration order,
28
+ * so "first-in-`parent_names`-array wins" is the operator-controlled
29
+ * precedence. Conflicts surface as advisory `schema_conflict` warnings; no
30
+ * write blocking.
31
+ * - `_default` can technically carry its own `parent_names` and the resolver
32
+ * handles it (cycle guard + visited Set), but the resulting interaction is
33
+ * non-obvious — `_default` is usually appended last, so its ancestors
34
+ * become low-precedence. Treat `_default` as a root tag in normal use.
35
+ *
36
+ * Resolution model:
37
+ * - Lazy: rebuilt on first access, cached on the store.
38
+ * - Invalidated when `tags.fields` or `tags.parent_names` is mutated, when a
39
+ * tag is deleted, renamed, or merged.
40
+ * - When no ancestor declares fields, validation is a no-op (status omitted).
41
+ */
42
+
43
+ import { Database } from "bun:sqlite";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Types
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export interface SchemaField {
50
+ type?: "string" | "number" | "boolean" | "array" | "object";
51
+ enum?: string[];
52
+ description?: string;
53
+ }
54
+
55
+ /**
56
+ * Tag-record snapshot used by the resolver. Loaded from the `tags` table once
57
+ * and cached on the store. `allTags` carries every known name (so `_default`
58
+ * existence checks and query expansion can be answered without a re-read).
59
+ */
60
+ export interface ResolvedSchemas {
61
+ /** Set of all known tag names (for `_default` magic + presence checks). */
62
+ allTags: Set<string>;
63
+ /** Per-tag own fields (only entries with at least one declaration). */
64
+ tagToFields: Map<string, Record<string, SchemaField>>;
65
+ /** Per-tag `parent_names` (only entries with at least one parent declared). */
66
+ tagToParents: Map<string, string[]>;
67
+ }
68
+
69
+ export interface ValidationWarning {
70
+ field: string;
71
+ /** Tag whose schema declared the violated field (or won the conflict). */
72
+ schema: string;
73
+ /**
74
+ * `type_mismatch` — value's type contradicts the declared `type`.
75
+ * `enum_mismatch` — string value not in the declared `enum`.
76
+ * `schema_conflict` — two ancestors declared the same field with
77
+ * different specs; first-in-walk wins, the loser surfaces here so the
78
+ * operator can resolve the disagreement.
79
+ */
80
+ reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
81
+ message: string;
82
+ /**
83
+ * `schema_conflict` only — the tag whose declaration was overridden. Set
84
+ * when `reason === "schema_conflict"`; absent on type/enum mismatches.
85
+ * Surfaces structurally so agents don't have to regex `message` to find
86
+ * the loser.
87
+ */
88
+ loser_schema?: string;
89
+ }
90
+
91
+ export interface ValidationStatus {
92
+ /** Tag names whose schemas contributed at least one field to the merged map. */
93
+ schemas: string[];
94
+ /** Empty when all checks pass. */
95
+ warnings: ValidationWarning[];
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Loading
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
103
+ if (!raw) return {};
104
+ let parsed: unknown;
105
+ try {
106
+ parsed = JSON.parse(raw);
107
+ } catch {
108
+ return {};
109
+ }
110
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
111
+ const fields: Record<string, SchemaField> = {};
112
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
113
+ if (!v || typeof v !== "object") continue;
114
+ const f = v as Record<string, unknown>;
115
+ const field: SchemaField = {};
116
+ if (typeof f.type === "string") field.type = f.type as SchemaField["type"];
117
+ if (Array.isArray(f.enum)) field.enum = f.enum.filter((x): x is string => typeof x === "string");
118
+ if (typeof f.description === "string") field.description = f.description;
119
+ fields[k] = field;
120
+ }
121
+ return fields;
122
+ }
123
+
124
+ function parseParentsJson(raw: string | null): string[] {
125
+ if (!raw) return [];
126
+ let parsed: unknown;
127
+ try { parsed = JSON.parse(raw); } catch { return []; }
128
+ if (!Array.isArray(parsed)) return [];
129
+ return parsed.filter((x): x is string => typeof x === "string" && x.length > 0);
130
+ }
131
+
132
+ /**
133
+ * Build a resolution map from the `tags` table. Returns a well-formed
134
+ * `ResolvedSchemas` even when no tag declares fields (empty `tagToFields`).
135
+ */
136
+ export function loadSchemaConfig(db: Database): ResolvedSchemas {
137
+ const allTags = new Set<string>();
138
+ const tagToFields = new Map<string, Record<string, SchemaField>>();
139
+ const tagToParents = new Map<string, string[]>();
140
+ const rows = db.prepare(
141
+ `SELECT name, fields, parent_names FROM tags`,
142
+ ).all() as { name: string; fields: string | null; parent_names: string | null }[];
143
+ for (const row of rows) {
144
+ if (!row.name) continue;
145
+ allTags.add(row.name);
146
+ const fields = parseFieldsJson(row.fields);
147
+ if (Object.keys(fields).length > 0) tagToFields.set(row.name, fields);
148
+ const parents = parseParentsJson(row.parent_names);
149
+ if (parents.length > 0) tagToParents.set(row.name, parents);
150
+ }
151
+ return { allTags, tagToFields, tagToParents };
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Resolution + validation
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Walk-order accumulator for a single note's effective ancestor set. DFS
160
+ * through `parent_names` in declaration order, cycle-protected via a visited
161
+ * Set. The output array preserves first-encounter order so the field-merge
162
+ * pass can apply first-wins precedence.
163
+ *
164
+ * Exported so other consumers (vault projection, future hierarchy
165
+ * inspectors) can reuse the exact walk semantics rather than carrying
166
+ * their own copy. Mutating walks (push to `out`, add to `visited`) keep
167
+ * the implementation cheap; callers pass fresh accumulators.
168
+ */
169
+ export function walkAncestors(
170
+ startTag: string,
171
+ resolved: ResolvedSchemas,
172
+ visited: Set<string>,
173
+ out: string[],
174
+ ): void {
175
+ if (visited.has(startTag)) return;
176
+ visited.add(startTag);
177
+ out.push(startTag);
178
+ const parents = resolved.tagToParents.get(startTag);
179
+ if (!parents) return;
180
+ for (const p of parents) {
181
+ walkAncestors(p, resolved, visited, out);
182
+ }
183
+ }
184
+
185
+ interface MergedField {
186
+ spec: SchemaField;
187
+ sourceTag: string;
188
+ }
189
+
190
+ interface NoteResolution {
191
+ /** Walk-order tag list whose `fields` contributed at least one entry. */
192
+ effectiveTags: string[];
193
+ /** Field name → winning spec + source tag. First-in-walk wins. */
194
+ mergedFields: Map<string, MergedField>;
195
+ /** Conflict warnings — same field declared by ≥2 ancestors with diverging specs. */
196
+ conflicts: ValidationWarning[];
197
+ }
198
+
199
+ /**
200
+ * Resolve the effective schema for a note. Walks each note tag through its
201
+ * `parent_names` chain (cycle-safe), implicitly appends `_default` when the
202
+ * tag exists, and merges all encountered `fields` declarations with
203
+ * first-in-walk precedence. A note with no tags still picks up `_default`'s
204
+ * schema when one is declared.
205
+ *
206
+ * Internal — exported for tests. The public entry point is `validateNote`.
207
+ */
208
+ export function resolveNoteSchemas(
209
+ resolved: ResolvedSchemas,
210
+ note: { tags?: string[] },
211
+ ): NoteResolution {
212
+ const visited = new Set<string>();
213
+ const order: string[] = [];
214
+
215
+ for (const tag of note.tags ?? []) {
216
+ walkAncestors(tag, resolved, visited, order);
217
+ }
218
+ if (resolved.allTags.has("_default")) {
219
+ walkAncestors("_default", resolved, visited, order);
220
+ }
221
+
222
+ const mergedFields = new Map<string, MergedField>();
223
+ const conflicts: ValidationWarning[] = [];
224
+
225
+ for (const tagName of order) {
226
+ const fields = resolved.tagToFields.get(tagName);
227
+ if (!fields) continue;
228
+ for (const [fieldName, spec] of Object.entries(fields)) {
229
+ const existing = mergedFields.get(fieldName);
230
+ if (!existing) {
231
+ mergedFields.set(fieldName, { spec, sourceTag: tagName });
232
+ continue;
233
+ }
234
+ if (fieldSpecsEqual(existing.spec, spec)) continue;
235
+ conflicts.push({
236
+ field: fieldName,
237
+ schema: existing.sourceTag,
238
+ loser_schema: tagName,
239
+ reason: "schema_conflict",
240
+ message: `field '${fieldName}' has conflicting specs in ancestor tags '${existing.sourceTag}' (kept) and '${tagName}' (ignored)`,
241
+ });
242
+ }
243
+ }
244
+
245
+ const contributing = new Set<string>();
246
+ for (const { sourceTag } of mergedFields.values()) contributing.add(sourceTag);
247
+ const effectiveTags = order.filter((t) => contributing.has(t));
248
+
249
+ return { effectiveTags, mergedFields, conflicts };
250
+ }
251
+
252
+ function fieldSpecsEqual(a: SchemaField, b: SchemaField): boolean {
253
+ if (a.type !== b.type) return false;
254
+ if (!stringArraysEqual(a.enum, b.enum)) return false;
255
+ return true;
256
+ }
257
+
258
+ function stringArraysEqual(a: string[] | undefined, b: string[] | undefined): boolean {
259
+ if (a === b) return true;
260
+ if (!a || !b) return false;
261
+ if (a.length !== b.length) return false;
262
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
263
+ return true;
264
+ }
265
+
266
+ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
267
+ if (type === undefined) return true;
268
+ switch (type) {
269
+ case "string":
270
+ return typeof value === "string";
271
+ case "number":
272
+ return typeof value === "number" && Number.isFinite(value);
273
+ case "boolean":
274
+ return typeof value === "boolean";
275
+ case "array":
276
+ return Array.isArray(value);
277
+ case "object":
278
+ return !!value && typeof value === "object" && !Array.isArray(value);
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Validate a note's metadata against the merged schema. Returns null when no
284
+ * ancestor declares any fields (so the caller can omit `validation_status`
285
+ * entirely). Otherwise returns the status with conflict warnings prepended,
286
+ * followed by per-field type/enum mismatches.
287
+ *
288
+ * Rules per merged field:
289
+ * - Present and `type` declared and value's type doesn't match → `type_mismatch`
290
+ * - Present and `enum` declared and value not in enum → `enum_mismatch`
291
+ *
292
+ * Fields not declared by any ancestor's schema are ignored entirely (this
293
+ * isn't a "strict" validator — it's a guide). There is no `required` concept
294
+ * (post-v17); declarations are advisory only.
295
+ */
296
+ export function validateNote(
297
+ resolved: ResolvedSchemas,
298
+ note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
299
+ ): ValidationStatus | null {
300
+ const resolution = resolveNoteSchemas(resolved, note);
301
+ if (resolution.mergedFields.size === 0) return null;
302
+
303
+ const m = note.metadata ?? {};
304
+ const warnings: ValidationWarning[] = [...resolution.conflicts];
305
+
306
+ for (const [fieldName, { spec, sourceTag }] of resolution.mergedFields) {
307
+ if (!(fieldName in m)) continue;
308
+ const value = m[fieldName];
309
+ if (value === undefined || value === null) continue;
310
+
311
+ if (spec.type && !valueMatchesType(value, spec.type)) {
312
+ warnings.push({
313
+ field: fieldName,
314
+ schema: sourceTag,
315
+ reason: "type_mismatch",
316
+ message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
317
+ });
318
+ }
319
+
320
+ if (spec.enum && spec.enum.length > 0 && typeof value === "string" && !spec.enum.includes(value)) {
321
+ warnings.push({
322
+ field: fieldName,
323
+ schema: sourceTag,
324
+ reason: "enum_mismatch",
325
+ message: `'${fieldName}' must be one of [${spec.enum.join(", ")}] (tag '${sourceTag}')`,
326
+ });
327
+ }
328
+ }
329
+
330
+ return { schemas: resolution.effectiveTags, warnings };
331
+ }