@openparachute/vault 0.6.0-rc.1 → 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 (91) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +7 -7
  3. package/core/src/core.test.ts +279 -26
  4. package/core/src/expand-visibility.test.ts +102 -0
  5. package/core/src/expand.ts +31 -3
  6. package/core/src/indexed-fields.ts +1 -1
  7. package/core/src/link-count.test.ts +301 -0
  8. package/core/src/links.ts +97 -2
  9. package/core/src/mcp.ts +201 -33
  10. package/core/src/notes.ts +44 -8
  11. package/core/src/obsidian-alignment.test.ts +375 -0
  12. package/core/src/obsidian.ts +234 -14
  13. package/core/src/portable-md.test.ts +40 -0
  14. package/core/src/portable-md.ts +142 -16
  15. package/core/src/schema.ts +58 -11
  16. package/core/src/store.ts +69 -22
  17. package/core/src/tag-expand-axis.test.ts +301 -0
  18. package/core/src/tag-hierarchy.ts +80 -0
  19. package/core/src/tag-schemas.ts +61 -46
  20. package/core/src/triggers-store.test.ts +100 -0
  21. package/core/src/triggers-store.ts +165 -0
  22. package/core/src/types.ts +68 -4
  23. package/core/src/vault-projection.ts +20 -0
  24. package/core/src/wikilinks.ts +2 -2
  25. package/package.json +2 -3
  26. package/src/admin-spa.test.ts +100 -10
  27. package/src/admin-spa.ts +48 -3
  28. package/src/auth-hub-jwt.test.ts +8 -1
  29. package/src/auth-status.ts +2 -2
  30. package/src/auth.test.ts +39 -3
  31. package/src/auth.ts +31 -2
  32. package/src/auto-transcribe.test.ts +51 -0
  33. package/src/auto-transcribe.ts +24 -6
  34. package/src/autostart.test.ts +75 -0
  35. package/src/autostart.ts +84 -0
  36. package/src/cli.ts +434 -140
  37. package/src/config.test.ts +109 -0
  38. package/src/config.ts +157 -10
  39. package/src/export-watch.test.ts +23 -0
  40. package/src/export-watch.ts +14 -0
  41. package/src/git-preflight.test.ts +70 -0
  42. package/src/git-preflight.ts +68 -0
  43. package/src/hub-jwt.test.ts +75 -2
  44. package/src/hub-jwt.ts +43 -6
  45. package/src/init-summary.test.ts +120 -5
  46. package/src/init-summary.ts +67 -25
  47. package/src/live-match.test.ts +198 -0
  48. package/src/live-match.ts +310 -0
  49. package/src/mcp-install.test.ts +93 -0
  50. package/src/mcp-install.ts +106 -0
  51. package/src/mcp-tools.ts +80 -7
  52. package/src/mirror-config.test.ts +14 -0
  53. package/src/mirror-config.ts +11 -0
  54. package/src/mirror-import.test.ts +110 -0
  55. package/src/mirror-import.ts +71 -13
  56. package/src/mirror-manager.test.ts +51 -0
  57. package/src/mirror-manager.ts +73 -11
  58. package/src/mirror-routes.test.ts +463 -1
  59. package/src/mirror-routes.ts +474 -4
  60. package/src/oauth-discovery.test.ts +55 -0
  61. package/src/oauth-discovery.ts +24 -5
  62. package/src/routes.ts +696 -121
  63. package/src/routing.test.ts +451 -5
  64. package/src/routing.ts +113 -5
  65. package/src/scopes.ts +1 -1
  66. package/src/server.ts +66 -4
  67. package/src/storage.test.ts +162 -0
  68. package/src/subscribe.test.ts +588 -0
  69. package/src/subscribe.ts +248 -0
  70. package/src/subscriptions.ts +295 -0
  71. package/src/tag-expand-routes.test.ts +45 -0
  72. package/src/tag-scope.ts +68 -1
  73. package/src/token-store.ts +7 -7
  74. package/src/transcription-worker.test.ts +471 -5
  75. package/src/transcription-worker.ts +212 -44
  76. package/src/triggers-api.test.ts +533 -0
  77. package/src/triggers-api.ts +295 -0
  78. package/src/triggers.ts +93 -7
  79. package/src/usage.test.ts +362 -0
  80. package/src/usage.ts +318 -0
  81. package/src/vault-create.test.ts +340 -12
  82. package/src/vault-name.test.ts +61 -3
  83. package/src/vault-name.ts +62 -14
  84. package/src/vault-remove.test.ts +187 -0
  85. package/src/vault-store.ts +10 -3
  86. package/src/vault.test.ts +1353 -62
  87. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  88. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  89. package/web/ui/dist/index.html +2 -2
  90. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  91. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Runtime trigger-registration REST API (frictionless-channel-setup PR 1).
3
+ *
4
+ * Mounts under `/vault/<v>/api/triggers`, ADMIN-scoped. Registering a webhook
5
+ * trigger exfiltrates note data to an arbitrary URL — that's an admin
6
+ * capability, not `write` — so every method here gates on `vault:admin`
7
+ * (or the narrowed `vault:<v>:admin`). The gate is enforced in `handleTriggers`
8
+ * itself rather than the method-based verb gate in routing.ts, because a GET
9
+ * here must still require admin (read is too weak).
10
+ *
11
+ * POST /api/triggers — upsert {name, events, when, action};
12
+ * validate webhook URL; persist; (re-)register
13
+ * live on the shared hook registry, vault-scoped.
14
+ * GET /api/triggers — list this vault's persisted triggers.
15
+ * DELETE /api/triggers/:name — unregister the live hook + delete the row.
16
+ *
17
+ * A process-wide registry of unregister-fns keyed by (vault, name) lets
18
+ * POST-replace and DELETE unwind the prior live hook before re-registering.
19
+ */
20
+
21
+ import type { SqliteStore } from "../core/src/store.ts";
22
+ import { defaultHookRegistry } from "../core/src/hooks.ts";
23
+ import {
24
+ upsertTrigger,
25
+ listTriggers,
26
+ getTrigger,
27
+ deleteTrigger,
28
+ loadAllTriggers,
29
+ type StoredTrigger,
30
+ type TriggerInput,
31
+ } from "../core/src/triggers-store.ts";
32
+ import { registerVaultTrigger } from "./triggers.ts";
33
+ import { hasScopeForVault, SCOPE_ADMIN } from "./scopes.ts";
34
+ import type { AuthResult } from "./auth.ts";
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Live registry of unregister-fns, keyed by `${vault}${name}` (SPACE
38
+ // separator). Neither vault names (validated on create) nor trigger names
39
+ // (NAME_RE below — [A-Za-z0-9][A-Za-z0-9_-]*) admit a space, so the composite
40
+ // key can't collide. Survives across requests in the long-lived server
41
+ // process; on boot it's repopulated by `loadVaultTriggers`.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const liveTriggers = new Map<string, () => void>();
45
+
46
+ function liveKey(vaultName: string, triggerName: string): string {
47
+ return `${vaultName}${triggerName}`;
48
+ }
49
+
50
+ /** Unregister + forget any live hook for (vault, name). No-op if absent. */
51
+ function unwindLiveTrigger(vaultName: string, triggerName: string): void {
52
+ const key = liveKey(vaultName, triggerName);
53
+ const fn = liveTriggers.get(key);
54
+ if (fn) {
55
+ try {
56
+ fn();
57
+ } catch {
58
+ // unregister is a pure array splice — failures shouldn't happen, but a
59
+ // throw here must not block delete/replace. Swallow + drop the entry.
60
+ }
61
+ liveTriggers.delete(key);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Register a stored trigger live on the shared hook registry, vault-scoped,
67
+ * replacing any prior same-(vault,name) hook. Exported for the boot path.
68
+ */
69
+ export function registerLiveTrigger(vaultName: string, trigger: StoredTrigger): void {
70
+ unwindLiveTrigger(vaultName, trigger.name);
71
+ const unregister = registerVaultTrigger(defaultHookRegistry, trigger, vaultName);
72
+ liveTriggers.set(liveKey(vaultName, trigger.name), unregister);
73
+ }
74
+
75
+ /**
76
+ * Boot loader: register every persisted trigger for a vault, scoped to it.
77
+ * Idempotent — replaces any existing live registration per (vault, name).
78
+ * Called for each vault at server start (alongside config.yaml triggers).
79
+ */
80
+ export function loadVaultTriggers(vaultName: string, store: SqliteStore): number {
81
+ const triggers = loadAllTriggers(store.db);
82
+ for (const t of triggers) {
83
+ registerLiveTrigger(vaultName, t);
84
+ }
85
+ return triggers.length;
86
+ }
87
+
88
+ /** Test-only: clear the live registry (unregistering each hook). */
89
+ export function clearLiveTriggers(): void {
90
+ for (const [, fn] of liveTriggers) {
91
+ try {
92
+ fn();
93
+ } catch {
94
+ /* swallow */
95
+ }
96
+ }
97
+ liveTriggers.clear();
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Validation
102
+ // ---------------------------------------------------------------------------
103
+
104
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
105
+
106
+ function validateInput(body: unknown):
107
+ | { ok: true; input: TriggerInput }
108
+ | { ok: false; message: string } {
109
+ if (typeof body !== "object" || body === null) {
110
+ return { ok: false, message: "request body must be a JSON object" };
111
+ }
112
+ const b = body as Record<string, unknown>;
113
+
114
+ if (typeof b.name !== "string" || !NAME_RE.test(b.name)) {
115
+ return {
116
+ ok: false,
117
+ message:
118
+ "`name` is required and must match [A-Za-z0-9][A-Za-z0-9_-]* (no whitespace, NUL, or leading punctuation)",
119
+ };
120
+ }
121
+
122
+ // events — optional; default applied at the store. When present, must be a
123
+ // subset of {created, updated}.
124
+ let events: Array<"created" | "updated"> | undefined;
125
+ if (b.events !== undefined) {
126
+ if (
127
+ !Array.isArray(b.events) ||
128
+ !b.events.every((e) => e === "created" || e === "updated")
129
+ ) {
130
+ return { ok: false, message: "`events` must be an array of 'created'/'updated'" };
131
+ }
132
+ events = b.events as Array<"created" | "updated">;
133
+ }
134
+
135
+ if (typeof b.when !== "object" || b.when === null || Array.isArray(b.when)) {
136
+ return { ok: false, message: "`when` is required and must be an object" };
137
+ }
138
+
139
+ if (typeof b.action !== "object" || b.action === null || Array.isArray(b.action)) {
140
+ return { ok: false, message: "`action` is required and must be an object" };
141
+ }
142
+ const action = b.action as Record<string, unknown>;
143
+ if (typeof action.webhook !== "string" || action.webhook.length === 0) {
144
+ return { ok: false, message: "`action.webhook` is required and must be a string URL" };
145
+ }
146
+ // Reuse the same http/https validation registerTriggers does, but fail the
147
+ // request here (400) rather than silently skipping at registration time.
148
+ try {
149
+ const url = new URL(action.webhook);
150
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
151
+ return {
152
+ ok: false,
153
+ message: `\`action.webhook\` must use http or https (got ${url.protocol})`,
154
+ };
155
+ }
156
+ } catch {
157
+ return { ok: false, message: `\`action.webhook\` is not a valid URL: ${action.webhook}` };
158
+ }
159
+ if (action.auth !== undefined) {
160
+ if (typeof action.auth !== "object" || action.auth === null || Array.isArray(action.auth)) {
161
+ return { ok: false, message: "`action.auth` must be an object when present" };
162
+ }
163
+ // `bearer` is the only auth field today. When present it MUST be a
164
+ // non-empty string — a non-string (number/object/false/null) would either
165
+ // silently no-op or serialize as `[object Object]` into the Authorization
166
+ // header at fire time. Reject at the door.
167
+ const auth = action.auth as Record<string, unknown>;
168
+ if (auth.bearer !== undefined && (typeof auth.bearer !== "string" || auth.bearer.length === 0)) {
169
+ return {
170
+ ok: false,
171
+ message: "`action.auth.bearer` must be a non-empty string when present",
172
+ };
173
+ }
174
+ }
175
+
176
+ return {
177
+ ok: true,
178
+ input: {
179
+ name: b.name,
180
+ events,
181
+ when: b.when as TriggerInput["when"],
182
+ action: b.action as TriggerInput["action"],
183
+ },
184
+ };
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Response shaping
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Redact the webhook auth secret before serializing a trigger into ANY HTTP
193
+ * response. A registration credential (`action.auth.bearer`, typically a hub
194
+ * JWT) is a one-way input: the caller supplies it, but it must never be
195
+ * readable back over GET (or echoed by POST). We keep the `bearer` KEY present
196
+ * — set to the sentinel `"[REDACTED]"` — so clients can still see that auth IS
197
+ * configured, just not its value.
198
+ *
199
+ * Response-only: the DB row and the live webhook-fire path keep the real
200
+ * bearer (verified by the per-vault firing test, which asserts the fired
201
+ * request still carries the real `Authorization: Bearer <token>`).
202
+ */
203
+ const REDACTED = "[REDACTED]";
204
+
205
+ export function redactTrigger(t: StoredTrigger): StoredTrigger {
206
+ const bearer = t.action.auth?.bearer;
207
+ if (bearer === undefined) return t;
208
+ return {
209
+ ...t,
210
+ action: {
211
+ ...t.action,
212
+ auth: { ...t.action.auth, bearer: REDACTED },
213
+ },
214
+ };
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // HTTP handler
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Handle `/api/triggers` and `/api/triggers/:name`. `subPath` is the portion
223
+ * AFTER `/triggers` (e.g. "" for the collection, "/my-trigger" for an item).
224
+ * Admin-scoped — the gate lives here (not the routing.ts verb gate) so GET
225
+ * also requires admin.
226
+ */
227
+ export async function handleTriggers(
228
+ req: Request,
229
+ store: SqliteStore,
230
+ subPath: string,
231
+ vaultName: string,
232
+ auth: AuthResult,
233
+ ): Promise<Response> {
234
+ // Admin gate — every method. A webhook trigger exfiltrates note data, so
235
+ // even listing/reading is an admin capability here.
236
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
237
+ return Response.json(
238
+ {
239
+ error: "Forbidden",
240
+ error_type: "insufficient_scope",
241
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace(
242
+ "vault:",
243
+ `vault:${vaultName}:`,
244
+ )}').`,
245
+ required_scope: SCOPE_ADMIN,
246
+ granted_scopes: auth.scopes,
247
+ },
248
+ { status: 403 },
249
+ );
250
+ }
251
+
252
+ const itemName = subPath.startsWith("/") ? decodeURIComponent(subPath.slice(1)) : "";
253
+
254
+ // Collection: /api/triggers
255
+ if (itemName === "") {
256
+ if (req.method === "GET") {
257
+ return Response.json({ triggers: listTriggers(store.db).map(redactTrigger) });
258
+ }
259
+ if (req.method === "POST") {
260
+ let body: unknown;
261
+ try {
262
+ body = await req.json();
263
+ } catch {
264
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
265
+ }
266
+ const validated = validateInput(body);
267
+ if (!validated.ok) {
268
+ return Response.json({ error: validated.message }, { status: 400 });
269
+ }
270
+ // Persist (upsert by name) then (re-)register live, vault-scoped. The
271
+ // live registration replaces any prior same-name hook for this vault.
272
+ const stored = upsertTrigger(store.db, validated.input);
273
+ registerLiveTrigger(vaultName, stored);
274
+ return Response.json({ trigger: redactTrigger(stored) }, { status: 200 });
275
+ }
276
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
277
+ }
278
+
279
+ // Item: /api/triggers/:name
280
+ if (req.method === "DELETE") {
281
+ const existed = getTrigger(store.db, itemName) !== null;
282
+ unwindLiveTrigger(vaultName, itemName);
283
+ const removed = deleteTrigger(store.db, itemName);
284
+ if (!existed && !removed) {
285
+ return Response.json({ error: "Not found", name: itemName }, { status: 404 });
286
+ }
287
+ return Response.json({ deleted: itemName });
288
+ }
289
+ if (req.method === "GET") {
290
+ const t = getTrigger(store.db, itemName);
291
+ if (!t) return Response.json({ error: "Not found", name: itemName }, { status: 404 });
292
+ return Response.json({ trigger: redactTrigger(t) });
293
+ }
294
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
295
+ }
package/src/triggers.ts CHANGED
@@ -31,12 +31,25 @@ import crypto from "node:crypto";
31
31
  import type { Note, Store, Attachment } from "../core/src/types.ts";
32
32
  import type { HookRegistry, HookEvent, NoteHookPayload } from "../core/src/hooks.ts";
33
33
  import type { TriggerConfig, TriggerWhen } from "./config.ts";
34
+ import type { StoredTrigger } from "../core/src/triggers-store.ts";
34
35
  import { getVaultNameForStore } from "./vault-store.ts";
35
36
  import { assetsDir } from "./routes.ts";
36
37
  import { appendContextPart, fetchContextEntries, type ContextPayload } from "./context.ts";
37
38
 
38
39
  const DEFAULT_TIMEOUT = 60_000;
39
40
 
41
+ /**
42
+ * Build the optional auth headers for a trigger's webhook POST. When
43
+ * `action.auth.bearer` is set we send `Authorization: Bearer <bearer>` (the
44
+ * JWT webhook-auth path, frictionless-channel-setup PR 1). Returns an empty
45
+ * object otherwise — back-compat with webhook URLs carrying a `?secret=`
46
+ * query param, which is unaffected by this.
47
+ */
48
+ function buildAuthHeaders(action: TriggerConfig["action"]): Record<string, string> {
49
+ const bearer = action.auth?.bearer;
50
+ return bearer ? { Authorization: `Bearer ${bearer}` } : {};
51
+ }
52
+
40
53
  export interface WebhookResponse {
41
54
  content?: string;
42
55
  metadata?: Record<string, unknown>;
@@ -163,11 +176,12 @@ async function dispatchJson(
163
176
  existingMeta: Record<string, unknown>,
164
177
  hookEvent: HookEvent | undefined,
165
178
  context: ContextPayload | null,
179
+ authHeaders: Record<string, string>,
166
180
  signal: AbortSignal,
167
181
  ): Promise<DispatchResult> {
168
182
  const resp = await fetch(url, {
169
183
  method: "POST",
170
- headers: { "Content-Type": "application/json" },
184
+ headers: { "Content-Type": "application/json", ...authHeaders },
171
185
  body: JSON.stringify({
172
186
  trigger: trigger.name,
173
187
  event: hookEvent ?? "updated",
@@ -207,6 +221,7 @@ async function dispatchAttachment(
207
221
  attachments: Attachment[],
208
222
  store: Store,
209
223
  context: ContextPayload | null,
224
+ authHeaders: Record<string, string>,
210
225
  signal: AbortSignal,
211
226
  ): Promise<DispatchResult> {
212
227
  const assetsRoot = resolveAssetsDir(store);
@@ -223,7 +238,9 @@ async function dispatchAttachment(
223
238
  form.append("file", file);
224
239
  if (context) appendContextPart(form, context);
225
240
 
226
- const resp = await fetch(url, { method: "POST", body: form, signal });
241
+ // multipart boundary is set by fetch from the FormData body; only the auth
242
+ // header is added here (no Content-Type — that would clobber the boundary).
243
+ const resp = await fetch(url, { method: "POST", body: form, headers: authHeaders, signal });
227
244
  if (!resp.ok) {
228
245
  throw new Error(`webhook returned ${resp.status}: ${await resp.text().catch(() => "")}`);
229
246
  }
@@ -244,6 +261,7 @@ async function dispatchContent(
244
261
  url: string,
245
262
  note: Note,
246
263
  store: Store,
264
+ authHeaders: Record<string, string>,
247
265
  signal: AbortSignal,
248
266
  ): Promise<DispatchResult> {
249
267
  if (!note.content || !note.content.trim()) {
@@ -252,7 +270,7 @@ async function dispatchContent(
252
270
 
253
271
  const resp = await fetch(url, {
254
272
  method: "POST",
255
- headers: { "Content-Type": "application/json" },
273
+ headers: { "Content-Type": "application/json", ...authHeaders },
256
274
  body: JSON.stringify({ input: note.content }),
257
275
  signal,
258
276
  });
@@ -280,15 +298,40 @@ async function dispatchContent(
280
298
  // Registration
281
299
  // ---------------------------------------------------------------------------
282
300
 
301
+ export interface RegisterTriggersOptions {
302
+ logger?: { error: (...args: unknown[]) => void; info?: (...args: unknown[]) => void };
303
+ /**
304
+ * When set, the registered handler early-returns unless the firing event's
305
+ * vault (resolved via `getVaultNameForStore(store)`) equals this value.
306
+ * Used by the runtime per-vault trigger system: a trigger registered for
307
+ * vault A never acts on a vault-B note event. `config.yaml` triggers omit
308
+ * this and stay global (fire for every vault). See `registerVaultTrigger`.
309
+ */
310
+ vaultName?: string;
311
+ }
312
+
283
313
  /**
284
314
  * Register all triggers from config onto a HookRegistry.
285
315
  * Returns a cleanup function that unregisters all hooks.
316
+ *
317
+ * `opts.logger` defaults to console. `opts.vaultName`, when set, scopes every
318
+ * registered handler to that vault (early-return on mismatch) — the
319
+ * load-bearing isolation for runtime per-vault triggers. A plain logger object
320
+ * is still accepted as the third arg for back-compat with existing callers.
286
321
  */
287
322
  export function registerTriggers(
288
323
  hooks: HookRegistry,
289
324
  triggers: TriggerConfig[],
290
- logger: { error: (...args: unknown[]) => void; info?: (...args: unknown[]) => void } = console,
325
+ optsOrLogger: RegisterTriggersOptions | { error: (...args: unknown[]) => void; info?: (...args: unknown[]) => void } = {},
291
326
  ): () => void {
327
+ // Back-compat: callers historically passed a bare logger as the 3rd arg.
328
+ // Distinguish it from the new options object by the presence of `error`.
329
+ const opts: RegisterTriggersOptions =
330
+ optsOrLogger && typeof (optsOrLogger as { error?: unknown }).error === "function"
331
+ ? { logger: optsOrLogger as RegisterTriggersOptions["logger"] }
332
+ : (optsOrLogger as RegisterTriggersOptions);
333
+ const logger = opts.logger ?? console;
334
+ const scopedVault = opts.vaultName;
292
335
  const unregisters: Array<() => void> = [];
293
336
 
294
337
  for (const trigger of triggers) {
@@ -310,6 +353,7 @@ export function registerTriggers(
310
353
  const renderedKey = `${trigger.name}_rendered_at`;
311
354
  const timeout = trigger.action.timeout ?? DEFAULT_TIMEOUT;
312
355
  const sendMode = trigger.action.send ?? "json";
356
+ const authHeaders = buildAuthHeaders(trigger.action);
313
357
 
314
358
  const unregister = hooks.onNote({
315
359
  name: trigger.name,
@@ -322,6 +366,16 @@ export function registerTriggers(
322
366
  return predicate(payload as Note);
323
367
  },
324
368
  handler: async (payload: NoteHookPayload, store: Store, hookEvent?: HookEvent) => {
369
+ // Per-vault scoping (runtime triggers): the process-wide hook registry
370
+ // is shared across every vault, so a trigger registered for vault A
371
+ // would otherwise fire on vault-B note events too. Resolve the firing
372
+ // store's vault and early-return on mismatch. `config.yaml` triggers
373
+ // leave `scopedVault` undefined and stay global. This is the
374
+ // load-bearing isolation check — see the per-vault firing test.
375
+ if (scopedVault !== undefined && getVaultNameForStore(store) !== scopedVault) {
376
+ return;
377
+ }
378
+
325
379
  // Same shape contract as the predicate — triggers don't
326
380
  // subscribe to deleted events, so narrow back to Note.
327
381
  const note = payload as Note;
@@ -357,16 +411,16 @@ export function registerTriggers(
357
411
  let result: DispatchResult;
358
412
  switch (sendMode) {
359
413
  case "attachment":
360
- result = await dispatchAttachment(trigger.action.webhook, note, attachments, store, context, controller.signal);
414
+ result = await dispatchAttachment(trigger.action.webhook, note, attachments, store, context, authHeaders, controller.signal);
361
415
  break;
362
416
  case "content":
363
417
  // send=content is pure TTS (audio out); vault context makes no
364
418
  // sense here and would confuse the server contract.
365
- result = await dispatchContent(trigger.action.webhook, note, store, controller.signal);
419
+ result = await dispatchContent(trigger.action.webhook, note, store, authHeaders, controller.signal);
366
420
  break;
367
421
  case "json":
368
422
  default:
369
- result = await dispatchJson(trigger.action.webhook, trigger, note, attachments, existingMeta, hookEvent, context, controller.signal);
423
+ result = await dispatchJson(trigger.action.webhook, trigger, note, attachments, existingMeta, hookEvent, context, authHeaders, controller.signal);
370
424
  break;
371
425
  }
372
426
  webhookResult = result.webhookResult;
@@ -438,3 +492,35 @@ export function registerTriggers(
438
492
 
439
493
  return () => unregisters.forEach((fn) => fn());
440
494
  }
495
+
496
+ /**
497
+ * Convert a persisted `StoredTrigger` (core/src/triggers-store.ts) into the
498
+ * `TriggerConfig` shape `registerTriggers` consumes. The two are structurally
499
+ * compatible — this is a thin, explicit bridge so the type boundary between
500
+ * core (storage) and src (config types) stays visible.
501
+ */
502
+ export function storedTriggerToConfig(t: StoredTrigger): TriggerConfig {
503
+ return {
504
+ name: t.name,
505
+ events: t.events,
506
+ when: t.when as TriggerWhen,
507
+ action: t.action as unknown as TriggerConfig["action"],
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Register a single runtime trigger scoped to `vaultName`. The handler
513
+ * early-returns unless the firing store resolves to `vaultName` (see the
514
+ * scoping check in `registerTriggers`), so a trigger registered for vault A
515
+ * never acts on a vault-B event. Otherwise identical to a config.yaml trigger
516
+ * — same two-phase claim + webhook fire. Returns an unregister function the
517
+ * caller keys by (vault, name) so POST-replace and DELETE can unwind it.
518
+ */
519
+ export function registerVaultTrigger(
520
+ hooks: HookRegistry,
521
+ trigger: StoredTrigger,
522
+ vaultName: string,
523
+ logger: { error: (...args: unknown[]) => void; info?: (...args: unknown[]) => void } = console,
524
+ ): () => void {
525
+ return registerTriggers(hooks, [storedTriggerToConfig(trigger)], { logger, vaultName });
526
+ }