@openparachute/vault 0.5.3-rc.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.parachute/module.json +14 -3
  2. package/core/src/mcp.ts +20 -0
  3. package/core/src/schema.ts +45 -1
  4. package/core/src/store.ts +66 -19
  5. package/core/src/tag-expand-axis.test.ts +301 -0
  6. package/core/src/tag-hierarchy.ts +80 -0
  7. package/core/src/triggers-store.test.ts +100 -0
  8. package/core/src/triggers-store.ts +165 -0
  9. package/core/src/types.ts +27 -1
  10. package/package.json +1 -1
  11. package/src/admin-spa.test.ts +100 -10
  12. package/src/admin-spa.ts +48 -3
  13. package/src/auto-transcribe.test.ts +51 -0
  14. package/src/auto-transcribe.ts +24 -6
  15. package/src/cli.ts +45 -18
  16. package/src/config.test.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/live-match.test.ts +198 -0
  19. package/src/live-match.ts +310 -0
  20. package/src/routes.ts +192 -78
  21. package/src/routing.test.ts +64 -0
  22. package/src/routing.ts +48 -1
  23. package/src/server.ts +49 -3
  24. package/src/subscribe.test.ts +588 -0
  25. package/src/subscribe.ts +248 -0
  26. package/src/subscriptions.ts +295 -0
  27. package/src/tag-expand-routes.test.ts +45 -0
  28. package/src/triggers-api.test.ts +533 -0
  29. package/src/triggers-api.ts +295 -0
  30. package/src/triggers.ts +93 -7
  31. package/src/vault-create.test.ts +35 -1
  32. package/src/vault-name.test.ts +61 -3
  33. package/src/vault-name.ts +62 -14
  34. package/src/vault-remove.test.ts +187 -0
  35. package/src/vault-store.ts +10 -3
  36. package/src/vault.test.ts +194 -0
  37. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  38. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  41. package/web/ui/dist/assets/index-DJL6Az--.js +0 -60
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Runtime trigger-registration API (frictionless-channel-setup PR 1).
3
+ *
4
+ * Covers:
5
+ * - CRUD over the REST surface (POST creates+persists+lists, DELETE removes,
6
+ * POST same name replaces).
7
+ * - Persistence across restart (loadAllTriggers → re-register → still fires).
8
+ * - Per-vault firing isolation (a trigger for vault A does NOT fire on a
9
+ * vault-B note event) — the load-bearing test.
10
+ * - JWT webhook auth (action.auth.bearer → Authorization: Bearer header).
11
+ * - Admin-scope enforcement (read/write token → 403, admin → 200).
12
+ * - Live registration (POST then a matching note fires WITHOUT a restart).
13
+ *
14
+ * Uses PARACHUTE_HOME override + a real hub-JWT mint fixture (JWKS server) so
15
+ * the admin-scope path exercises the surviving credential shape end-to-end,
16
+ * mirroring routing.test.ts.
17
+ */
18
+
19
+ import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test";
20
+ import { rmSync, existsSync, mkdirSync } from "fs";
21
+ import { join } from "path";
22
+ import { tmpdir } from "os";
23
+ import { generateKeyPair, exportJWK, SignJWT } from "jose";
24
+
25
+ const testDir = join(
26
+ tmpdir(),
27
+ `vault-triggers-api-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
28
+ );
29
+ process.env.PARACHUTE_HOME = testDir;
30
+
31
+ const { route } = await import("./routing.ts");
32
+ const { writeGlobalConfig, writeVaultConfig } = await import("./config.ts");
33
+ const { clearVaultStoreCache, getVaultStore, defaultHookRegistry } = await import("./vault-store.ts");
34
+ const {
35
+ loadVaultTriggers,
36
+ registerLiveTrigger,
37
+ clearLiveTriggers,
38
+ } = await import("./triggers-api.ts");
39
+ const { registerTriggers } = await import("./triggers.ts");
40
+ const { listTriggers, getTrigger } = await import("../core/src/triggers-store.ts");
41
+ const { resetJwksCache, resetRevocationCache } = await import("./hub-jwt.ts");
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Hub-JWT fixture (same shape as routing.test.ts).
45
+ // ---------------------------------------------------------------------------
46
+
47
+ let hubServer: ReturnType<typeof Bun.serve>;
48
+ let signingKey: CryptoKey;
49
+ let publicJwk: Record<string, unknown>;
50
+ let hubFixtureOrigin = "";
51
+ const KID = "triggers-api-test-k1";
52
+
53
+ async function mintJwt(vaultName: string, scopes: string[]): Promise<string> {
54
+ const iat = Math.floor(Date.now() / 1000);
55
+ return await new SignJWT({ scope: scopes.join(" "), client_id: "triggers-api-test" })
56
+ .setProtectedHeader({ alg: "RS256", kid: KID })
57
+ .setIssuer(hubServer ? `http://127.0.0.1:${hubServer.port}` : "")
58
+ .setSubject("triggers-api-test-user")
59
+ .setAudience(`vault.${vaultName}`)
60
+ .setIssuedAt(iat)
61
+ .setExpirationTime(iat + 60)
62
+ .setJti(`jti-${Math.random().toString(36).slice(2)}`)
63
+ .sign(signingKey);
64
+ }
65
+
66
+ function adminToken(vaultName: string) {
67
+ return mintJwt(vaultName, [`vault:${vaultName}:admin`]);
68
+ }
69
+
70
+ function createVault(name: string): void {
71
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // A local webhook receiver — records every hit (and its Authorization header).
76
+ // ---------------------------------------------------------------------------
77
+
78
+ interface WebhookHit {
79
+ url: string;
80
+ auth: string | null;
81
+ body: unknown;
82
+ }
83
+
84
+ let webhookServer: ReturnType<typeof Bun.serve>;
85
+ let hits: WebhookHit[] = [];
86
+ let webhookBase = "";
87
+
88
+ /** Await pending hook handlers: flush the queued microtask, then drain. */
89
+ async function settleHooks(): Promise<void> {
90
+ await Promise.resolve();
91
+ await new Promise((r) => setTimeout(r, 0));
92
+ await defaultHookRegistry.drain();
93
+ }
94
+
95
+ function reset(): void {
96
+ clearLiveTriggers();
97
+ defaultHookRegistry.clear();
98
+ clearVaultStoreCache();
99
+ hits = [];
100
+ if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
101
+ mkdirSync(testDir, { recursive: true });
102
+ mkdirSync(join(testDir, "vault", "data"), { recursive: true });
103
+ writeGlobalConfig({ port: 1940 });
104
+ if (hubFixtureOrigin) {
105
+ process.env.PARACHUTE_HUB_ORIGIN = hubFixtureOrigin;
106
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = hubFixtureOrigin;
107
+ }
108
+ resetJwksCache();
109
+ resetRevocationCache();
110
+ }
111
+
112
+ beforeAll(async () => {
113
+ const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
114
+ signingKey = privateKey;
115
+ const jwk = await exportJWK(publicKey);
116
+ publicJwk = { kty: "RSA", n: jwk.n, e: jwk.e, kid: KID, alg: "RS256", use: "sig" };
117
+ hubServer = Bun.serve({
118
+ port: 0,
119
+ fetch(req) {
120
+ const url = new URL(req.url);
121
+ if (url.pathname === "/.well-known/jwks.json") return Response.json({ keys: [publicJwk] });
122
+ if (url.pathname === "/.well-known/parachute-revocation.json") {
123
+ return Response.json({ generated_at: new Date().toISOString(), jtis: [] });
124
+ }
125
+ return new Response("not found", { status: 404 });
126
+ },
127
+ });
128
+ hubFixtureOrigin = `http://127.0.0.1:${hubServer.port}`;
129
+
130
+ webhookServer = Bun.serve({
131
+ port: 0,
132
+ async fetch(req) {
133
+ let body: unknown = null;
134
+ try {
135
+ body = await req.json();
136
+ } catch {
137
+ body = null;
138
+ }
139
+ hits.push({
140
+ url: req.url,
141
+ auth: req.headers.get("authorization"),
142
+ body,
143
+ });
144
+ // Standard json webhook response — no content/metadata mutation needed.
145
+ return Response.json({});
146
+ },
147
+ });
148
+ webhookBase = `http://127.0.0.1:${webhookServer.port}`;
149
+ });
150
+
151
+ beforeEach(() => {
152
+ reset();
153
+ });
154
+
155
+ afterEach(() => {
156
+ clearLiveTriggers();
157
+ defaultHookRegistry.clear();
158
+ });
159
+
160
+ afterAll(() => {
161
+ clearVaultStoreCache();
162
+ hubServer?.stop(true);
163
+ webhookServer?.stop(true);
164
+ delete process.env.PARACHUTE_HUB_ORIGIN;
165
+ delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
166
+ if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
167
+ });
168
+
169
+ function triggerBody(name: string, extra: Record<string, unknown> = {}) {
170
+ return {
171
+ name,
172
+ events: ["created", "updated"],
173
+ when: { tags: ["channel-message"] },
174
+ action: { webhook: `${webhookBase}/hook`, ...extra },
175
+ };
176
+ }
177
+
178
+ async function post(vault: string, token: string, body: unknown): Promise<Response> {
179
+ const path = `/vault/${vault}/api/triggers`;
180
+ return route(
181
+ new Request(`http://localhost:1940${path}`, {
182
+ method: "POST",
183
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
184
+ body: JSON.stringify(body),
185
+ }),
186
+ path,
187
+ );
188
+ }
189
+
190
+ async function get(vault: string, token: string): Promise<Response> {
191
+ const path = `/vault/${vault}/api/triggers`;
192
+ return route(
193
+ new Request(`http://localhost:1940${path}`, {
194
+ headers: { Authorization: `Bearer ${token}` },
195
+ }),
196
+ path,
197
+ );
198
+ }
199
+
200
+ async function getOne(vault: string, token: string, name: string): Promise<Response> {
201
+ const path = `/vault/${vault}/api/triggers/${name}`;
202
+ return route(
203
+ new Request(`http://localhost:1940${path}`, {
204
+ headers: { Authorization: `Bearer ${token}` },
205
+ }),
206
+ path,
207
+ );
208
+ }
209
+
210
+ async function del(vault: string, token: string, name: string): Promise<Response> {
211
+ const path = `/vault/${vault}/api/triggers/${name}`;
212
+ return route(
213
+ new Request(`http://localhost:1940${path}`, {
214
+ method: "DELETE",
215
+ headers: { Authorization: `Bearer ${token}` },
216
+ }),
217
+ path,
218
+ );
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // CRUD
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe("triggers REST CRUD", () => {
226
+ test("POST creates + persists + lists", async () => {
227
+ createVault("alpha");
228
+ const token = await adminToken("alpha");
229
+
230
+ const res = await post("alpha", token, triggerBody("inbound"));
231
+ expect(res.status).toBe(200);
232
+ const created = await res.json();
233
+ expect(created.trigger.name).toBe("inbound");
234
+ expect(created.trigger.created_at).toBeTruthy();
235
+
236
+ // Persisted to the table.
237
+ const rows = listTriggers(getVaultStore("alpha").db);
238
+ expect(rows.map((r) => r.name)).toEqual(["inbound"]);
239
+
240
+ // Listed via GET.
241
+ const listRes = await get("alpha", token);
242
+ expect(listRes.status).toBe(200);
243
+ const listed = await listRes.json();
244
+ expect(listed.triggers.map((t: { name: string }) => t.name)).toEqual(["inbound"]);
245
+ });
246
+
247
+ test("DELETE removes the row + 404 on a missing name", async () => {
248
+ createVault("alpha");
249
+ const token = await adminToken("alpha");
250
+ await post("alpha", token, triggerBody("inbound"));
251
+
252
+ const res = await del("alpha", token, "inbound");
253
+ expect(res.status).toBe(200);
254
+ expect(listTriggers(getVaultStore("alpha").db)).toHaveLength(0);
255
+
256
+ const missing = await del("alpha", token, "inbound");
257
+ expect(missing.status).toBe(404);
258
+ });
259
+
260
+ test("POST with the same name replaces (upsert by name)", async () => {
261
+ createVault("alpha");
262
+ const token = await adminToken("alpha");
263
+
264
+ await post("alpha", token, triggerBody("inbound", { timeout: 1000 }));
265
+ await post("alpha", token, triggerBody("inbound", { timeout: 5000 }));
266
+
267
+ const rows = listTriggers(getVaultStore("alpha").db);
268
+ expect(rows).toHaveLength(1);
269
+ expect(rows[0]!.action.timeout).toBe(5000);
270
+ });
271
+
272
+ test("POST with an invalid webhook URL → 400", async () => {
273
+ createVault("alpha");
274
+ const token = await adminToken("alpha");
275
+ const res = await post("alpha", token, {
276
+ name: "bad",
277
+ when: { tags: ["x"] },
278
+ action: { webhook: "ftp://nope" },
279
+ });
280
+ expect(res.status).toBe(400);
281
+ });
282
+ });
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Admin-scope enforcement
286
+ // ---------------------------------------------------------------------------
287
+
288
+ describe("triggers admin-scope", () => {
289
+ test("read token → 403", async () => {
290
+ createVault("alpha");
291
+ const readTok = await mintJwt("alpha", ["vault:alpha:read"]);
292
+ const res = await get("alpha", readTok);
293
+ expect(res.status).toBe(403);
294
+ });
295
+
296
+ test("write token → 403 on POST", async () => {
297
+ createVault("alpha");
298
+ const writeTok = await mintJwt("alpha", ["vault:alpha:write"]);
299
+ const res = await post("alpha", writeTok, triggerBody("inbound"));
300
+ expect(res.status).toBe(403);
301
+ });
302
+
303
+ test("admin token → 200", async () => {
304
+ createVault("alpha");
305
+ const token = await adminToken("alpha");
306
+ const res = await post("alpha", token, triggerBody("inbound"));
307
+ expect(res.status).toBe(200);
308
+ });
309
+ });
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Live registration (no restart) + JWT webhook auth
313
+ // ---------------------------------------------------------------------------
314
+
315
+ describe("triggers live registration", () => {
316
+ test("POST then a matching note fires the webhook WITHOUT a restart", async () => {
317
+ createVault("alpha");
318
+ const token = await adminToken("alpha");
319
+ await post("alpha", token, triggerBody("inbound"));
320
+
321
+ const store = getVaultStore("alpha");
322
+ await store.createNote("hello from alpha", { tags: ["channel-message"] });
323
+ await settleHooks();
324
+
325
+ expect(hits).toHaveLength(1);
326
+ expect(hits[0]!.url).toContain("/hook");
327
+ });
328
+
329
+ test("action.auth.bearer → Authorization: Bearer on the fired request", async () => {
330
+ createVault("alpha");
331
+ const token = await adminToken("alpha");
332
+ await post("alpha", token, triggerBody("inbound", { auth: { bearer: "hub-jwt-xyz" } }));
333
+
334
+ const store = getVaultStore("alpha");
335
+ await store.createNote("ping", { tags: ["channel-message"] });
336
+ await settleHooks();
337
+
338
+ expect(hits).toHaveLength(1);
339
+ expect(hits[0]!.auth).toBe("Bearer hub-jwt-xyz");
340
+ });
341
+ });
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Secret redaction on read (M1) — action.auth.bearer is one-way: never
345
+ // readable back over GET, but the live fire still carries the real token.
346
+ // ---------------------------------------------------------------------------
347
+
348
+ describe("triggers webhook-auth redaction", () => {
349
+ test("GET list redacts action.auth.bearer; DB keeps the real value", async () => {
350
+ createVault("alpha");
351
+ const token = await adminToken("alpha");
352
+ await post("alpha", token, triggerBody("inbound", { auth: { bearer: "super-secret-jwt" } }));
353
+
354
+ const res = await get("alpha", token);
355
+ expect(res.status).toBe(200);
356
+ const body = await res.json();
357
+ expect(body.triggers).toHaveLength(1);
358
+ // Key present (auth IS configured) but value redacted.
359
+ expect(body.triggers[0].action.auth.bearer).toBe("[REDACTED]");
360
+
361
+ // The stored row keeps the real bearer — redaction is response-only.
362
+ expect(getTrigger(getVaultStore("alpha").db, "inbound")!.action.auth!.bearer).toBe(
363
+ "super-secret-jwt",
364
+ );
365
+ });
366
+
367
+ test("GET :name redacts action.auth.bearer", async () => {
368
+ createVault("alpha");
369
+ const token = await adminToken("alpha");
370
+ await post("alpha", token, triggerBody("inbound", { auth: { bearer: "super-secret-jwt" } }));
371
+
372
+ const res = await getOne("alpha", token, "inbound");
373
+ expect(res.status).toBe(200);
374
+ const body = await res.json();
375
+ expect(body.trigger.action.auth.bearer).toBe("[REDACTED]");
376
+ });
377
+
378
+ test("POST response echoes a redacted bearer (does not re-expose the input)", async () => {
379
+ createVault("alpha");
380
+ const token = await adminToken("alpha");
381
+ const res = await post("alpha", token, triggerBody("inbound", { auth: { bearer: "secret" } }));
382
+ const body = await res.json();
383
+ expect(body.trigger.action.auth.bearer).toBe("[REDACTED]");
384
+ });
385
+
386
+ test("redaction is response-only — the live webhook still fires with the REAL bearer", async () => {
387
+ createVault("alpha");
388
+ const token = await adminToken("alpha");
389
+ await post("alpha", token, triggerBody("inbound", { auth: { bearer: "the-real-token" } }));
390
+
391
+ // Read it back over GET (redacted) — must NOT change the fire behavior.
392
+ await get("alpha", token);
393
+ await getOne("alpha", token, "inbound");
394
+
395
+ await getVaultStore("alpha").createNote("fire", { tags: ["channel-message"] });
396
+ await settleHooks();
397
+
398
+ expect(hits).toHaveLength(1);
399
+ expect(hits[0]!.auth).toBe("Bearer the-real-token");
400
+ });
401
+
402
+ test("a trigger WITHOUT auth lists with no auth key (no spurious [REDACTED])", async () => {
403
+ createVault("alpha");
404
+ const token = await adminToken("alpha");
405
+ await post("alpha", token, triggerBody("inbound"));
406
+ const body = await (await get("alpha", token)).json();
407
+ expect(body.triggers[0].action.auth).toBeUndefined();
408
+ });
409
+ });
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // action.auth.bearer input validation (M2)
413
+ // ---------------------------------------------------------------------------
414
+
415
+ describe("triggers auth.bearer validation", () => {
416
+ test("POST with auth.bearer = 42 → 400", async () => {
417
+ createVault("alpha");
418
+ const token = await adminToken("alpha");
419
+ const res = await post("alpha", token, triggerBody("bad", { auth: { bearer: 42 } }));
420
+ expect(res.status).toBe(400);
421
+ });
422
+
423
+ test("POST with auth.bearer = {} → 400", async () => {
424
+ createVault("alpha");
425
+ const token = await adminToken("alpha");
426
+ const res = await post("alpha", token, triggerBody("bad", { auth: { bearer: {} } }));
427
+ expect(res.status).toBe(400);
428
+ });
429
+
430
+ test("POST with auth.bearer = '' (empty) → 400", async () => {
431
+ createVault("alpha");
432
+ const token = await adminToken("alpha");
433
+ const res = await post("alpha", token, triggerBody("bad", { auth: { bearer: "" } }));
434
+ expect(res.status).toBe(400);
435
+ });
436
+
437
+ test("POST with a valid string bearer → 200", async () => {
438
+ createVault("alpha");
439
+ const token = await adminToken("alpha");
440
+ const res = await post("alpha", token, triggerBody("ok", { auth: { bearer: "jwt" } }));
441
+ expect(res.status).toBe(200);
442
+ });
443
+
444
+ test("POST with empty auth object ({}) → 200 (no bearer to validate)", async () => {
445
+ createVault("alpha");
446
+ const token = await adminToken("alpha");
447
+ const res = await post("alpha", token, triggerBody("ok2", { auth: {} }));
448
+ expect(res.status).toBe(200);
449
+ });
450
+ });
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Persistence across restart
454
+ // ---------------------------------------------------------------------------
455
+
456
+ describe("triggers persistence across restart", () => {
457
+ test("loadVaultTriggers re-registers a persisted trigger so it fires again", async () => {
458
+ createVault("alpha");
459
+ const token = await adminToken("alpha");
460
+ await post("alpha", token, triggerBody("inbound"));
461
+
462
+ // Simulate a restart: drop every live hook + the store cache, then
463
+ // re-load from the table (the boot path).
464
+ clearLiveTriggers();
465
+ defaultHookRegistry.clear();
466
+ clearVaultStoreCache();
467
+
468
+ const store = getVaultStore("alpha");
469
+ const n = loadVaultTriggers("alpha", store);
470
+ expect(n).toBe(1);
471
+
472
+ await store.createNote("after restart", { tags: ["channel-message"] });
473
+ await settleHooks();
474
+
475
+ expect(hits).toHaveLength(1);
476
+ });
477
+ });
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // Per-vault firing isolation — the load-bearing test
481
+ // ---------------------------------------------------------------------------
482
+
483
+ describe("triggers per-vault firing isolation", () => {
484
+ test("a trigger registered for vault A does NOT fire on a vault-B note event", async () => {
485
+ createVault("alpha");
486
+ createVault("beta");
487
+
488
+ // Register the trigger for ALPHA only.
489
+ const alphaTok = await adminToken("alpha");
490
+ await post("alpha", alphaTok, triggerBody("inbound"));
491
+
492
+ // A matching note in BETA must NOT fire the webhook.
493
+ const betaStore = getVaultStore("beta");
494
+ await betaStore.createNote("from beta", { tags: ["channel-message"] });
495
+ await settleHooks();
496
+ expect(hits).toHaveLength(0);
497
+
498
+ // The same note in ALPHA fires exactly once.
499
+ const alphaStore = getVaultStore("alpha");
500
+ await alphaStore.createNote("from alpha", { tags: ["channel-message"] });
501
+ await settleHooks();
502
+ expect(hits).toHaveLength(1);
503
+ });
504
+ });
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // config.yaml back-compat — global triggers still load + fire
508
+ // ---------------------------------------------------------------------------
509
+
510
+ describe("config.yaml triggers back-compat", () => {
511
+ test("a config.yaml (global, unscoped) trigger fires for any vault", async () => {
512
+ createVault("alpha");
513
+ createVault("beta");
514
+
515
+ // Register a global trigger the way server.ts:registerConfiguredTriggers
516
+ // does — no vaultName, so it fires for every vault.
517
+ registerTriggers(defaultHookRegistry, [
518
+ {
519
+ name: "global-hook",
520
+ events: ["created", "updated"],
521
+ when: { tags: ["channel-message"] },
522
+ action: { webhook: `${webhookBase}/hook` },
523
+ },
524
+ ]);
525
+
526
+ await getVaultStore("alpha").createNote("a", { tags: ["channel-message"] });
527
+ await getVaultStore("beta").createNote("b", { tags: ["channel-message"] });
528
+ await settleHooks();
529
+
530
+ // Fired for BOTH vaults — global, not vault-scoped.
531
+ expect(hits).toHaveLength(2);
532
+ });
533
+ });