@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
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Schema validation: resolves which schemas apply to a note (by path prefix
3
+ * or tag), then validates the note's metadata against each. Writes are
4
+ * never blocked — schemas are guidance. The MCP/REST layer surfaces a
5
+ * `validation_status` block on create/update responses with any warnings
6
+ * the agent can act on (missing required, type mismatch, enum mismatch).
7
+ *
8
+ * Storage (post-v15): schemas live in the `note_schemas` table; mapping
9
+ * rules live in `schema_mappings`. Authoring is via `update-note-schema`
10
+ * + `set-schema-mapping` (MCP/REST). The legacy `_schemas/<name>` and
11
+ * `_schema_defaults` notes are retired but left in place — inert after
12
+ * v15 (no resolver reads them).
13
+ *
14
+ * Resolution model:
15
+ * - Lazy: rebuilt on first access, cached on the store.
16
+ * - Invalidated when `note_schemas` or `schema_mappings` are mutated
17
+ * (table writes, not note writes).
18
+ * - When no mappings exist and nothing else matches, validation is a
19
+ * no-op (status omitted).
20
+ */
21
+
22
+ import { Database } from "bun:sqlite";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Legacy path prefixes — kept exported for any historical caller that still
26
+ // references them. No resolver code reads notes-as-config post-v15.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export const SCHEMA_CONFIG_PREFIX = "_schemas/";
30
+ export const SCHEMA_DEFAULTS_PATH = "_schema_defaults";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface SchemaField {
37
+ type?: "string" | "number" | "boolean" | "array" | "object";
38
+ enum?: string[];
39
+ description?: string;
40
+ }
41
+
42
+ export interface SchemaDefinition {
43
+ name: string;
44
+ description?: string;
45
+ fields: Record<string, SchemaField>;
46
+ required: string[];
47
+ }
48
+
49
+ export interface SchemaDefaults {
50
+ /** Path prefix → schema name. Longest prefix wins on tie. */
51
+ pathPrefixes: Array<{ prefix: string; schema: string }>;
52
+ /** Tag → schema name. */
53
+ tagToSchema: Map<string, string>;
54
+ }
55
+
56
+ export interface ResolvedSchemas {
57
+ defaults: SchemaDefaults;
58
+ definitions: Map<string, SchemaDefinition>;
59
+ }
60
+
61
+ export interface ValidationWarning {
62
+ field: string;
63
+ schema: string;
64
+ /** "missing_required" | "type_mismatch" | "enum_mismatch" */
65
+ reason: "missing_required" | "type_mismatch" | "enum_mismatch";
66
+ message: string;
67
+ }
68
+
69
+ export interface ValidationStatus {
70
+ /** Schema names that matched the note (for transparency). */
71
+ schemas: string[];
72
+ /** Empty when all checks pass. */
73
+ warnings: ValidationWarning[];
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Loading
78
+ // ---------------------------------------------------------------------------
79
+
80
+ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
81
+ if (!raw) return {};
82
+ let parsed: unknown;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ } catch {
86
+ return {};
87
+ }
88
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
89
+ const fields: Record<string, SchemaField> = {};
90
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
91
+ if (!v || typeof v !== "object") continue;
92
+ const f = v as Record<string, unknown>;
93
+ const field: SchemaField = {};
94
+ if (typeof f.type === "string") field.type = f.type as SchemaField["type"];
95
+ if (Array.isArray(f.enum)) field.enum = f.enum.filter((x): x is string => typeof x === "string");
96
+ if (typeof f.description === "string") field.description = f.description;
97
+ fields[k] = field;
98
+ }
99
+ return fields;
100
+ }
101
+
102
+ function parseRequiredJson(raw: string | null): string[] {
103
+ if (!raw) return [];
104
+ try {
105
+ const parsed = JSON.parse(raw);
106
+ if (!Array.isArray(parsed)) return [];
107
+ return parsed.filter((x): x is string => typeof x === "string");
108
+ } catch {
109
+ return [];
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Build the full resolution map from the `note_schemas` and `schema_mappings`
115
+ * tables. Always returns a well-formed `ResolvedSchemas` even when the
116
+ * tables are empty (empty maps).
117
+ */
118
+ export function loadSchemaConfig(db: Database): ResolvedSchemas {
119
+ const definitions = new Map<string, SchemaDefinition>();
120
+ const defRows = db.prepare(
121
+ `SELECT name, description, fields, required FROM note_schemas`,
122
+ ).all() as { name: string; description: string | null; fields: string | null; required: string | null }[];
123
+ for (const row of defRows) {
124
+ if (!row.name) continue;
125
+ definitions.set(row.name, {
126
+ name: row.name,
127
+ description: row.description ?? undefined,
128
+ fields: parseFieldsJson(row.fields),
129
+ required: parseRequiredJson(row.required),
130
+ });
131
+ }
132
+
133
+ const defaults: SchemaDefaults = {
134
+ pathPrefixes: [],
135
+ tagToSchema: new Map(),
136
+ };
137
+ const mappingRows = db.prepare(
138
+ `SELECT schema_name, match_kind, match_value FROM schema_mappings`,
139
+ ).all() as { schema_name: string; match_kind: string; match_value: string }[];
140
+ for (const row of mappingRows) {
141
+ if (row.match_kind === "path_prefix") {
142
+ defaults.pathPrefixes.push({ prefix: row.match_value, schema: row.schema_name });
143
+ } else if (row.match_kind === "tag") {
144
+ defaults.tagToSchema.set(row.match_value, row.schema_name);
145
+ }
146
+ }
147
+ // Longest prefix wins — sort once at load so resolve is O(n) without re-sorts.
148
+ defaults.pathPrefixes.sort((a, b) => b.prefix.length - a.prefix.length);
149
+
150
+ return { defaults, definitions };
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Resolution + validation
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Find the schemas that apply to a note based on its path and tags. Returns
159
+ * schema *names* in the order they were resolved (path-prefix first, then
160
+ * each matching tag in declaration order). Names that don't have a row in
161
+ * `note_schemas` are dropped.
162
+ */
163
+ export function resolveApplicableSchemas(
164
+ resolved: ResolvedSchemas,
165
+ note: { path?: string | null; tags?: string[] },
166
+ ): string[] {
167
+ const names: string[] = [];
168
+ const seen = new Set<string>();
169
+
170
+ if (note.path) {
171
+ for (const { prefix, schema } of resolved.defaults.pathPrefixes) {
172
+ if (note.path.startsWith(prefix)) {
173
+ if (!seen.has(schema) && resolved.definitions.has(schema)) {
174
+ names.push(schema);
175
+ seen.add(schema);
176
+ }
177
+ break; // longest match wins (sorted at load)
178
+ }
179
+ }
180
+ }
181
+
182
+ if (note.tags) {
183
+ for (const tag of note.tags) {
184
+ const schema = resolved.defaults.tagToSchema.get(tag);
185
+ if (schema && !seen.has(schema) && resolved.definitions.has(schema)) {
186
+ names.push(schema);
187
+ seen.add(schema);
188
+ }
189
+ }
190
+ }
191
+
192
+ return names;
193
+ }
194
+
195
+ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
196
+ if (type === undefined) return true;
197
+ switch (type) {
198
+ case "string":
199
+ return typeof value === "string";
200
+ case "number":
201
+ return typeof value === "number" && Number.isFinite(value);
202
+ case "boolean":
203
+ return typeof value === "boolean";
204
+ case "array":
205
+ return Array.isArray(value);
206
+ case "object":
207
+ return !!value && typeof value === "object" && !Array.isArray(value);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Validate a note's metadata against each applicable schema and collect
213
+ * warnings. Validation is non-blocking — the caller decides what to do with
214
+ * the warnings (currently: surface them on the create/update response).
215
+ *
216
+ * Rules per field:
217
+ * - In `required` and absent → `missing_required`
218
+ * - Present and `type` declared and value's type doesn't match → `type_mismatch`
219
+ * - Present and `enum` declared and value not in enum → `enum_mismatch`
220
+ *
221
+ * Fields not declared in the schema are ignored entirely (this isn't a
222
+ * "strict" validator — it's a guide).
223
+ */
224
+ export function validateMetadata(
225
+ resolved: ResolvedSchemas,
226
+ schemaNames: string[],
227
+ metadata: Record<string, unknown> | undefined,
228
+ ): ValidationStatus {
229
+ const warnings: ValidationWarning[] = [];
230
+ const m = metadata ?? {};
231
+
232
+ for (const name of schemaNames) {
233
+ const def = resolved.definitions.get(name);
234
+ if (!def) continue;
235
+
236
+ for (const requiredField of def.required) {
237
+ if (!(requiredField in m) || m[requiredField] === undefined || m[requiredField] === null) {
238
+ warnings.push({
239
+ field: requiredField,
240
+ schema: name,
241
+ reason: "missing_required",
242
+ message: `'${requiredField}' is required by schema '${name}'`,
243
+ });
244
+ }
245
+ }
246
+
247
+ for (const [fieldName, field] of Object.entries(def.fields)) {
248
+ if (!(fieldName in m)) continue;
249
+ const value = m[fieldName];
250
+ if (value === undefined || value === null) continue;
251
+
252
+ if (field.type && !valueMatchesType(value, field.type)) {
253
+ warnings.push({
254
+ field: fieldName,
255
+ schema: name,
256
+ reason: "type_mismatch",
257
+ message: `'${fieldName}' should be ${field.type} (schema '${name}')`,
258
+ });
259
+ }
260
+
261
+ if (field.enum && field.enum.length > 0 && typeof value === "string" && !field.enum.includes(value)) {
262
+ warnings.push({
263
+ field: fieldName,
264
+ schema: name,
265
+ reason: "enum_mismatch",
266
+ message: `'${fieldName}' must be one of [${field.enum.join(", ")}] (schema '${name}')`,
267
+ });
268
+ }
269
+ }
270
+ }
271
+
272
+ return { schemas: schemaNames, warnings };
273
+ }
274
+
275
+ /**
276
+ * Convenience: combine resolve + validate for a note. Returns null when no
277
+ * schemas apply (so the caller can decide whether to omit the field on the
278
+ * response or surface an empty status).
279
+ */
280
+ export function validateNote(
281
+ resolved: ResolvedSchemas,
282
+ note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
283
+ ): ValidationStatus | null {
284
+ const names = resolveApplicableSchemas(resolved, note);
285
+ if (names.length === 0) return null;
286
+ return validateMetadata(resolved, names, note.metadata);
287
+ }