@openparachute/vault 0.6.0-rc.1 → 0.6.1

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 (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/src/routing.ts CHANGED
@@ -15,6 +15,9 @@
15
15
  * /vaults/list — public vault-name discovery (can be
16
16
  * disabled globally via config)
17
17
  * /vaults — authenticated vault metadata list
18
+ * /vault/admin[/*] — daemon-level multi-vault admin SPA
19
+ * (B3; `admin` is a reserved vault
20
+ * name, dispatched BEFORE per-vault)
18
21
  * /vault/<name>/.well-known/oauth-* — discovery forwarder; metadata names
19
22
  * the hub as the authorization server
20
23
  * (vault is resource-server only)
@@ -49,10 +52,16 @@ import {
49
52
  authenticateGlobalRequest,
50
53
  extractApiKey,
51
54
  } from "./auth.ts";
52
- import { hasScopeForVault, SCOPE_ADMIN, scopeForMethod, verbForMethod } from "./scopes.ts";
55
+ import { hasScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
53
56
  import { getVaultStore } from "./vault-store.ts";
54
57
  import { handleScopedMcp } from "./mcp-http.ts";
55
- import { defaultAdminSpaDistDir, isAdminSpaPath, serveAdminSpa } from "./admin-spa.ts";
58
+ import {
59
+ defaultAdminSpaDistDir,
60
+ isAdminSpaPath,
61
+ isDaemonAdminSpaPath,
62
+ serveAdminSpa,
63
+ serveDaemonAdminSpa,
64
+ } from "./admin-spa.ts";
56
65
  import {
57
66
  handleNotes,
58
67
  handleTags,
@@ -63,6 +72,8 @@ import {
63
72
  handleViewNote,
64
73
  type TagScopeCtx,
65
74
  } from "./routes.ts";
75
+ import { handleSubscribe } from "./subscribe.ts";
76
+ import { handleTriggers } from "./triggers-api.ts";
66
77
  import { expandTokenTagScope } from "./tag-scope.ts";
67
78
  import {
68
79
  handleProtectedResource,
@@ -76,6 +87,7 @@ import {
76
87
  handleAuthGet,
77
88
  handleAuthGithubCreateRepo,
78
89
  handleAuthGithubDeviceCode,
90
+ handleAuthGithubInstallations,
79
91
  handleAuthGithubPoll,
80
92
  handleAuthGithubRepos,
81
93
  handleAuthGithubSelectRepo,
@@ -87,6 +99,7 @@ import {
87
99
  handleMirrorRunNow,
88
100
  } from "./mirror-routes.ts";
89
101
  import { getMirrorManager } from "./mirror-registry.ts";
102
+ import { buildUsageReport } from "./usage.ts";
90
103
 
91
104
  /**
92
105
  * Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
@@ -179,6 +192,23 @@ function handleParachuteIcon(): Response {
179
192
  });
180
193
  }
181
194
 
195
+ /**
196
+ * Filter a server-wide vault-name list for a caller's per-vault binding
197
+ * (vault#259). `vaultName === null` is the operator / admin-channel caller
198
+ * (server-wide VAULT_AUTH_TOKEN or legacy cross-vault config.yaml key) — they
199
+ * keep the FULL list. A non-null binding reduces the list to just that vault
200
+ * (empty if the bound vault isn't in the listing). Pure + exported so the
201
+ * policy is unit-testable independent of the auth path. See the `/vaults`
202
+ * handler for the full rationale.
203
+ */
204
+ export function filterVaultListForBinding(
205
+ names: string[],
206
+ vaultName: string | null,
207
+ ): string[] {
208
+ if (vaultName === null) return names;
209
+ return names.filter((name) => name === vaultName);
210
+ }
211
+
182
212
  export async function route(
183
213
  req: Request,
184
214
  path: string,
@@ -269,6 +299,26 @@ export async function route(
269
299
  });
270
300
  }
271
301
 
302
+ // Daemon-level multi-vault admin SPA — `/vault/admin[/*]` (B3 of the
303
+ // 2026-06-09 hub-module-boundary migration). The vault MODULE's own home
304
+ // surface: list / create / delete vaults, deep-link into each instance's
305
+ // per-vault admin. Same static bundle as the per-vault mount below —
306
+ // `web/ui/src/lib/mount.ts` detects the basename at runtime.
307
+ //
308
+ // MUST dispatch BEFORE `isAdminSpaPath`: the per-vault regex also matches
309
+ // `/vault/admin/admin` (capturing name="admin"), which must NOT boot
310
+ // per-vault mode — `admin` is a reserved vault name (B2) and this mount is
311
+ // daemon-owned. Hub#637's `/vault/admin` route forwards the FULL path here
312
+ // (no stripPrefix on vault's services row). A pre-reservation squatter
313
+ // vault named "admin" is fully shadowed by this branch — server boot
314
+ // warns with the recovery procedure (see vault-name.ts).
315
+ if (isDaemonAdminSpaPath(path)) {
316
+ if (req.method !== "GET") {
317
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
318
+ }
319
+ return serveDaemonAdminSpa(defaultAdminSpaDistDir(), path);
320
+ }
321
+
272
322
  // Admin SPA — per-vault at `/vault/<name>/admin/*` (vault#252). Static-
273
323
  // file serving only (index.html + Vite asset bundle); no auth at this
274
324
  // seam since the bundle reveals nothing privileged. The SPA's data
@@ -285,10 +335,24 @@ export async function route(
285
335
  }
286
336
 
287
337
  // Authenticated vault metadata list.
338
+ //
339
+ // Vault-binding filter (vault#259, info-leak follow-up): `/vaults` is a
340
+ // cross-vault DISCOVERY endpoint, not a single-vault operational one (unlike
341
+ // /mcp, which legitimately routes a vault-bound token back into its own
342
+ // vault). A caller bound to one vault shouldn't learn that OTHER vaults exist
343
+ // on this server. So when the authenticated caller carries a per-vault
344
+ // binding (`auth.vault_name !== null`), the listing is reduced to just that
345
+ // vault. Operator / admin-channel callers — the server-wide VAULT_AUTH_TOKEN
346
+ // and legacy cross-vault config.yaml keys — have `vault_name === null` and
347
+ // keep the full listing (they're explicitly the cross-vault management
348
+ // channel). `authenticateGlobalRequest` already 401s hub JWTs here, so the
349
+ // only callers that reach this point today are operator-channel
350
+ // (`vault_name === null`); this filter is the security-correct shape for any
351
+ // future vault-bound credential that becomes accepted on this surface.
288
352
  if (path === "/vaults" && req.method === "GET") {
289
353
  const auth = await authenticateGlobalRequest(req);
290
354
  if ("error" in auth) return auth.error;
291
- const names = listVaults();
355
+ const names = filterVaultListForBinding(listVaults(), auth.vault_name);
292
356
  const vaults = names.map((name) => {
293
357
  const config = readVaultConfig(name);
294
358
  return {
@@ -462,8 +526,37 @@ export async function route(
462
526
  });
463
527
  }
464
528
 
529
+ // /.parachute/usage — per-vault data-footprint report ("how big is this
530
+ // vault"). READ-scoped, deliberately: a vault's own user must be able to
531
+ // see their own vault's size, and the operator (who holds broad/admin)
532
+ // inherits read. This mirrors the bare-root stats precedent above (also
533
+ // read-gated by the method) — same data-disclosure class (counts + sizes,
534
+ // no note content). The expensive part (two dir-walks) is TTL-cached inside
535
+ // usage.ts; `?fresh=1` bypasses the cache. Hub-side aggregation/UI is a
536
+ // separate follow-up; this endpoint is the load-bearing primitive.
537
+ if (subpath === "/.parachute/usage") {
538
+ if (req.method !== "GET") {
539
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
540
+ }
541
+ if (!hasScopeForVault(auth.scopes, vaultName, "read")) {
542
+ return Response.json(
543
+ {
544
+ error: "Forbidden",
545
+ error_type: "insufficient_scope",
546
+ message: `This endpoint requires the '${SCOPE_READ}' scope (or '${SCOPE_READ.replace("vault:", `vault:${vaultName}:`)}').`,
547
+ required_scope: SCOPE_READ,
548
+ granted_scopes: auth.scopes,
549
+ },
550
+ { status: 403 },
551
+ );
552
+ }
553
+ const fresh = new URL(req.url).searchParams.get("fresh") === "1";
554
+ const stats = await store.getVaultStats();
555
+ return Response.json(buildUsageReport(vaultName, stats, { fresh }));
556
+ }
557
+
465
558
  // The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
466
- // at 0.6.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
559
+ // at 0.5.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
467
560
  // JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
468
561
  // request now falls through to the catch-all 404 below.
469
562
 
@@ -643,6 +736,13 @@ export async function route(
643
736
  if (req.method === "POST") return handleAuthGithubPoll(req, manager);
644
737
  return Response.json({ error: "Method not allowed" }, { status: 405 });
645
738
  }
739
+ if (subpath === "/.parachute/mirror/auth/github/installations") {
740
+ // Install state (vault#480) — which app, installed-anywhere?, install
741
+ // link, per-account installations. Explicitly-network (probes GitHub);
742
+ // the offline status read stays on GET /.parachute/mirror/auth.
743
+ if (req.method === "GET") return handleAuthGithubInstallations(manager);
744
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
745
+ }
646
746
  if (subpath === "/.parachute/mirror/auth/github/repos") {
647
747
  if (req.method === "GET") return handleAuthGithubRepos(manager);
648
748
  return Response.json({ error: "Method not allowed" }, { status: 405 });
@@ -667,6 +767,18 @@ export async function route(
667
767
  return Response.json({ error: "Not found" }, { status: 404 });
668
768
  }
669
769
 
770
+ // /api/triggers — runtime trigger-registration API. Dispatched BEFORE the
771
+ // generic method→verb scope gate below because it's ADMIN-scoped on EVERY
772
+ // method (a webhook trigger exfiltrates note data; even GET is admin). The
773
+ // gate lives inside `handleTriggers`. Subpath is everything after
774
+ // `/api/triggers`. See src/triggers-api.ts.
775
+ {
776
+ const triggersPath = apiMatch[1] ?? "";
777
+ if (triggersPath === "/triggers" || triggersPath.startsWith("/triggers/")) {
778
+ return handleTriggers(req, store, triggersPath.slice("/triggers".length), vaultName, auth);
779
+ }
780
+ }
781
+
670
782
  // REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
671
783
  // POST/PATCH/PUT/DELETE → vault:write. Inheritance (admin ⊇ write ⊇ read)
672
784
  // and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
@@ -701,6 +813,10 @@ export async function route(
701
813
  };
702
814
 
703
815
  if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
816
+ // Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
817
+ // upsert/remove events over text/event-stream. Auth + tag-scope already
818
+ // resolved above and threaded through, mirroring the /notes branch.
819
+ if (apiPath === "/subscribe") return handleSubscribe(req, store, vaultName, tagScope);
704
820
  if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
705
821
  if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
706
822
  if (apiPath === "/vault") {
@@ -708,7 +824,7 @@ export async function route(
708
824
  writeVaultConfig(vaultConfig);
709
825
  });
710
826
  }
711
- if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
827
+ if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store, tagScope);
712
828
  if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName, store, tagScope);
713
829
  if (apiPath === "/health") return Response.json({ status: "ok", vault: vaultName });
714
830
 
package/src/scopes.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * VAULT_AUTH_TOKEN operator bearer, which are vault-pinned by context
8
8
  * (the YAML key lives under a specific vault; the operator bearer is
9
9
  * server-wide full-admin). (The `pvt_*` vault-DB token that also used
10
- * this shape was dropped at 0.6.0 — vault#282 Stage 2.)
10
+ * this shape was dropped at 0.5.0 — vault#282 Stage 2.)
11
11
  * - **Narrowed** `vault:<name>:<verb>` — used by hub-issued JWTs, which are
12
12
  * not pinned by storage and so MUST name the resource they grant access
13
13
  * to. Hub JWTs carrying broad `vault:<verb>` are rejected at validation
package/src/server.ts CHANGED
@@ -15,13 +15,14 @@
15
15
  * The request pipeline lives in ./routing.ts (exported for unit testing).
16
16
  */
17
17
 
18
- import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath } from "./config.ts";
18
+ import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath, bootAutoCreateAllowed } from "./config.ts";
19
19
  import { existsSync, rmSync } from "fs";
20
20
  import { migrateVaultKeys } from "./token-store.ts";
21
- import { resolveFirstBootVaultName } from "./vault-name.ts";
21
+ import { resolveFirstBootVaultName, reservedNameSquatWarnings } from "./vault-name.ts";
22
22
  import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
23
23
  import { defaultHookRegistry } from "../core/src/hooks.ts";
24
24
  import { registerTriggers } from "./triggers.ts";
25
+ import { loadVaultTriggers } from "./triggers-api.ts";
25
26
  import { route } from "./routing.ts";
26
27
  import { startTranscriptionWorker, registerTranscriptionHook, type TranscriptionWorker } from "./transcription-worker.ts";
27
28
  import { setTranscriptionWorker } from "./transcription-registry.ts";
@@ -45,6 +46,7 @@ import {
45
46
  } from "./mirror-config.ts";
46
47
  import { GLOBAL_CONFIG_PATH } from "./config.ts";
47
48
  import { selfRegister } from "./self-register.ts";
49
+ import { warnLegacyGlobalApiKeys } from "./auth.ts";
48
50
  import pkg from "../package.json" with { type: "json" };
49
51
 
50
52
  // Register webhook triggers from global config. Replaces the old hardcoded
@@ -144,13 +146,30 @@ if (process.env.VAULT_AUTH_TOKEN?.trim()) {
144
146
  console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
145
147
  }
146
148
 
149
+ // Legacy GLOBAL api_keys warning (security review — multi-user hardening).
150
+ // Surfaced at boot so the operator rotates/removes cross-vault credentials
151
+ // before multi-user sharing. Warning only — never touches the keys. The
152
+ // logic lives in auth.ts (import-safe + unit-tested); see warnLegacyGlobalApiKeys.
153
+ warnLegacyGlobalApiKeys(readGlobalConfig().api_keys);
154
+
147
155
  // Auto-init: create a default vault if none exist (first run in Docker).
148
156
  // The vault name comes from PARACHUTE_VAULT_NAME when set + valid; otherwise
149
157
  // falls back to "default". Hub's first-boot wizard (hub#267) passes through
150
158
  // an operator-chosen name via this env var.
159
+ //
160
+ // Gated on the `auto_create: false` marker (2026-06-09 hub-module-boundary
161
+ // migration): `parachute-vault remove` writes it when the operator deletes
162
+ // their LAST vault, so an explicit empty-the-server action isn't silently
163
+ // undone by a resurrection with fresh credentials on the next boot. Fresh
164
+ // installs have no config.yaml — no marker — so Docker first-run still
165
+ // auto-creates.
151
166
  if (listVaults().length === 0) {
152
167
  const globalConfig = readGlobalConfig();
153
- if (!globalConfig.default_vault) {
168
+ if (!bootAutoCreateAllowed(globalConfig)) {
169
+ console.log(
170
+ '[vault first-boot] auto-create disabled (auto_create: false in config.yaml — the last vault was removed deliberately). Create one with: parachute-vault create <name>',
171
+ );
172
+ } else if (!globalConfig.default_vault) {
154
173
  const firstBoot = resolveFirstBootVaultName(process.env.PARACHUTE_VAULT_NAME);
155
174
  if (firstBoot.source === "env") {
156
175
  console.log(`[vault first-boot] using PARACHUTE_VAULT_NAME=${firstBoot.name}`);
@@ -197,6 +216,15 @@ if (listVaults().length === 0) {
197
216
  // survive — see self-register.ts header for the v0.6 vs v0.7 design note.
198
217
  selfRegister({ version: pkg.version });
199
218
 
219
+ // Loud boot warning for vaults squatting a shadowed reserved name (B2 of the
220
+ // 2026-06-09 hub-module-boundary migration). A vault named `admin`/`new`/
221
+ // `assets` (created before the names were reserved) has its entire
222
+ // `/vault/<name>/*` data plane shadowed by reserved hub/daemon routes — warn
223
+ // with the recovery procedure rather than silently serving nothing.
224
+ for (const warning of reservedNameSquatWarnings(listVaults())) {
225
+ console.warn(warning);
226
+ }
227
+
200
228
  // Migrate tag schemas from vault.yaml → DB for each vault.
201
229
  // Only inserts schemas that don't already exist in the DB (safe across restarts).
202
230
  for (const vaultName of listVaults()) {
@@ -237,6 +265,31 @@ for (const vaultName of listVaults()) {
237
265
  }
238
266
  }
239
267
 
268
+ // Load persisted runtime triggers for each vault and register them
269
+ // vault-scoped on the shared hook registry (frictionless-channel-setup PR 1).
270
+ // Runs alongside the config.yaml trigger registration above; config.yaml
271
+ // triggers stay global, these fire only for their own vault. Survives
272
+ // restart — the `triggers` table is the source of truth. Per-vault failure
273
+ // is isolated so one bad vault can't block the others.
274
+ {
275
+ let totalTriggers = 0;
276
+ for (const vaultName of listVaults()) {
277
+ try {
278
+ const store = getVaultStore(vaultName);
279
+ const n = loadVaultTriggers(vaultName, store);
280
+ totalTriggers += n;
281
+ if (n > 0) {
282
+ console.log(`[triggers] loaded ${n} runtime trigger(s) for vault "${vaultName}"`);
283
+ }
284
+ } catch (err) {
285
+ console.error(`[triggers] failed to load runtime triggers for vault "${vaultName}":`, err);
286
+ }
287
+ }
288
+ if (totalTriggers === 0) {
289
+ console.log("[triggers] no persisted runtime triggers");
290
+ }
291
+ }
292
+
240
293
  const globalConfig = readGlobalConfig();
241
294
  const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PORT;
242
295
  const hostname = resolveBindHostname();
@@ -345,8 +398,17 @@ const server = Bun.serve({
345
398
 
346
399
  const corsHeaders = {
347
400
  "Access-Control-Allow-Origin": "*",
348
- "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
401
+ "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
349
402
  "Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key, Mcp-Session-Id",
403
+ // Expose response headers a BROWSER-based MCP client (e.g. claude.ai) must
404
+ // read cross-origin: `WWW-Authenticate` carries the RFC 9728 auth challenge
405
+ // (the `resource_metadata` PRM URL) the client follows to discover the auth
406
+ // server — without exposing it, the browser's fetch() can't see it and the
407
+ // OAuth flow never starts ("Couldn't register with the sign-in service").
408
+ // `Mcp-Session-Id` is the streamable-HTTP MCP session the client echoes
409
+ // back. (Claude Code is a CLI → no CORS → unaffected. The hub already
410
+ // exposes WWW-Authenticate; this matches it on the resource server.)
411
+ "Access-Control-Expose-Headers": "WWW-Authenticate, Mcp-Session-Id",
350
412
  };
351
413
 
352
414
  if (req.method === "OPTIONS") {
@@ -215,3 +215,165 @@ describe("storage GET tag-scope enforcement", () => {
215
215
  expect(res.status).toBe(403);
216
216
  });
217
217
  });
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // GET percent-encoded-slash handling (feedback finding D).
221
+ //
222
+ // `path` reaches handleStorage from `url.pathname`, which keeps an encoded
223
+ // `%2F` slash LITERAL (WHATWG). The old `path.match(/^\/([^/]+)\/(.+)$/)`
224
+ // required a literal slash, so `/api/storage/<date>%2F<file>` fell to the
225
+ // unconditional 404 — a trap-grade asymmetry with the single-note routes,
226
+ // which decode their first segment and therefore REQUIRE `%2F`. The fix
227
+ // decodes `path` before matching, accepting BOTH forms; the decoded path is
228
+ // also what the DB stores (`${date}/${filename}`), so tag-scope lookup and
229
+ // the traversal guard keep working. These tests pin both forms + the
230
+ // guard-safety of the decode.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ describe("storage GET percent-encoded slash (finding D)", () => {
234
+ const VAULT = "encode-vault";
235
+
236
+ async function setup(): Promise<{
237
+ store: SqliteStore;
238
+ assets: string;
239
+ inScopePath: string;
240
+ outScopePath: string;
241
+ }> {
242
+ const store = freshStore();
243
+ const assets = join(testDir, "assets", VAULT, "data");
244
+ mkdirSync(join(assets, "2026-05-28"), { recursive: true });
245
+ process.env.ASSETS_DIR = assets;
246
+
247
+ const workNote = await store.createNote("work note", { tags: ["work"] });
248
+ const healthNote = await store.createNote("health note", { tags: ["health"] });
249
+
250
+ const inScopePath = "2026-05-28/work-asset.pdf";
251
+ const outScopePath = "2026-05-28/health-asset.pdf";
252
+ writeFileSync(join(assets, inScopePath), Buffer.from([0x25, 0x50, 0x44, 0x46])); // %PDF
253
+ writeFileSync(join(assets, outScopePath), Buffer.from([0x25, 0x50, 0x44, 0x46]));
254
+
255
+ await store.addAttachment(workNote.id, inScopePath, "application/pdf");
256
+ await store.addAttachment(healthNote.id, outScopePath, "application/pdf");
257
+
258
+ return { store, assets, inScopePath, outScopePath };
259
+ }
260
+
261
+ // The request URL carries the encoded form; the `path` arg mirrors what the
262
+ // dispatcher hands the handler (derived from url.pathname, %2F kept literal).
263
+ function getReqEncoded(reqPath: string): { req: Request; path: string } {
264
+ const encoded = reqPath.replace(/\//g, "%2F");
265
+ return {
266
+ req: new Request(`http://localhost:1940/storage/${encoded}`, { method: "GET" }),
267
+ path: `/${encoded}`,
268
+ };
269
+ }
270
+
271
+ test("encoded %2F path serves the same bytes as the literal form (200)", async () => {
272
+ const { store, inScopePath } = await setup();
273
+ const ctx = await tagScopeCtx(store, ["work"]);
274
+ const { req, path } = getReqEncoded(inScopePath);
275
+ const res = await handleStorage(req, path, VAULT, store, ctx);
276
+ expect(res.status).toBe(200);
277
+ expect(res.headers.get("Content-Type")).toBe("application/pdf");
278
+ expect((await res.arrayBuffer()).byteLength).toBe(4);
279
+ });
280
+
281
+ test("literal-slash path still serves (regression)", async () => {
282
+ const { store, inScopePath } = await setup();
283
+ const ctx = await tagScopeCtx(store, ["work"]);
284
+ const res = await handleStorage(
285
+ new Request(`http://localhost:1940/storage/${inScopePath}`, { method: "GET" }),
286
+ `/${inScopePath}`,
287
+ VAULT,
288
+ store,
289
+ ctx,
290
+ );
291
+ expect(res.status).toBe(200);
292
+ expect((await res.arrayBuffer()).byteLength).toBe(4);
293
+ });
294
+
295
+ test("encoded traversal %2E%2E%2F… → 403 (decoded `..` hits the traversal guard)", async () => {
296
+ const { store } = await setup();
297
+ const ctx = await tagScopeCtx(store, ["work"]);
298
+ // Fully percent-encoded `/a/../../../../../../etc/passwd`. Decode yields
299
+ // the literal traversal, which resolves outside assetsDir → 403.
300
+ const evilDecoded = "/a/../../../../../../etc/passwd";
301
+ const evilEncoded = "/a%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2Fetc%2Fpasswd";
302
+ expect(decodeURIComponent(evilEncoded)).toBe(evilDecoded);
303
+ const res = await handleStorage(
304
+ new Request(`http://localhost:1940/storage${evilEncoded}`, { method: "GET" }),
305
+ evilEncoded,
306
+ VAULT,
307
+ store,
308
+ ctx,
309
+ );
310
+ expect(res.status).toBe(403);
311
+ });
312
+
313
+ test("tag-scoped token + out-of-scope owning note → still 404 with an encoded path", async () => {
314
+ const { store, outScopePath } = await setup();
315
+ const ctx = await tagScopeCtx(store, ["work"]);
316
+ const { req, path } = getReqEncoded(outScopePath);
317
+ const res = await handleStorage(req, path, VAULT, store, ctx);
318
+ expect(res.status).toBe(404);
319
+ });
320
+
321
+ test("malformed `%` (e.g. /api/storage/2026%2) → 404, not 500", async () => {
322
+ const { store } = await setup();
323
+ const ctx = await tagScopeCtx(store, ["work"]);
324
+ // `%2` is not a valid percent-escape → decodeURIComponent throws → 404.
325
+ const bad = "/2026%2";
326
+ expect(() => decodeURIComponent(bad)).toThrow();
327
+ const res = await handleStorage(
328
+ new Request(`http://localhost:1940/storage${bad}`, { method: "GET" }),
329
+ bad,
330
+ VAULT,
331
+ store,
332
+ ctx,
333
+ );
334
+ expect(res.status).toBe(404);
335
+ });
336
+
337
+ test("double-encoded %252F → 404 (decodes ONCE to literal %2F, no slash, no second decode)", async () => {
338
+ const { store } = await setup();
339
+ const ctx = await tagScopeCtx(store, ["work"]);
340
+ // `%252F` → decodeURIComponent once → `%2F` (a literal `%2F`, NOT a slash).
341
+ // The single decode is deliberate: a second decode would turn this into a
342
+ // real slash and risk serving / re-looping. With one decode the path has
343
+ // no `/` separator, so the date/file match fails → 404.
344
+ const doubleEncoded = "/2026-05-28%252Ffile.bin";
345
+ expect(decodeURIComponent(doubleEncoded)).toBe("/2026-05-28%2Ffile.bin");
346
+ const res = await handleStorage(
347
+ new Request(`http://localhost:1940/storage${doubleEncoded}`, { method: "GET" }),
348
+ doubleEncoded,
349
+ VAULT,
350
+ store,
351
+ ctx,
352
+ );
353
+ expect(res.status).toBe(404);
354
+ });
355
+ });
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // POST parity (finding D — decode block must not change upload behavior).
359
+ //
360
+ // The `POST /upload` branch returns BEFORE the decode block, so a real upload
361
+ // is untouched. A POST to a non-`/upload` storage path with a malformed `%`
362
+ // falls past the upload branch into the decode (the `try/catch` is not method-
363
+ // gated), where the throw → 404 — the same status the pre-fix unconditional
364
+ // final 404 produced for this request. Pins that the decode doesn't turn a
365
+ // malformed-`%` POST into a 500 and keeps POST behavior at parity.
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe("storage POST parity (finding D)", () => {
369
+ test("POST to a malformed-`%` storage path → 404, unchanged by the GET-side decode", async () => {
370
+ const bad = "/2026%2";
371
+ const res = await handleStorage(
372
+ new Request(`http://localhost:1940/storage${bad}`, { method: "POST" }),
373
+ bad,
374
+ "default",
375
+ uploadStore,
376
+ );
377
+ expect(res.status).toBe(404);
378
+ });
379
+ });