@openparachute/vault 0.4.8 → 0.4.9-rc.11
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.
- package/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/core/src/store.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { initSchema } from "./schema.js";
|
|
|
4
4
|
import * as noteOps from "./notes.js";
|
|
5
5
|
import * as linkOps from "./links.js";
|
|
6
6
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
7
|
+
import * as indexedFieldOps from "./indexed-fields.js";
|
|
8
|
+
import {
|
|
9
|
+
pruneOrphanedIndexedFields,
|
|
10
|
+
reconcileDeclaredIndexes,
|
|
11
|
+
type PrunedField,
|
|
12
|
+
} from "./indexed-fields.js";
|
|
7
13
|
import { syncWikilinks, resolveUnresolvedWikilinks } from "./wikilinks.js";
|
|
8
14
|
import { pathTitle } from "./paths.js";
|
|
9
15
|
import { HookRegistry } from "./hooks.js";
|
|
@@ -217,10 +223,23 @@ export class BunSqliteStore implements Store {
|
|
|
217
223
|
}
|
|
218
224
|
|
|
219
225
|
async deleteNote(id: string): Promise<void> {
|
|
220
|
-
// Read before delete so we can invalidate config caches on the way out
|
|
226
|
+
// Read before delete so we can invalidate config caches on the way out
|
|
227
|
+
// AND so the post-delete hook dispatch carries the minimum payload
|
|
228
|
+
// ({ id, path }). The full note can't be reconstructed post-delete —
|
|
229
|
+
// by design, hooks subscribing to "deleted" receive a DeletedNoteRef,
|
|
230
|
+
// not a Note.
|
|
221
231
|
const existing = noteOps.getNote(this.db, id);
|
|
222
232
|
noteOps.deleteNote(this.db, id);
|
|
223
233
|
if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
|
|
234
|
+
// Dispatch even when `existing` was null — the caller asked for a
|
|
235
|
+
// deletion, and downstream consumers (e.g. the mirror) reconcile via
|
|
236
|
+
// id. Path is undefined in that case; the mirror sweep will catch
|
|
237
|
+
// any orphans missed by the targeted-removal fast path.
|
|
238
|
+
this.hooks.dispatch(
|
|
239
|
+
"deleted",
|
|
240
|
+
{ id, ...(existing?.path ? { path: existing.path } : {}) },
|
|
241
|
+
this,
|
|
242
|
+
);
|
|
224
243
|
}
|
|
225
244
|
|
|
226
245
|
async queryNotes(opts: QueryOpts): Promise<Note[]> {
|
|
@@ -340,6 +359,10 @@ export class BunSqliteStore implements Store {
|
|
|
340
359
|
// and may have declared `fields` powering schema validation.
|
|
341
360
|
this._tagHierarchy = null;
|
|
342
361
|
this._schemaConfig = null;
|
|
362
|
+
// Fire "deleted" only when SOMETHING happened (the underlying
|
|
363
|
+
// deleteTag returns `deleted: false` when the tag didn't exist).
|
|
364
|
+
// The git-mirror reacts to this by sweeping the schema sidecar.
|
|
365
|
+
if (result.deleted) this.hooks.dispatchTag("deleted", name, this);
|
|
343
366
|
return result;
|
|
344
367
|
}
|
|
345
368
|
|
|
@@ -352,6 +375,16 @@ export class BunSqliteStore implements Store {
|
|
|
352
375
|
// the schema-config by parent_names + fields content.
|
|
353
376
|
this._tagHierarchy = null;
|
|
354
377
|
this._schemaConfig = null;
|
|
378
|
+
// Rename = delete-then-upsert from the perspective of any consumer
|
|
379
|
+
// that keys schema artifacts on the tag name (e.g. the git-mirror's
|
|
380
|
+
// `.parachute/schemas/<tag>.yaml` sidecar file). Fire both events
|
|
381
|
+
// so the consumer drops the old artifact and writes the new one.
|
|
382
|
+
// Only dispatch when the rename actually happened — error returns
|
|
383
|
+
// ({ error: ... }) shouldn't notify subscribers about phantom moves.
|
|
384
|
+
if ("renamed" in result) {
|
|
385
|
+
this.hooks.dispatchTag("deleted", oldName, this);
|
|
386
|
+
this.hooks.dispatchTag("upserted", newName, this);
|
|
387
|
+
}
|
|
355
388
|
return result;
|
|
356
389
|
}
|
|
357
390
|
|
|
@@ -365,6 +398,15 @@ export class BunSqliteStore implements Store {
|
|
|
365
398
|
// bust the schema cache — `fields` declarations follow tag identity.
|
|
366
399
|
this._tagHierarchy = null;
|
|
367
400
|
this._schemaConfig = null;
|
|
401
|
+
// Each merged source vanishes from the tag set; the target's
|
|
402
|
+
// schema may have absorbed fields/relationships from the sources.
|
|
403
|
+
// Fire "deleted" for each source and "upserted" for the target so
|
|
404
|
+
// the mirror sweeps the source sidecars and rewrites the target.
|
|
405
|
+
for (const source of sources) {
|
|
406
|
+
if (source === target) continue;
|
|
407
|
+
this.hooks.dispatchTag("deleted", source, this);
|
|
408
|
+
}
|
|
409
|
+
this.hooks.dispatchTag("upserted", target, this);
|
|
368
410
|
return result;
|
|
369
411
|
}
|
|
370
412
|
|
|
@@ -440,12 +482,26 @@ export class BunSqliteStore implements Store {
|
|
|
440
482
|
// `fields` drives validation — bust the schema cache so the next
|
|
441
483
|
// create/update sees the new declarations.
|
|
442
484
|
this._schemaConfig = null;
|
|
485
|
+
// The tag schema sidecar in the mirror needs to track this. Fire
|
|
486
|
+
// "upserted" regardless of whether the row was created or modified
|
|
487
|
+
// — the mirror writes the sidecar fresh either way.
|
|
488
|
+
this.hooks.dispatchTag("upserted", tag, this);
|
|
443
489
|
return result;
|
|
444
490
|
}
|
|
445
491
|
|
|
446
492
|
async deleteTagSchema(tag: string) {
|
|
447
493
|
const result = tagSchemaOps.deleteTagSchema(this.db, tag);
|
|
448
|
-
if (result)
|
|
494
|
+
if (result) {
|
|
495
|
+
this._schemaConfig = null;
|
|
496
|
+
// Schema-only delete: the tag may still exist as a name in the
|
|
497
|
+
// hierarchy, but the sidecar lost its content. Mirror reacts by
|
|
498
|
+
// sweeping the sidecar file. (If the underlying row was reduced
|
|
499
|
+
// to a bare name with no schema content, hasSchemaContent() in
|
|
500
|
+
// exportVaultToDir already wouldn't have written it on the next
|
|
501
|
+
// export pass — the targeted delete is the fast path; the sweep
|
|
502
|
+
// is the safety net.)
|
|
503
|
+
this.hooks.dispatchTag("deleted", tag, this);
|
|
504
|
+
}
|
|
449
505
|
return result;
|
|
450
506
|
}
|
|
451
507
|
|
|
@@ -453,6 +509,37 @@ export class BunSqliteStore implements Store {
|
|
|
453
509
|
return tagSchemaOps.getTagSchemaMap(this.db);
|
|
454
510
|
}
|
|
455
511
|
|
|
512
|
+
// ---- Indexed-field lifecycle (generated columns + indexes) ----
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Prune orphaned `indexed_fields` declarers — declarer tags that no longer
|
|
516
|
+
* have a `tags` row. Fields with no surviving live declarer are dropped
|
|
517
|
+
* wholesale (row + generated column + index); co-declared fields keep their
|
|
518
|
+
* column and just lose the dead declarers. `dryRun` (default true) returns
|
|
519
|
+
* the plan without mutating. See the gitcoin orphaned-fields bug.
|
|
520
|
+
*/
|
|
521
|
+
async pruneIndexedFields(opts?: { dryRun?: boolean }): Promise<PrunedField[]> {
|
|
522
|
+
const plan = pruneOrphanedIndexedFields(this.db, opts);
|
|
523
|
+
// A drop changes the queryable-field catalog vault-info advertises; bust
|
|
524
|
+
// the schema cache so the next read reflects it.
|
|
525
|
+
if (opts?.dryRun === false && plan.length > 0) this._schemaConfig = null;
|
|
526
|
+
return plan;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Replay `declareField` for every `indexed: true` field across all current
|
|
531
|
+
* tag records, materializing the generated columns + indexes. Idempotent —
|
|
532
|
+
* used by the portable-md import path so a fresh import ends with the same
|
|
533
|
+
* backing columns a live vault would have. Returns the count of (tag, field)
|
|
534
|
+
* declarations replayed.
|
|
535
|
+
*/
|
|
536
|
+
async reconcileDeclaredIndexes(): Promise<number> {
|
|
537
|
+
const schemas = await this.listTagRecords();
|
|
538
|
+
const count = reconcileDeclaredIndexes(this.db, schemas);
|
|
539
|
+
if (count > 0) this._schemaConfig = null;
|
|
540
|
+
return count;
|
|
541
|
+
}
|
|
542
|
+
|
|
456
543
|
// ---- Tag Records (post-v14: full identity row) ----
|
|
457
544
|
|
|
458
545
|
async listTagRecords() {
|
|
@@ -467,6 +554,18 @@ export class BunSqliteStore implements Store {
|
|
|
467
554
|
* Partial upsert of the full tag record. Any patch field left undefined
|
|
468
555
|
* is preserved; pass null to clear. Invalidates the tag-hierarchy cache
|
|
469
556
|
* when `parent_names` is touched.
|
|
557
|
+
*
|
|
558
|
+
* Indexed-field lifecycle is reconciled HERE — at the single store
|
|
559
|
+
* chokepoint every caller (MCP update-tag, REST PUT /tags/:name, import)
|
|
560
|
+
* funnels through — so no caller can persist a `fields` change without the
|
|
561
|
+
* matching declareField/releaseField. When `patch.fields` is touched
|
|
562
|
+
* (object or explicit `null`), the prior-vs-next indexed-field set is
|
|
563
|
+
* diffed: added indexed fields get `declareField`, removed ones get
|
|
564
|
+
* `releaseField` (which drops the generated column + index only when this
|
|
565
|
+
* tag is the last live declarer — the co-declaration guard). `patch.fields
|
|
566
|
+
* === undefined` (no-touch) skips reconciliation entirely. Centralizing
|
|
567
|
+
* here is the same discipline as moving delete-release into noteOps.deleteTag
|
|
568
|
+
* — it closes the REST PUT orphaned-column leak. See the gitcoin bug.
|
|
470
569
|
*/
|
|
471
570
|
async upsertTagRecord(
|
|
472
571
|
tag: string,
|
|
@@ -477,7 +576,43 @@ export class BunSqliteStore implements Store {
|
|
|
477
576
|
parent_names?: string[] | null;
|
|
478
577
|
},
|
|
479
578
|
) {
|
|
579
|
+
// Snapshot the prior indexed-field set BEFORE the write so the diff below
|
|
580
|
+
// sees what this tag declared going in. Only needed when `fields` changes.
|
|
581
|
+
const priorRecord =
|
|
582
|
+
patch.fields !== undefined ? tagSchemaOps.getTagRecord(this.db, tag) : null;
|
|
583
|
+
|
|
480
584
|
const result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
|
|
585
|
+
|
|
586
|
+
if (patch.fields !== undefined) {
|
|
587
|
+
const indexedSet = (fields: Record<string, tagSchemaOps.TagFieldSchema> | null | undefined) =>
|
|
588
|
+
new Set(
|
|
589
|
+
Object.entries(fields ?? {})
|
|
590
|
+
.filter(([, v]) => v.indexed === true)
|
|
591
|
+
.map(([k]) => k),
|
|
592
|
+
);
|
|
593
|
+
const nextFields = patch.fields; // object | null
|
|
594
|
+
const priorIndexed = indexedSet(priorRecord?.fields);
|
|
595
|
+
const nextIndexed = indexedSet(nextFields);
|
|
596
|
+
for (const fieldName of nextIndexed) {
|
|
597
|
+
const spec = nextFields![fieldName]!;
|
|
598
|
+
const mapped = indexedFieldOps.mapFieldType(spec.type);
|
|
599
|
+
// Unmappable type for indexing is a caller error; surface it rather
|
|
600
|
+
// than silently skipping. MCP/REST validate up-front for a cleaner
|
|
601
|
+
// message, but this is the backstop at the chokepoint.
|
|
602
|
+
if (!mapped) {
|
|
603
|
+
throw new indexedFieldOps.IndexedFieldError(
|
|
604
|
+
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
|
|
608
|
+
}
|
|
609
|
+
for (const fieldName of priorIndexed) {
|
|
610
|
+
if (!nextIndexed.has(fieldName)) {
|
|
611
|
+
indexedFieldOps.releaseField(this.db, fieldName, tag);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
481
616
|
if (patch.parent_names !== undefined) {
|
|
482
617
|
// parent_names drives both query expansion (tag hierarchy) AND, post
|
|
483
618
|
// vault#270, schema inheritance — bust both caches.
|
|
@@ -494,6 +629,11 @@ export class BunSqliteStore implements Store {
|
|
|
494
629
|
this._tagHierarchy = null;
|
|
495
630
|
this._schemaConfig = null;
|
|
496
631
|
}
|
|
632
|
+
// Tag-mutation event for the git-mirror and any other downstream
|
|
633
|
+
// consumer. Fire "upserted" on every successful tag-record write —
|
|
634
|
+
// schema/relationship/parent-name mutations all alter the sidecar
|
|
635
|
+
// contents the mirror persists.
|
|
636
|
+
this.hooks.dispatchTag("upserted", tag, this);
|
|
497
637
|
return result;
|
|
498
638
|
}
|
|
499
639
|
|
|
@@ -599,6 +739,17 @@ export class BunSqliteStore implements Store {
|
|
|
599
739
|
const other = this.db.prepare(
|
|
600
740
|
"SELECT 1 FROM attachments WHERE path = ? LIMIT 1",
|
|
601
741
|
).get(row.path);
|
|
742
|
+
|
|
743
|
+
// Post-delete event for downstream consumers (e.g. the git-mirror's
|
|
744
|
+
// sweep of `.parachute/attachments/<id>/...`). Payload is the
|
|
745
|
+
// DeletedAttachmentRef — the row is gone, so we pass only id /
|
|
746
|
+
// note_id / path.
|
|
747
|
+
this.hooks.dispatchAttachment(
|
|
748
|
+
"deleted",
|
|
749
|
+
{ id: attachmentId, noteId, path: row.path },
|
|
750
|
+
this,
|
|
751
|
+
);
|
|
752
|
+
|
|
602
753
|
return { deleted: true, path: row.path, orphaned: !other };
|
|
603
754
|
}
|
|
604
755
|
|
|
@@ -621,6 +772,38 @@ export class BunSqliteStore implements Store {
|
|
|
621
772
|
};
|
|
622
773
|
}
|
|
623
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Reverse-lookup: every attachment row whose `path` column equals the given
|
|
777
|
+
* vault-internal relative path (`<date>/<filename>`). A single on-disk asset
|
|
778
|
+
* can be referenced by more than one attachment row (the orphan check in
|
|
779
|
+
* `deleteAttachment` accounts for that), so this returns an array. Used by
|
|
780
|
+
* the raw `/api/storage/<date>/<file>` byte-serve path to map a requested
|
|
781
|
+
* file back to its owning note(s) for tag-scope enforcement — without this,
|
|
782
|
+
* a tag-scoped token could fetch an out-of-scope note's attachment bytes
|
|
783
|
+
* directly by path (the path-secrecy-only bypass; see the C0 adversarial
|
|
784
|
+
* audit finding).
|
|
785
|
+
*/
|
|
786
|
+
async getAttachmentsByPath(path: string): Promise<Attachment[]> {
|
|
787
|
+
const rows = this.db.prepare(
|
|
788
|
+
"SELECT * FROM attachments WHERE path = ? ORDER BY created_at",
|
|
789
|
+
).all(path) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string }[];
|
|
790
|
+
|
|
791
|
+
return rows.map((r) => {
|
|
792
|
+
let metadata: Record<string, unknown> | undefined;
|
|
793
|
+
if (r.metadata && r.metadata !== "{}") {
|
|
794
|
+
try { metadata = JSON.parse(r.metadata); } catch {}
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
id: r.id,
|
|
798
|
+
noteId: r.note_id,
|
|
799
|
+
path: r.path,
|
|
800
|
+
mimeType: r.mime_type,
|
|
801
|
+
metadata,
|
|
802
|
+
createdAt: r.created_at,
|
|
803
|
+
};
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
624
807
|
/**
|
|
625
808
|
* Replace the attachment's metadata JSON blob. The caller passes the full
|
|
626
809
|
* merged object — this is a set, not a patch, so partial-field updates
|
package/core/src/types.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
|
|
2
|
+
import type { PrunedField } from "./indexed-fields.js";
|
|
2
3
|
|
|
3
4
|
// ---- Re-exports ----
|
|
4
5
|
|
|
5
6
|
export type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
|
|
7
|
+
export type { PrunedField } from "./indexed-fields.js";
|
|
6
8
|
|
|
7
9
|
// ---- Note ----
|
|
8
10
|
|
|
@@ -278,6 +280,24 @@ export interface Store {
|
|
|
278
280
|
deleteTagSchema(tag: string): Promise<boolean>;
|
|
279
281
|
getTagSchemaMap(): Promise<Record<string, { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> }>>;
|
|
280
282
|
|
|
283
|
+
// Indexed-field lifecycle — generated columns + indexes on `notes` derived
|
|
284
|
+
// from tag-declared `indexed: true` fields. See core/src/indexed-fields.ts.
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Prune orphaned `indexed_fields` declarers — declarer tags with no `tags`
|
|
288
|
+
* row. Fields left with no live declarer are dropped wholesale; co-declared
|
|
289
|
+
* fields keep their column and lose only the dead declarers. `dryRun`
|
|
290
|
+
* (default true) returns the plan without mutating.
|
|
291
|
+
*/
|
|
292
|
+
pruneIndexedFields(opts?: { dryRun?: boolean }): Promise<PrunedField[]>;
|
|
293
|
+
/**
|
|
294
|
+
* Replay `declareField` for every `indexed: true` field across all current
|
|
295
|
+
* tag records, materializing the backing columns + indexes. Idempotent —
|
|
296
|
+
* used by the import path so a fresh import has the same columns a live
|
|
297
|
+
* vault would. Returns the count of (tag, field) declarations replayed.
|
|
298
|
+
*/
|
|
299
|
+
reconcileDeclaredIndexes(): Promise<number>;
|
|
300
|
+
|
|
281
301
|
// Tag records — full v14 identity row (description + fields + typed
|
|
282
302
|
// relationships + parent_names + timestamps). See
|
|
283
303
|
// parachute-patterns/patterns/tag-data-model.md.
|
|
@@ -322,6 +342,14 @@ export interface Store {
|
|
|
322
342
|
addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Promise<Attachment>;
|
|
323
343
|
getAttachments(noteId: string): Promise<Attachment[]>;
|
|
324
344
|
getAttachment(attachmentId: string): Promise<Attachment | null>;
|
|
345
|
+
/**
|
|
346
|
+
* Reverse-lookup attachment rows by their vault-internal relative `path`
|
|
347
|
+
* (`<date>/<filename>`). Returns every row sharing that path (a single
|
|
348
|
+
* on-disk asset can be referenced by >1 row). Used by the raw
|
|
349
|
+
* `/api/storage/<date>/<file>` serve path to map a requested file back to
|
|
350
|
+
* its owning note(s) for tag-scope enforcement.
|
|
351
|
+
*/
|
|
352
|
+
getAttachmentsByPath(path: string): Promise<Attachment[]>;
|
|
325
353
|
setAttachmentMetadata(attachmentId: string, metadata: Record<string, unknown>): Promise<void>;
|
|
326
354
|
deleteAttachment(noteId: string, attachmentId: string): Promise<{ deleted: boolean; path: string | null; orphaned: boolean }>;
|
|
327
355
|
listAttachmentsByTranscribeStatus(status: "pending" | "failed" | "done", limit?: number): Promise<Attachment[]>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9-rc.11",
|
|
4
4
|
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
5
|
"module": "src/cli.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
30
|
-
"@openparachute/scope-guard": "^0.
|
|
30
|
+
"@openparachute/scope-guard": "^0.4.0-rc.2",
|
|
31
31
|
"jose": "^6.2.2",
|
|
32
32
|
"otpauth": "^9.5.0",
|
|
33
33
|
"qrcode-terminal": "^0.12.0"
|
package/src/auth-hub-jwt.test.ts
CHANGED
|
@@ -105,6 +105,13 @@ interface SignOpts {
|
|
|
105
105
|
* a hub-minted admin token; provide `["<name>"]` for a non-admin user.
|
|
106
106
|
*/
|
|
107
107
|
vaultScope?: string[];
|
|
108
|
+
/**
|
|
109
|
+
* `permissions` claim (auth-unification arc, C0). Undefined → omit
|
|
110
|
+
* entirely (today's hub-JWT shape → unscoped). Provide
|
|
111
|
+
* `{ scoped_tags: [...] }` for a tag-scoped token, or a deliberately
|
|
112
|
+
* malformed value to exercise the fail-closed path.
|
|
113
|
+
*/
|
|
114
|
+
permissions?: unknown;
|
|
108
115
|
}
|
|
109
116
|
|
|
110
117
|
async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
|
|
@@ -115,6 +122,7 @@ async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
|
|
|
115
122
|
client_id: "test-client",
|
|
116
123
|
};
|
|
117
124
|
if (opts.vaultScope !== undefined) payload.vault_scope = opts.vaultScope;
|
|
125
|
+
if (opts.permissions !== undefined) payload.permissions = opts.permissions;
|
|
118
126
|
return await new SignJWT(payload)
|
|
119
127
|
.setProtectedHeader({ alg: "RS256", kid: kp.kid })
|
|
120
128
|
.setIssuer(opts.iss)
|
|
@@ -518,3 +526,142 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
|
|
|
518
526
|
}
|
|
519
527
|
});
|
|
520
528
|
});
|
|
529
|
+
|
|
530
|
+
describe("authenticateVaultRequest — hub JWT tag-scoping (auth-unification C0)", () => {
|
|
531
|
+
// Helper: pull a successful AuthResult out of authenticateVaultRequest,
|
|
532
|
+
// failing the test loudly if auth returned an error Response.
|
|
533
|
+
async function authOk(
|
|
534
|
+
token: string,
|
|
535
|
+
vaultName: string,
|
|
536
|
+
): Promise<import("./auth.ts").AuthResult> {
|
|
537
|
+
const config = readVaultConfig(vaultName)!;
|
|
538
|
+
const store = getVaultStore(vaultName);
|
|
539
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
540
|
+
if ("error" in result) {
|
|
541
|
+
const body = await result.error.json();
|
|
542
|
+
throw new Error(`expected AuthResult, got ${result.error.status}: ${JSON.stringify(body)}`);
|
|
543
|
+
}
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
test("permissions.scoped_tags:[health] → AuthResult.scoped_tags=[health]; query-notes enforces (health visible, work hidden)", async () => {
|
|
548
|
+
seedVault("journal");
|
|
549
|
+
const store = getVaultStore("journal");
|
|
550
|
+
// Seed two notes: one tagged health, one tagged work.
|
|
551
|
+
await store.createNote("blood pressure log", { path: "h1", tags: ["health"] });
|
|
552
|
+
await store.createNote("quarterly OKRs", { path: "w1", tags: ["work"] });
|
|
553
|
+
|
|
554
|
+
const token = await signJwt(kp, {
|
|
555
|
+
iss: fixture.origin,
|
|
556
|
+
aud: "vault.journal",
|
|
557
|
+
scope: "vault:journal:read",
|
|
558
|
+
permissions: { scoped_tags: ["health"] },
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const auth = await authOk(token, "journal");
|
|
562
|
+
// The READ side: the allowlist is lifted off the validated token.
|
|
563
|
+
expect(auth.scoped_tags).toEqual(["health"]);
|
|
564
|
+
|
|
565
|
+
// End-to-end enforcement: the tag-scope-wrapped query-notes tool must
|
|
566
|
+
// hide the `work` note and surface the `health` note.
|
|
567
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
568
|
+
const tools = generateScopedMcpTools("journal", auth);
|
|
569
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
570
|
+
const result = (await query.execute({})) as any;
|
|
571
|
+
const notes = Array.isArray(result) ? result : result.notes;
|
|
572
|
+
const paths = notes.map((n: any) => n.path).sort();
|
|
573
|
+
expect(paths).toEqual(["h1"]);
|
|
574
|
+
expect(paths).not.toContain("w1");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("no permissions claim → scoped_tags=null (unscoped, full vault — regression: unchanged)", async () => {
|
|
578
|
+
seedVault("journal");
|
|
579
|
+
const store = getVaultStore("journal");
|
|
580
|
+
await store.createNote("a", { path: "h1", tags: ["health"] });
|
|
581
|
+
await store.createNote("b", { path: "w1", tags: ["work"] });
|
|
582
|
+
|
|
583
|
+
const token = await signJwt(kp, {
|
|
584
|
+
iss: fixture.origin,
|
|
585
|
+
aud: "vault.journal",
|
|
586
|
+
scope: "vault:journal:read",
|
|
587
|
+
// permissions intentionally omitted — today's hub-JWT shape
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const auth = await authOk(token, "journal");
|
|
591
|
+
expect(auth.scoped_tags).toBeNull();
|
|
592
|
+
|
|
593
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
594
|
+
const tools = generateScopedMcpTools("journal", auth);
|
|
595
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
596
|
+
const result = (await query.execute({})) as any;
|
|
597
|
+
const notes = Array.isArray(result) ? result : result.notes;
|
|
598
|
+
const paths = notes.map((n: any) => n.path).sort();
|
|
599
|
+
// Unscoped: BOTH notes visible.
|
|
600
|
+
expect(paths).toEqual(["h1", "w1"]);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("permissions present but scoped_tags absent → scoped_tags=null (unscoped)", async () => {
|
|
604
|
+
seedVault("journal");
|
|
605
|
+
const token = await signJwt(kp, {
|
|
606
|
+
iss: fixture.origin,
|
|
607
|
+
aud: "vault.journal",
|
|
608
|
+
scope: "vault:journal:read",
|
|
609
|
+
permissions: { some_other_perm: true },
|
|
610
|
+
});
|
|
611
|
+
const auth = await authOk(token, "journal");
|
|
612
|
+
expect(auth.scoped_tags).toBeNull();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("permissions.scoped_tags:null → scoped_tags=null (explicit unscoped)", async () => {
|
|
616
|
+
seedVault("journal");
|
|
617
|
+
const token = await signJwt(kp, {
|
|
618
|
+
iss: fixture.origin,
|
|
619
|
+
aud: "vault.journal",
|
|
620
|
+
scope: "vault:journal:read",
|
|
621
|
+
permissions: { scoped_tags: null },
|
|
622
|
+
});
|
|
623
|
+
const auth = await authOk(token, "journal");
|
|
624
|
+
expect(auth.scoped_tags).toBeNull();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// ---- Fail-closed cases: present-but-malformed scoped_tags MUST 401, ----
|
|
628
|
+
// ---- never silently widen to full-vault. ----
|
|
629
|
+
for (const [label, badValue] of [
|
|
630
|
+
["a string", "health"],
|
|
631
|
+
["a number", 42],
|
|
632
|
+
["an object", { health: true }],
|
|
633
|
+
["an array with a non-string", ["health", 5]],
|
|
634
|
+
["an array with an empty string", ["health", ""]],
|
|
635
|
+
["an empty array", []],
|
|
636
|
+
] as Array<[string, unknown]>) {
|
|
637
|
+
test(`malformed scoped_tags (${label}) → 401 fail-closed (does NOT widen to full vault)`, async () => {
|
|
638
|
+
seedVault("journal");
|
|
639
|
+
const token = await signJwt(kp, {
|
|
640
|
+
iss: fixture.origin,
|
|
641
|
+
aud: "vault.journal",
|
|
642
|
+
scope: "vault:journal:read",
|
|
643
|
+
permissions: { scoped_tags: badValue },
|
|
644
|
+
});
|
|
645
|
+
const config = readVaultConfig("journal")!;
|
|
646
|
+
const store = getVaultStore("journal");
|
|
647
|
+
|
|
648
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
649
|
+
try {
|
|
650
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
651
|
+
// The whole request is rejected — NOT served with scoped_tags=null
|
|
652
|
+
// (full vault) or scoped_tags=[] (also full vault on the MCP path).
|
|
653
|
+
expect("error" in result).toBe(true);
|
|
654
|
+
if ("error" in result) {
|
|
655
|
+
expect(result.error.status).toBe(401);
|
|
656
|
+
const body = (await result.error.json()) as { error: string; message: string };
|
|
657
|
+
expect(body.error).toBe("Unauthorized");
|
|
658
|
+
expect(body.message).toContain("malformed tag-scope");
|
|
659
|
+
}
|
|
660
|
+
// Audit log carries the diagnostic.
|
|
661
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
662
|
+
} finally {
|
|
663
|
+
warnSpy.mockRestore();
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -91,6 +91,12 @@ function tryServerWideAuth(
|
|
|
91
91
|
legacyDerived: false,
|
|
92
92
|
scoped_tags: null,
|
|
93
93
|
vault_name: null,
|
|
94
|
+
// No stable session id for the env-var operator token — every request
|
|
95
|
+
// is the operator-bearer, not a minted session. manage-token's session
|
|
96
|
+
// pin is a no-op for this caller (it'd still mint, but list/revoke
|
|
97
|
+
// would see no other operator mints; that's fine — env-var-bearer is
|
|
98
|
+
// explicitly the operator-channel, not a user surface).
|
|
99
|
+
caller_jti: null,
|
|
94
100
|
};
|
|
95
101
|
}
|
|
96
102
|
|
|
@@ -120,6 +126,15 @@ export interface AuthResult {
|
|
|
120
126
|
* legacy / server-wide / hub JWT — no per-vault binding. See vault#257.
|
|
121
127
|
*/
|
|
122
128
|
vault_name: string | null;
|
|
129
|
+
/**
|
|
130
|
+
* Session identifier (v19). For `pvt_*` tokens this is the display id
|
|
131
|
+
* (`t_<hashprefix>`) of the presented token. For hub JWTs it's the
|
|
132
|
+
* `jti` claim, when present. NULL for legacy YAML keys / server-wide
|
|
133
|
+
* env-var tokens / hub JWTs without a `jti`. Used by the manage-token
|
|
134
|
+
* MCP tool to stamp child tokens with `parent_jti` so list/revoke can
|
|
135
|
+
* scope to this session's mints. See vault#376.
|
|
136
|
+
*/
|
|
137
|
+
caller_jti: string | null;
|
|
123
138
|
}
|
|
124
139
|
|
|
125
140
|
/**
|
|
@@ -134,6 +149,7 @@ function legacyAuthResult(permission: TokenPermission): AuthResult {
|
|
|
134
149
|
legacyDerived: true,
|
|
135
150
|
scoped_tags: null,
|
|
136
151
|
vault_name: null,
|
|
152
|
+
caller_jti: null,
|
|
137
153
|
};
|
|
138
154
|
}
|
|
139
155
|
|
|
@@ -285,6 +301,7 @@ export async function authenticateVaultRequest(
|
|
|
285
301
|
legacyDerived: resolved.legacyDerived,
|
|
286
302
|
scoped_tags: resolved.scoped_tags,
|
|
287
303
|
vault_name: resolved.vault_name,
|
|
304
|
+
caller_jti: resolved.jti,
|
|
288
305
|
};
|
|
289
306
|
}
|
|
290
307
|
} catch {
|
|
@@ -314,6 +331,75 @@ export async function authenticateVaultRequest(
|
|
|
314
331
|
return { error: Response.json({ error: "Unauthorized", message: "Invalid API key" }, { status: 401 }) };
|
|
315
332
|
}
|
|
316
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Sentinel thrown when a hub JWT carries a `permissions.scoped_tags` claim
|
|
336
|
+
* that is present but malformed (not an array of non-empty strings). See
|
|
337
|
+
* `parseScopedTagsFromPermissions` for why this MUST fail closed.
|
|
338
|
+
*/
|
|
339
|
+
class MalformedScopedTagsError extends Error {}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Map a validated hub JWT's `permissions` claim into the `scoped_tags`
|
|
343
|
+
* allowlist for `AuthResult` (auth-unification arc, C0 — the READ side).
|
|
344
|
+
*
|
|
345
|
+
* Wire contract (the shape the SPA + manage-token mint sides target):
|
|
346
|
+
*
|
|
347
|
+
* permissions: { scoped_tags: string[] } // root tag names
|
|
348
|
+
*
|
|
349
|
+
* Same semantics as the deprecated `pvt_*` `scoped_tags` DB column: each
|
|
350
|
+
* entry is a ROOT tag name; the token sees notes carrying that tag or a
|
|
351
|
+
* sub-tag thereof (hierarchy expansion happens in tag-scope.ts).
|
|
352
|
+
*
|
|
353
|
+
* Three outcomes, chosen for a strict FAIL-CLOSED invariant — tag-scoping is
|
|
354
|
+
* always a RESTRICTION, so a misread must NEVER widen access:
|
|
355
|
+
*
|
|
356
|
+
* 1. Claim absent (no `permissions`, or no `scoped_tags` key) → returns
|
|
357
|
+
* `null` = UNSCOPED = full vault. This is legitimate and is today's
|
|
358
|
+
* behavior for every hub JWT. Absence genuinely means "not tag-scoped".
|
|
359
|
+
*
|
|
360
|
+
* 2. `scoped_tags` is a non-empty array of non-empty strings → returns
|
|
361
|
+
* that array. The token is tag-scoped; the allowlist is enforced.
|
|
362
|
+
*
|
|
363
|
+
* 3. `scoped_tags` is present but MALFORMED — a string, a number, an
|
|
364
|
+
* object, an array containing a non-string / empty-string, or an empty
|
|
365
|
+
* array `[]` — throws `MalformedScopedTagsError`. The caller REJECTS
|
|
366
|
+
* the request (401). We do NOT coerce to `null` or `[]`:
|
|
367
|
+
*
|
|
368
|
+
* - Coercing to `null` would WIDEN a token that was MEANT to be scoped
|
|
369
|
+
* up to full-vault — a privilege leak. Forbidden.
|
|
370
|
+
* - Coercing to `[]` is NOT reliably fail-closed across enforcement
|
|
371
|
+
* paths. The MCP read-tool wrappers fast-path out on
|
|
372
|
+
* `scoped_tags.length === 0` (mcp-tools.ts ~L178) and the REST path
|
|
373
|
+
* collapses `[]` → null inside `expandTokenTagScope` (tag-scope.ts
|
|
374
|
+
* ~L35) — both treat `[]` as UNSCOPED = full vault. So `[]` would
|
|
375
|
+
* ALSO widen. (Note: `filterNotesByTagScope`/`noteWithinTagScope`
|
|
376
|
+
* would treat a raw `[]` as "matches nothing", but the call sites
|
|
377
|
+
* never reach them with `[]` because of the upstream short-circuits —
|
|
378
|
+
* the two enforcement paths disagree on `[]`, which is exactly why we
|
|
379
|
+
* refuse to manufacture it here.)
|
|
380
|
+
*
|
|
381
|
+
* The only correct fail-closed action for a present-but-unreadable
|
|
382
|
+
* scope is to reject the whole request — never serve it wide.
|
|
383
|
+
*/
|
|
384
|
+
function parseScopedTagsFromPermissions(
|
|
385
|
+
permissions: Record<string, unknown> | undefined,
|
|
386
|
+
): string[] | null {
|
|
387
|
+
if (!permissions || !("scoped_tags" in permissions)) return null;
|
|
388
|
+
const raw = permissions.scoped_tags;
|
|
389
|
+
if (raw === null || raw === undefined) return null; // explicit "unscoped"
|
|
390
|
+
if (
|
|
391
|
+
Array.isArray(raw) &&
|
|
392
|
+
raw.length > 0 &&
|
|
393
|
+
raw.every((t) => typeof t === "string" && t.length > 0)
|
|
394
|
+
) {
|
|
395
|
+
return raw as string[];
|
|
396
|
+
}
|
|
397
|
+
// Present but malformed (incl. `[]`): fail closed — never widen.
|
|
398
|
+
throw new MalformedScopedTagsError(
|
|
399
|
+
"hub JWT permissions.scoped_tags is present but not a non-empty array of tag names",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
317
403
|
/**
|
|
318
404
|
* Validate a JWT-shaped bearer and convert the result into an `AuthResult`.
|
|
319
405
|
* The token's scope claim becomes the granted scopes; permission is derived
|
|
@@ -336,6 +422,12 @@ export async function authenticateVaultRequest(
|
|
|
336
422
|
* unchanged. The 403 (not 401) signals "your credential is valid but
|
|
337
423
|
* doesn't grant access to this vault" — distinct from authentication
|
|
338
424
|
* failures upstream. See hub#283 + scope-guard 0.3.0 for the mint side.
|
|
425
|
+
*
|
|
426
|
+
* Tag-scoping (auth-unification arc, C0): the token's `permissions.scoped_tags`
|
|
427
|
+
* claim (when present and well-formed) maps into `AuthResult.scoped_tags`,
|
|
428
|
+
* restricting which notes the token sees. A present-but-malformed claim FAILS
|
|
429
|
+
* CLOSED with a 401 rather than widening to full-vault — see
|
|
430
|
+
* `parseScopedTagsFromPermissions`.
|
|
339
431
|
*/
|
|
340
432
|
async function authenticateHubJwt(
|
|
341
433
|
token: string,
|
|
@@ -396,8 +488,35 @@ async function authenticateHubJwt(
|
|
|
396
488
|
hasScope(claims.scopes, SCOPE_WRITE) || hasScope(claims.scopes, SCOPE_ADMIN)
|
|
397
489
|
? "full"
|
|
398
490
|
: "read";
|
|
399
|
-
|
|
491
|
+
// C0: read tag-scoping from the validated token's `permissions` claim.
|
|
492
|
+
// Throws MalformedScopedTagsError (caught below → 401) on a present-but-
|
|
493
|
+
// malformed claim so we never widen access on a misread.
|
|
494
|
+
const scoped_tags = parseScopedTagsFromPermissions(claims.permissions);
|
|
495
|
+
return {
|
|
496
|
+
permission,
|
|
497
|
+
scopes: claims.scopes,
|
|
498
|
+
legacyDerived: false,
|
|
499
|
+
scoped_tags,
|
|
500
|
+
vault_name: null,
|
|
501
|
+
// claims.jti is `undefined` when the issuer didn't stamp one. Pass it
|
|
502
|
+
// through verbatim — manage-token's session-pin will be null in that
|
|
503
|
+
// case, and list/revoke from that session sees no mints.
|
|
504
|
+
caller_jti: claims.jti ?? null,
|
|
505
|
+
};
|
|
400
506
|
} catch (err) {
|
|
507
|
+
if (err instanceof MalformedScopedTagsError) {
|
|
508
|
+
// Fail-closed (C0): a present-but-malformed `permissions.scoped_tags`
|
|
509
|
+
// means the token wanted to be tag-scoped but we can't read the
|
|
510
|
+
// allowlist. Reject rather than widen to full-vault. The audit log
|
|
511
|
+
// carries the diagnostic; the client gets a generic 401.
|
|
512
|
+
console.warn(`[auth] hub JWT rejected: ${err.message}`);
|
|
513
|
+
return {
|
|
514
|
+
error: Response.json(
|
|
515
|
+
{ error: "Unauthorized", message: "token has a malformed tag-scope claim" },
|
|
516
|
+
{ status: 401 },
|
|
517
|
+
),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
401
520
|
if (err instanceof HubJwtError) {
|
|
402
521
|
// Revocation-related codes get sanitized client messages: server-side
|
|
403
522
|
// audit log carries the full diagnostic (jti for `revoked`,
|
|
@@ -511,6 +630,7 @@ export async function authenticateGlobalRequest(
|
|
|
511
630
|
legacyDerived: resolved.legacyDerived,
|
|
512
631
|
scoped_tags: resolved.scoped_tags,
|
|
513
632
|
vault_name: resolved.vault_name,
|
|
633
|
+
caller_jti: resolved.jti,
|
|
514
634
|
};
|
|
515
635
|
}
|
|
516
636
|
} catch {
|