@openparachute/vault 0.4.8 → 0.4.9-rc.10

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 (40) hide show
  1. package/core/src/hooks.test.ts +320 -1
  2. package/core/src/hooks.ts +243 -38
  3. package/core/src/mcp.ts +35 -0
  4. package/core/src/portable-md.test.ts +252 -1
  5. package/core/src/portable-md.ts +370 -2
  6. package/core/src/schema.ts +51 -2
  7. package/core/src/store.ts +68 -2
  8. package/package.json +1 -1
  9. package/src/auth.ts +29 -1
  10. package/src/auto-transcribe.test.ts +7 -2
  11. package/src/auto-transcribe.ts +6 -2
  12. package/src/export-watch.test.ts +74 -0
  13. package/src/export-watch.ts +108 -7
  14. package/src/github-device-flow.test.ts +404 -0
  15. package/src/github-device-flow.ts +415 -0
  16. package/src/mcp-http.ts +24 -36
  17. package/src/mcp-tools.ts +286 -2
  18. package/src/mirror-config.test.ts +184 -14
  19. package/src/mirror-config.ts +220 -24
  20. package/src/mirror-credentials.test.ts +450 -0
  21. package/src/mirror-credentials.ts +577 -0
  22. package/src/mirror-deps.ts +42 -1
  23. package/src/mirror-import.test.ts +550 -0
  24. package/src/mirror-import.ts +484 -0
  25. package/src/mirror-manager.test.ts +423 -12
  26. package/src/mirror-manager.ts +579 -62
  27. package/src/mirror-routes.test.ts +966 -10
  28. package/src/mirror-routes.ts +1096 -5
  29. package/src/module-config.ts +11 -5
  30. package/src/routing.test.ts +92 -1
  31. package/src/routing.ts +165 -1
  32. package/src/server.ts +21 -8
  33. package/src/token-store.ts +158 -5
  34. package/src/transcription-worker.ts +9 -4
  35. package/src/triggers.ts +16 -3
  36. package/src/vault.test.ts +380 -1
  37. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  38. package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -19,8 +19,11 @@
19
19
  * - port: GlobalConfig.port, exposed read-only.
20
20
  * - autoTranscribe.*: vault↔scribe handoff (vault#353, design 2026-05-21
21
21
  * Part 2). Three nested fields per design Q4:
22
- * - enabled: boolean toggle, default false (persisted in
23
- * GlobalConfig.auto_transcribe.enabled).
22
+ * - enabled: boolean toggle, default true when scribe is
23
+ * reachable (persisted in
24
+ * GlobalConfig.auto_transcribe.enabled). Default
25
+ * flipped from off → on so installing scribe is
26
+ * the only opt-in signal needed.
24
27
  * - scribeUrl: readOnly — resolved per-process from
25
28
  * `~/.parachute/services.json` via
26
29
  * `scribe-discovery.ts`. Operators can't point at an
@@ -69,10 +72,10 @@ export function buildConfigSchema(): ModuleConfigSchema {
69
72
  properties: {
70
73
  enabled: {
71
74
  type: "boolean",
72
- default: false,
75
+ default: true,
73
76
  title: "Enable auto-transcription",
74
77
  description:
75
- "Master toggle. When false, audio uploads land normally without any scribe interaction. Global — persisted in `GlobalConfig.auto_transcribe.enabled` and applies to every vault on this server. Per-vault control is a future enhancement when multi-vault deployments need it.",
78
+ "Master toggle. Default on audio uploads transcribe automatically when scribe is reachable. Set to false to disable. Global — persisted in `GlobalConfig.auto_transcribe.enabled` and applies to every vault on this server. Per-vault control is a future enhancement when multi-vault deployments need it.",
76
79
  },
77
80
  scribeUrl: {
78
81
  type: "string",
@@ -139,7 +142,10 @@ export function buildConfigValues(
139
142
  return {
140
143
  audio_retention: vaultConfig.audio_retention ?? "keep",
141
144
  autoTranscribe: {
142
- enabled: globalConfig.auto_transcribe?.enabled ?? false,
145
+ // Match shouldAutoTranscribe's `?? true` so the admin SPA displays
146
+ // the same value runtime uses. An unset config row shows `true`
147
+ // because that's what vault will actually do on the next audio upload.
148
+ enabled: globalConfig.auto_transcribe?.enabled ?? true,
143
149
  scribeUrl,
144
150
  },
145
151
  // Legacy alias mirrors `autoTranscribe.scribeUrl` so hubs reading the
@@ -17,7 +17,7 @@
17
17
  * never touch ~/.parachute.
18
18
  */
19
19
 
20
- import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
20
+ import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test";
21
21
  import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
22
22
  import { join } from "path";
23
23
  import { tmpdir } from "os";
@@ -606,6 +606,24 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
606
606
 
607
607
  const HUB_ORIGIN = "http://127.0.0.1:1939";
608
608
 
609
+ // Process-env isolation: sibling test files (tokens-routes.test.ts,
610
+ // auth-hub-jwt.test.ts) set PARACHUTE_HUB_ORIGIN in their own beforeAll
611
+ // hooks. Bun's test runner shares a single process across test files,
612
+ // and when file-ordering puts those before this one, their hook-set
613
+ // value can still be live when our tests run. Restore the default
614
+ // (unset) here so we test against `DEFAULT_HUB_LOOPBACK`. Caught when
615
+ // vault rc.1 release CI failed with "Received: http://127.0.0.1:34295"
616
+ // — a leaked ephemeral port from another test's fixture.
617
+ let _prevHubOriginRouting: string | undefined;
618
+ beforeAll(() => {
619
+ _prevHubOriginRouting = process.env.PARACHUTE_HUB_ORIGIN;
620
+ delete process.env.PARACHUTE_HUB_ORIGIN;
621
+ });
622
+ afterAll(() => {
623
+ if (_prevHubOriginRouting === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
624
+ else process.env.PARACHUTE_HUB_ORIGIN = _prevHubOriginRouting;
625
+ });
626
+
609
627
  describe("per-vault OAuth discovery (hub-rooted after workstream E)", () => {
610
628
  test("AS metadata names the hub as issuer + endpoints", async () => {
611
629
  createVault("journal");
@@ -1804,3 +1822,76 @@ describe("/vault/<name>/.parachute/mirror — auth + dispatch", () => {
1804
1822
  expect([405, 503]).toContain(res.status);
1805
1823
  });
1806
1824
  });
1825
+
1826
+ // ---------------------------------------------------------------------------
1827
+ // /vault/<name>/.parachute/mirror/run-now — manual-trigger endpoint added
1828
+ // alongside the SPA UI. Tests pin the auth gate matches the parent
1829
+ // endpoint; handler-shape coverage lives in mirror-routes.test.ts.
1830
+ // ---------------------------------------------------------------------------
1831
+
1832
+ describe("/vault/<name>/.parachute/mirror/run-now — auth + dispatch", () => {
1833
+ test("unauthenticated → 401", async () => {
1834
+ createVault("journal");
1835
+ const p = "/vault/journal/.parachute/mirror/run-now";
1836
+ const res = await route(
1837
+ new Request(`http://localhost:1940${p}`, { method: "POST" }),
1838
+ p,
1839
+ );
1840
+ expect(res.status).toBe(401);
1841
+ });
1842
+
1843
+ test("vault:read token → 403 insufficient_scope", async () => {
1844
+ createVault("journal");
1845
+ const store = getVaultStore("journal");
1846
+ const { fullToken } = generateToken();
1847
+ createToken(store.db, fullToken, {
1848
+ label: "reader",
1849
+ permission: "read",
1850
+ scopes: ["vault:read"],
1851
+ });
1852
+ const p = "/vault/journal/.parachute/mirror/run-now";
1853
+ const res = await route(
1854
+ new Request(`http://localhost:1940${p}`, {
1855
+ method: "POST",
1856
+ headers: { authorization: `Bearer ${fullToken}` },
1857
+ }),
1858
+ p,
1859
+ );
1860
+ expect(res.status).toBe(403);
1861
+ const body = (await res.json()) as { error_type?: string; required_scope?: string };
1862
+ expect(body.error_type).toBe("insufficient_scope");
1863
+ expect(body.required_scope).toBe("vault:admin");
1864
+ });
1865
+
1866
+ test("admin token reaches the handler — 503 when manager not wired, 400 when wired+disabled", async () => {
1867
+ // Mirrors the parent endpoint's harness behavior: test ordering
1868
+ // determines whether a previous test wired a manager. Either way
1869
+ // the auth gate passed, which is what this routing-level test pins.
1870
+ createVault("journal");
1871
+ const token = createAdminToken("journal");
1872
+ const p = "/vault/journal/.parachute/mirror/run-now";
1873
+ const res = await route(
1874
+ new Request(`http://localhost:1940${p}`, {
1875
+ method: "POST",
1876
+ headers: { authorization: `Bearer ${token}` },
1877
+ }),
1878
+ p,
1879
+ );
1880
+ expect([400, 503]).toContain(res.status);
1881
+ });
1882
+
1883
+ test("non-POST methods return 405 when manager is wired", async () => {
1884
+ createVault("journal");
1885
+ const token = createAdminToken("journal");
1886
+ const p = "/vault/journal/.parachute/mirror/run-now";
1887
+ const res = await route(
1888
+ new Request(`http://localhost:1940${p}`, {
1889
+ method: "GET",
1890
+ headers: { authorization: `Bearer ${token}` },
1891
+ }),
1892
+ p,
1893
+ );
1894
+ // 503 short-circuits the method check when no manager is wired.
1895
+ expect([405, 503]).toContain(res.status);
1896
+ });
1897
+ });
package/src/routing.ts CHANGED
@@ -72,7 +72,21 @@ import {
72
72
  } from "./oauth-discovery.ts";
73
73
  import { handleConfigSchema, handleConfig } from "./module-config.ts";
74
74
  import { buildAuthStatus } from "./auth-status.ts";
75
- import { handleMirrorGet, handleMirrorPut } from "./mirror-routes.ts";
75
+ import {
76
+ handleAuthDelete,
77
+ handleAuthGet,
78
+ handleAuthGithubCreateRepo,
79
+ handleAuthGithubDeviceCode,
80
+ handleAuthGithubPoll,
81
+ handleAuthGithubRepos,
82
+ handleAuthGithubSelectRepo,
83
+ handleAuthPat,
84
+ handleMirrorGet,
85
+ handleMirrorImport,
86
+ handleMirrorPushNow,
87
+ handleMirrorPut,
88
+ handleMirrorRunNow,
89
+ } from "./mirror-routes.ts";
76
90
  import { getMirrorManager } from "./mirror-registry.ts";
77
91
 
78
92
  /**
@@ -511,6 +525,156 @@ export async function route(
511
525
  return Response.json({ error: "Method not allowed" }, { status: 405 });
512
526
  }
513
527
 
528
+ // /.parachute/mirror/run-now — fire a one-shot export+commit+push pass.
529
+ // Same admin gate as the GET/PUT above; same manager presence check.
530
+ // POST-only — a GET would imply "read the result of running" which
531
+ // isn't the verb (the rolling status is already available on the
532
+ // parent GET endpoint).
533
+ if (subpath === "/.parachute/mirror/run-now") {
534
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
535
+ return Response.json(
536
+ {
537
+ error: "Forbidden",
538
+ error_type: "insufficient_scope",
539
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
540
+ required_scope: SCOPE_ADMIN,
541
+ granted_scopes: auth.scopes,
542
+ },
543
+ { status: 403 },
544
+ );
545
+ }
546
+ const manager = getMirrorManager();
547
+ if (!manager) {
548
+ return Response.json(
549
+ {
550
+ error: "Mirror manager not initialized",
551
+ message:
552
+ "The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
553
+ },
554
+ { status: 503 },
555
+ );
556
+ }
557
+ if (req.method === "POST") return handleMirrorRunNow(manager);
558
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
559
+ }
560
+
561
+ // /.parachute/mirror/push-now — fire `git push` against committed state.
562
+ // Cut 6 of vault#392. Same admin gate + manager check as run-now;
563
+ // POST-only. Distinguished from /run-now in that this skips export +
564
+ // commit, only pushes — for "did my credentials actually work?" flow.
565
+ if (subpath === "/.parachute/mirror/push-now") {
566
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
567
+ return Response.json(
568
+ {
569
+ error: "Forbidden",
570
+ error_type: "insufficient_scope",
571
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
572
+ required_scope: SCOPE_ADMIN,
573
+ granted_scopes: auth.scopes,
574
+ },
575
+ { status: 403 },
576
+ );
577
+ }
578
+ const manager = getMirrorManager();
579
+ if (!manager) {
580
+ return Response.json(
581
+ {
582
+ error: "Mirror manager not initialized",
583
+ message:
584
+ "The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
585
+ },
586
+ { status: 503 },
587
+ );
588
+ }
589
+ if (req.method === "POST") return handleMirrorPushNow(manager);
590
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
591
+ }
592
+
593
+ // /.parachute/mirror/import — clone a vault export from git + import.
594
+ // Admin-gated. POST-only. Synchronous (imports finish in <30s for
595
+ // typical vaults). See mirror-routes.ts:handleMirrorImport for the
596
+ // request/response shape + error map. Symmetric counterpart to the
597
+ // export-to-git flow vault#382 + vault#384 shipped.
598
+ if (subpath === "/.parachute/mirror/import") {
599
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
600
+ return Response.json(
601
+ {
602
+ error: "Forbidden",
603
+ error_type: "insufficient_scope",
604
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
605
+ required_scope: SCOPE_ADMIN,
606
+ granted_scopes: auth.scopes,
607
+ },
608
+ { status: 403 },
609
+ );
610
+ }
611
+ if (req.method !== "POST") {
612
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
613
+ }
614
+ return handleMirrorImport(req, vaultName);
615
+ }
616
+
617
+ // /.parachute/mirror/auth/* — UI-configurable git push credentials.
618
+ // GitHub OAuth Device Flow + PAT fallback. All admin-gated; the
619
+ // routes themselves don't carry secrets in their responses
620
+ // (mirror-routes.ts redacts via sanitizeCredentials).
621
+ if (subpath.startsWith("/.parachute/mirror/auth")) {
622
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
623
+ return Response.json(
624
+ {
625
+ error: "Forbidden",
626
+ error_type: "insufficient_scope",
627
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
628
+ required_scope: SCOPE_ADMIN,
629
+ granted_scopes: auth.scopes,
630
+ },
631
+ { status: 403 },
632
+ );
633
+ }
634
+ const manager = getMirrorManager();
635
+ if (!manager) {
636
+ return Response.json(
637
+ {
638
+ error: "Mirror manager not initialized",
639
+ message:
640
+ "The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
641
+ },
642
+ { status: 503 },
643
+ );
644
+ }
645
+
646
+ if (subpath === "/.parachute/mirror/auth") {
647
+ if (req.method === "GET") return handleAuthGet();
648
+ if (req.method === "DELETE") return handleAuthDelete(manager);
649
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
650
+ }
651
+ if (subpath === "/.parachute/mirror/auth/github/device-code") {
652
+ if (req.method === "POST") return handleAuthGithubDeviceCode();
653
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
654
+ }
655
+ if (subpath === "/.parachute/mirror/auth/github/poll") {
656
+ if (req.method === "POST") return handleAuthGithubPoll(req, manager);
657
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
658
+ }
659
+ if (subpath === "/.parachute/mirror/auth/github/repos") {
660
+ if (req.method === "GET") return handleAuthGithubRepos();
661
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
662
+ }
663
+ if (subpath === "/.parachute/mirror/auth/github/create-repo") {
664
+ if (req.method === "POST") return handleAuthGithubCreateRepo(req);
665
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
666
+ }
667
+ if (subpath === "/.parachute/mirror/auth/github/select-repo") {
668
+ if (req.method === "POST") return handleAuthGithubSelectRepo(req, manager);
669
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
670
+ }
671
+ if (subpath === "/.parachute/mirror/auth/pat") {
672
+ if (req.method === "POST") return handleAuthPat(req, manager);
673
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
674
+ }
675
+ return Response.json({ error: "Not found" }, { status: 404 });
676
+ }
677
+
514
678
  const apiMatch = subpath.match(/^\/api(\/.*)?$/);
515
679
  if (!apiMatch) {
516
680
  return Response.json({ error: "Not found" }, { status: 404 });
package/src/server.ts CHANGED
@@ -315,18 +315,31 @@ const server = Bun.serve({
315
315
  console.log(`Parachute Vault server listening on http://${hostname}:${server.port}`);
316
316
 
317
317
  // Graceful shutdown — best-effort drain of in-flight note-mutation hooks.
318
+ //
319
+ // Order matters under the event-driven mirror shape (vault#382):
320
+ // 1. MirrorManager.stop() runs FIRST so it can unsubscribe from hooks
321
+ // cleanly + cancel its debounce timer. Otherwise the registry drain
322
+ // below would wait on the manager's hook handler that just queued
323
+ // itself after a final mutation.
324
+ // 2. Then drain hooks + stop the transcription worker in parallel —
325
+ // neither depends on the other.
326
+ //
327
+ // The whole thing races a 5s timeout so a stuck handler doesn't hang
328
+ // shutdown indefinitely.
318
329
  async function shutdown(signal: string): Promise<void> {
319
330
  console.log(`\n[${signal}] shutting down; in-flight hooks: ${defaultHookRegistry.inFlightCount}`);
320
331
  try {
321
332
  await Promise.race([
322
- Promise.all([
323
- defaultHookRegistry.drain(),
324
- transcriptionWorker?.stop() ?? Promise.resolve(),
325
- // Mirror manager: cancel the watch interval + let any in-flight
326
- // export cycle settle. `stop` already has its own brief timeout
327
- // (250ms) so this doesn't block the larger shutdown race.
328
- mirrorManager?.stop() ?? Promise.resolve(),
329
- ]),
333
+ (async () => {
334
+ // Mirror stop first — unsubscribes + cancels its debounce. Its
335
+ // internal soft-settle timeout (250ms) bounds the wait.
336
+ await (mirrorManager?.stop() ?? Promise.resolve());
337
+ // Then drain hooks + stop the transcription worker in parallel.
338
+ await Promise.all([
339
+ defaultHookRegistry.drain(),
340
+ transcriptionWorker?.stop() ?? Promise.resolve(),
341
+ ]);
342
+ })(),
330
343
  new Promise<void>((resolve) => setTimeout(resolve, 5000)),
331
344
  ]);
332
345
  } catch (err) {
@@ -63,6 +63,26 @@ export interface Token {
63
63
  expires_at: string | null;
64
64
  created_at: string;
65
65
  last_used_at: string | null;
66
+ /**
67
+ * Provenance (v19). 'mcp_mint' = minted via manage-token MCP tool;
68
+ * NULL = pre-v19 / CLI / REST / YAML-import. Used by manage-token list
69
+ * to restrict the surface to MCP-session-managed tokens. See vault#376.
70
+ */
71
+ created_via: string | null;
72
+ /**
73
+ * Session pin (v19). When this token was minted via manage-token, this
74
+ * is the display id (`t_<prefix>`) of the calling session's token (for
75
+ * pvt_* MCP sessions) or the hub JWT's jti claim (for hub-issued
76
+ * sessions). NULL otherwise.
77
+ */
78
+ parent_jti: string | null;
79
+ /**
80
+ * Soft-revoke timestamp (v19). When set, `resolveToken` returns null
81
+ * and the row stays in place for audit history. manage-token revoke is
82
+ * idempotent — calling revoke a second time on the same jti is a no-op
83
+ * with ok=true. NULL = active.
84
+ */
85
+ revoked_at: string | null;
66
86
  }
67
87
 
68
88
  export interface ResolvedToken {
@@ -88,6 +108,12 @@ export interface ResolvedToken {
88
108
  * vault. See vault#257.
89
109
  */
90
110
  vault_name: string | null;
111
+ /**
112
+ * Display id (`t_<hashprefix>`) of THIS token. Surfaced so callers that
113
+ * later mint child tokens (manage-token MCP tool) can stamp parent_jti
114
+ * without re-derivation. Pre-v19 lookups still compute this on the fly.
115
+ */
116
+ jti: string;
91
117
  }
92
118
 
93
119
  /**
@@ -149,6 +175,17 @@ export function createToken(
149
175
  */
150
176
  vault_name?: string | null;
151
177
  expires_at?: string | null;
178
+ /**
179
+ * Provenance tag (v19). `'mcp_mint'` for tokens minted via the
180
+ * manage-token MCP tool; omit/null for CLI / REST / YAML paths.
181
+ */
182
+ created_via?: string | null;
183
+ /**
184
+ * Session pin (v19). Display id (`t_<prefix>`) or hub JWT `jti` of the
185
+ * caller that minted this token via manage-token. Used by the
186
+ * manage-token list/revoke surface to scope itself to one session.
187
+ */
188
+ parent_jti?: string | null;
152
189
  },
153
190
  ): Token {
154
191
  const tokenHash = hashKey(fullToken);
@@ -159,10 +196,12 @@ export function createToken(
159
196
  const scopedTags = opts.scoped_tags && opts.scoped_tags.length > 0 ? opts.scoped_tags : null;
160
197
  const scopedTagsStr = scopedTags ? JSON.stringify(scopedTags) : null;
161
198
  const vaultName = opts.vault_name ?? null;
199
+ const createdVia = opts.created_via ?? null;
200
+ const parentJti = opts.parent_jti ?? null;
162
201
 
163
202
  db.prepare(`
164
- INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, scope_tag, scope_path_prefix, expires_at, created_at, vault_name)
165
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
203
+ INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, scope_tag, scope_path_prefix, expires_at, created_at, vault_name, created_via, parent_jti)
204
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
166
205
  `).run(
167
206
  tokenHash,
168
207
  opts.label,
@@ -174,6 +213,8 @@ export function createToken(
174
213
  opts.expires_at ?? null,
175
214
  now,
176
215
  vaultName,
216
+ createdVia,
217
+ parentJti,
177
218
  );
178
219
 
179
220
  return {
@@ -187,6 +228,9 @@ export function createToken(
187
228
  expires_at: opts.expires_at ?? null,
188
229
  created_at: now,
189
230
  last_used_at: null,
231
+ created_via: createdVia,
232
+ parent_jti: parentJti,
233
+ revoked_at: null,
190
234
  };
191
235
  }
192
236
 
@@ -200,8 +244,15 @@ export function resolveToken(db: Database, providedToken: string): ResolvedToken
200
244
  // preimage, which is computationally infeasible regardless of timing leaks.
201
245
  const candidateHash = hashKey(providedToken);
202
246
 
247
+ // Defensive SELECT for revoked_at: the column exists post-v19, but a
248
+ // freshly-opened ResolvedToken-only test fixture might run on a DB the
249
+ // migration hasn't touched. SQLite returns NULL for missing columns when
250
+ // the table is queried via prepared statements only after migration; here
251
+ // initSchema fires on every store-open path, so the column is guaranteed
252
+ // present in production. Tests instantiating bare DBs against this
253
+ // module are expected to call initSchema first.
203
254
  const row = db.prepare(`
204
- SELECT token_hash, permission, scopes, scoped_tags, expires_at, vault_name
255
+ SELECT token_hash, permission, scopes, scoped_tags, expires_at, vault_name, revoked_at
205
256
  FROM tokens WHERE token_hash = ?
206
257
  `).get(candidateHash) as {
207
258
  token_hash: string;
@@ -210,10 +261,16 @@ export function resolveToken(db: Database, providedToken: string): ResolvedToken
210
261
  scoped_tags: string | null;
211
262
  expires_at: string | null;
212
263
  vault_name: string | null;
264
+ revoked_at: string | null;
213
265
  } | null;
214
266
 
215
267
  if (!row) return null;
216
268
 
269
+ // Soft-revoked tokens never authenticate (v19). The row stays in place
270
+ // for audit; resolveToken just treats it as not-found from the caller's
271
+ // perspective.
272
+ if (row.revoked_at) return null;
273
+
217
274
  // Check expiry
218
275
  if (row.expires_at && new Date(row.expires_at) < new Date()) {
219
276
  return null;
@@ -229,8 +286,9 @@ export function resolveToken(db: Database, providedToken: string): ResolvedToken
229
286
  const scopes = hasVaultScope ? parsed : legacyPermissionToScopes(permission);
230
287
  const legacyDerived = !hasVaultScope;
231
288
  const scoped_tags = parseScopedTags(row.scoped_tags);
289
+ const jti = `t_${row.token_hash.slice(7, 19)}`;
232
290
 
233
- return { permission, scopes, legacyDerived, scoped_tags, vault_name: row.vault_name };
291
+ return { permission, scopes, legacyDerived, scoped_tags, vault_name: row.vault_name, jti };
234
292
  }
235
293
 
236
294
  /**
@@ -252,7 +310,8 @@ export function listTokens(
252
310
  const params = opts.vaultName ? [opts.vaultName] : [];
253
311
  const rows = db.prepare(`
254
312
  SELECT token_hash, label, permission, scope_tag, scope_path_prefix,
255
- scoped_tags, vault_name, expires_at, created_at, last_used_at
313
+ scoped_tags, vault_name, expires_at, created_at, last_used_at,
314
+ created_via, parent_jti, revoked_at
256
315
  FROM tokens ${where}
257
316
  ORDER BY created_at DESC
258
317
  `).all(...params) as (Omit<Token, "scoped_tags"> & { scoped_tags: string | null })[];
@@ -266,6 +325,100 @@ export function listTokens(
266
325
  }));
267
326
  }
268
327
 
328
+ /**
329
+ * List tokens minted via the manage-token MCP tool by a given session
330
+ * (parent_jti). Used by `manage-token` action="list" to scope its surface
331
+ * to its own session's mints — operators with multiple MCP sessions open
332
+ * don't see each other's tokens, and CLI/REST-minted tokens never appear.
333
+ *
334
+ * Returns metadata only (no token-hash exposure beyond the display id);
335
+ * the display id is what the caller uses to revoke. Includes `revoked_at`
336
+ * so the UI can render a tombstone for soft-revoked rows.
337
+ */
338
+ export function listMcpMintedTokens(
339
+ db: Database,
340
+ parentJti: string,
341
+ vaultName: string,
342
+ ): Array<{
343
+ jti: string;
344
+ label: string;
345
+ scopes: string[];
346
+ scoped_tags: string[] | null;
347
+ created_at: string;
348
+ expires_at: string | null;
349
+ revoked_at: string | null;
350
+ }> {
351
+ const rows = db.prepare(`
352
+ SELECT token_hash, label, scopes, scoped_tags, created_at, expires_at, revoked_at
353
+ FROM tokens
354
+ WHERE created_via = 'mcp_mint'
355
+ AND parent_jti = ?
356
+ AND vault_name = ?
357
+ ORDER BY created_at DESC
358
+ `).all(parentJti, vaultName) as {
359
+ token_hash: string;
360
+ label: string;
361
+ scopes: string | null;
362
+ scoped_tags: string | null;
363
+ created_at: string;
364
+ expires_at: string | null;
365
+ revoked_at: string | null;
366
+ }[];
367
+ return rows.map((r) => ({
368
+ jti: `t_${r.token_hash.slice(7, 19)}`,
369
+ label: r.label,
370
+ scopes: parseScopes(r.scopes),
371
+ scoped_tags: parseScopedTags(r.scoped_tags),
372
+ created_at: r.created_at,
373
+ expires_at: r.expires_at,
374
+ revoked_at: r.revoked_at,
375
+ }));
376
+ }
377
+
378
+ /**
379
+ * Soft-revoke a token minted via manage-token, scoped to the session that
380
+ * minted it. Idempotent: revoking an already-revoked or never-existent jti
381
+ * returns the same shape; second-call to revoke is intentionally still
382
+ * ok=true so the AI's revoke step doesn't surface a confusing failure on a
383
+ * retry after a network blip. The row stays in place for audit trail —
384
+ * resolveToken treats revoked_at-set rows as not-found.
385
+ *
386
+ * `parentJti` + `vaultName` scope the lookup: a token minted by a
387
+ * different MCP session (or against a different vault) returns ok=false.
388
+ * Returns { ok: true, already_revoked? } when the operation matched a row.
389
+ */
390
+ export function softRevokeMcpToken(
391
+ db: Database,
392
+ jti: string,
393
+ parentJti: string,
394
+ vaultName: string,
395
+ ): { ok: true; already_revoked: boolean } | { ok: false; reason: "not_found" } {
396
+ if (!jti.startsWith("t_")) {
397
+ return { ok: false, reason: "not_found" };
398
+ }
399
+ const hashPrefix = jti.slice(2);
400
+ const row = db.prepare(`
401
+ SELECT token_hash, revoked_at FROM tokens
402
+ WHERE token_hash LIKE ?
403
+ AND created_via = 'mcp_mint'
404
+ AND parent_jti = ?
405
+ AND vault_name = ?
406
+ LIMIT 1
407
+ `).get(`sha256:${hashPrefix}%`, parentJti, vaultName) as {
408
+ token_hash: string;
409
+ revoked_at: string | null;
410
+ } | null;
411
+
412
+ if (!row) return { ok: false, reason: "not_found" };
413
+ if (row.revoked_at) {
414
+ // Second revoke: idempotent — already done, surface true with the flag.
415
+ return { ok: true, already_revoked: true };
416
+ }
417
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE token_hash = ?")
418
+ .run(new Date().toISOString(), row.token_hash);
419
+ return { ok: true, already_revoked: false };
420
+ }
421
+
269
422
  /**
270
423
  * Find tokens whose `scoped_tags` allowlist references the given root tag.
271
424
  * Used by tag-delete and tag-merge to fail-closed (409) when removing a
@@ -554,10 +554,15 @@ export function registerTranscriptionHook(
554
554
  return registry.onAttachment({
555
555
  name: "transcription-kickoff",
556
556
  event: "created",
557
- when: (att) =>
558
- (att.metadata as { transcribe_status?: string } | undefined)
559
- ?.transcribe_status === "pending",
560
- handler: async (attachment, store) => {
557
+ when: (att) => {
558
+ // Only "created" payloads reach this predicate (we don't subscribe
559
+ // to "deleted"), so `metadata` is populated. The union widening
560
+ // post-deletion-events just means we narrow here defensively.
561
+ const meta = (att as Attachment).metadata as { transcribe_status?: string } | undefined;
562
+ return meta?.transcribe_status === "pending";
563
+ },
564
+ handler: async (payload, store) => {
565
+ const attachment = payload as Attachment;
561
566
  const vault = resolveVault(store);
562
567
  if (!vault) {
563
568
  logger.error(
package/src/triggers.ts CHANGED
@@ -29,7 +29,7 @@ import { join, normalize } from "path";
29
29
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
30
30
  import crypto from "node:crypto";
31
31
  import type { Note, Store, Attachment } from "../core/src/types.ts";
32
- import type { HookRegistry, HookEvent } from "../core/src/hooks.ts";
32
+ import type { HookRegistry, HookEvent, NoteHookPayload } from "../core/src/hooks.ts";
33
33
  import type { TriggerConfig, TriggerWhen } from "./config.ts";
34
34
  import { getVaultNameForStore } from "./vault-store.ts";
35
35
  import { assetsDir } from "./routes.ts";
@@ -56,6 +56,10 @@ export function buildPredicate(when: TriggerWhen, triggerName: string): (note: N
56
56
  const pendingKey = `${triggerName}_pending_at`;
57
57
  const renderedKey = `${triggerName}_rendered_at`;
58
58
 
59
+ // Hook dispatcher passes `NoteHookPayload` (Note | DeletedNoteRef). All
60
+ // triggers default to events `["created", "updated"]` so deleted shapes
61
+ // never reach this predicate, but we still type the parameter as Note
62
+ // — narrowing here keeps the rest of the predicate body unchanged.
59
63
  return (note: Note) => {
60
64
  const meta = note.metadata as Record<string, unknown> | undefined;
61
65
 
@@ -310,8 +314,17 @@ export function registerTriggers(
310
314
  const unregister = hooks.onNote({
311
315
  name: trigger.name,
312
316
  event: events,
313
- when: predicate,
314
- handler: async (note: Note, store: Store, hookEvent?: HookEvent) => {
317
+ when: (payload: NoteHookPayload) => {
318
+ // Triggers don't subscribe to "deleted"; if the union ever
319
+ // widens via config, the predicate sees a partial shape that
320
+ // simply doesn't match anything tag/metadata-based and returns
321
+ // false. Safe by construction.
322
+ return predicate(payload as Note);
323
+ },
324
+ handler: async (payload: NoteHookPayload, store: Store, hookEvent?: HookEvent) => {
325
+ // Same shape contract as the predicate — triggers don't
326
+ // subscribe to deleted events, so narrow back to Note.
327
+ const note = payload as Note;
315
328
  const existingMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
316
329
 
317
330
  // Handler-side re-check (same race-window protection as the old hooks)