@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.
- package/.parachute/module.json +14 -3
- package/core/src/mcp.ts +20 -0
- package/core/src/schema.ts +45 -1
- package/core/src/store.ts +66 -19
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +27 -1
- package/package.json +1 -1
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/cli.ts +45 -18
- package/src/config.test.ts +27 -0
- package/src/config.ts +87 -0
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/routes.ts +192 -78
- package/src/routing.test.ts +64 -0
- package/src/routing.ts +48 -1
- package/src/server.ts +49 -3
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/vault-create.test.ts +35 -1
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +194 -0
- package/web/ui/dist/assets/index-CGL256oe.js +60 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DJL6Az--.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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/vault-create.test.ts
CHANGED
|
@@ -28,11 +28,18 @@ function runCli(
|
|
|
28
28
|
args: string[],
|
|
29
29
|
env: Record<string, string>,
|
|
30
30
|
): { exitCode: number; stdout: string; stderr: string } {
|
|
31
|
+
// Hermetic: don't inherit the dev/CI box's PARACHUTE_HUB_ORIGIN. A leaked
|
|
32
|
+
// origin makes `detectHubPresence`'s rule-1 (configured-origin) short-circuit
|
|
33
|
+
// to "hub present" with no probe, flipping the no-hub guidance copy to the
|
|
34
|
+
// "admin wizard" variant — a CI flake when the runner env has it set. Tests
|
|
35
|
+
// that genuinely want a hub origin pass it explicitly via `env`.
|
|
36
|
+
const baseEnv: Record<string, string | undefined> = { ...process.env };
|
|
37
|
+
delete baseEnv.PARACHUTE_HUB_ORIGIN;
|
|
31
38
|
const proc = Bun.spawnSync({
|
|
32
39
|
cmd: ["bun", CLI, ...args],
|
|
33
40
|
stdout: "pipe",
|
|
34
41
|
stderr: "pipe",
|
|
35
|
-
env: { ...
|
|
42
|
+
env: { ...baseEnv, ...env },
|
|
36
43
|
});
|
|
37
44
|
return {
|
|
38
45
|
exitCode: proc.exitCode ?? -1,
|
|
@@ -150,6 +157,33 @@ describe("vault create --json", () => {
|
|
|
150
157
|
expect(stderr).toContain("lowercase");
|
|
151
158
|
});
|
|
152
159
|
|
|
160
|
+
test("reserved names (list/new/assets/admin) are rejected at create", () => {
|
|
161
|
+
// Consolidated reserved set (2026-06-09 hub-module-boundary B2). Before
|
|
162
|
+
// the consolidation cmdCreate hardcoded only "list" — `admin` could enter
|
|
163
|
+
// through `create` and capture the daemon-level /vault/admin mount.
|
|
164
|
+
for (const reserved of ["list", "new", "assets", "admin"]) {
|
|
165
|
+
const { exitCode, stdout, stderr } = runCli(
|
|
166
|
+
["create", reserved, "--json"],
|
|
167
|
+
{ PARACHUTE_HOME: home },
|
|
168
|
+
);
|
|
169
|
+
expect(exitCode).not.toBe(0);
|
|
170
|
+
expect(stdout).toBe("");
|
|
171
|
+
expect(stderr).toContain("reserved");
|
|
172
|
+
// The vault must not have been created.
|
|
173
|
+
expect(existsSync(join(home, "vault", "data", reserved))).toBe(false);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("near-misses of reserved names (adminx, admin2) create fine", () => {
|
|
178
|
+
for (const name of ["adminx", "admin2"]) {
|
|
179
|
+
const { exitCode, stdout } = runCli(["create", name, "--json"], {
|
|
180
|
+
PARACHUTE_HOME: home,
|
|
181
|
+
});
|
|
182
|
+
expect(exitCode).toBe(0);
|
|
183
|
+
expect(JSON.parse(stdout.trim()).name).toBe(name);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
153
187
|
test("duplicate name in --json mode errors on stderr and exits non-zero", () => {
|
|
154
188
|
runCli(["create", "dup", "--json"], { PARACHUTE_HOME: home });
|
|
155
189
|
const { exitCode, stdout, stderr } = runCli(
|
package/src/vault-name.test.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, test, expect } from "bun:test";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
RESERVED_VAULT_NAMES,
|
|
12
|
+
decideInitVaultName,
|
|
13
|
+
reservedNameSquatWarnings,
|
|
14
|
+
resolveFirstBootVaultName,
|
|
15
|
+
validateVaultName,
|
|
16
|
+
} from "./vault-name.ts";
|
|
11
17
|
|
|
12
18
|
describe("validateVaultName", () => {
|
|
13
19
|
describe("accepts", () => {
|
|
@@ -69,12 +75,29 @@ describe("validateVaultName", () => {
|
|
|
69
75
|
}
|
|
70
76
|
});
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
// The consolidated reserved set (2026-06-09 hub-module-boundary B2):
|
|
79
|
+
// `list` (legacy), `new` + `assets` (hub SPA route collisions), `admin`
|
|
80
|
+
// (the daemon-level /vault/admin multi-vault mount). Kept in lockstep
|
|
81
|
+
// with hub's RESERVED_VAULT_NAMES.
|
|
82
|
+
test.each(["list", "new", "assets", "admin"])("reserved name '%s'", (name) => {
|
|
83
|
+
const result = validateVaultName(name);
|
|
74
84
|
expect(result.ok).toBe(false);
|
|
75
85
|
if (!result.ok) expect(result.error).toContain("reserved");
|
|
76
86
|
});
|
|
77
87
|
|
|
88
|
+
test("the exported set carries exactly the four consolidated names", () => {
|
|
89
|
+
expect([...RESERVED_VAULT_NAMES].sort()).toEqual(["admin", "assets", "list", "new"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test.each(["adminx", "admin2", "newer", "asset", "listing"])(
|
|
93
|
+
"near-miss '%s' is NOT reserved",
|
|
94
|
+
(name) => {
|
|
95
|
+
const result = validateVaultName(name);
|
|
96
|
+
expect(result.ok).toBe(true);
|
|
97
|
+
if (result.ok) expect(result.name).toBe(name);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
78
101
|
test("single character (below 2-char min)", () => {
|
|
79
102
|
const result = validateVaultName("a");
|
|
80
103
|
expect(result.ok).toBe(false);
|
|
@@ -95,6 +118,41 @@ describe("validateVaultName", () => {
|
|
|
95
118
|
});
|
|
96
119
|
});
|
|
97
120
|
|
|
121
|
+
describe("reservedNameSquatWarnings", () => {
|
|
122
|
+
test("fires for a squatted 'admin' vault, naming the shadowing + recovery", () => {
|
|
123
|
+
const warnings = reservedNameSquatWarnings(["default", "admin"]);
|
|
124
|
+
expect(warnings).toHaveLength(1);
|
|
125
|
+
expect(warnings[0]).toContain('vault "admin"');
|
|
126
|
+
expect(warnings[0]).toContain("shadowed");
|
|
127
|
+
expect(warnings[0]).toContain("/vault/admin/*");
|
|
128
|
+
// Recovery procedure (no rename command exists): export → create →
|
|
129
|
+
// import → remove.
|
|
130
|
+
expect(warnings[0]).toContain("export");
|
|
131
|
+
expect(warnings[0]).toContain("create <newname>");
|
|
132
|
+
expect(warnings[0]).toContain("import");
|
|
133
|
+
expect(warnings[0]).toContain("remove admin --yes");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("fires once per squatted name — admin + new + assets all warned", () => {
|
|
137
|
+
const warnings = reservedNameSquatWarnings(["admin", "new", "assets", "ok"]);
|
|
138
|
+
expect(warnings).toHaveLength(3);
|
|
139
|
+
expect(warnings.join("\n")).toContain('vault "admin"');
|
|
140
|
+
expect(warnings.join("\n")).toContain('vault "new"');
|
|
141
|
+
expect(warnings.join("\n")).toContain('vault "assets"');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("silent for clean vault lists and near-misses", () => {
|
|
145
|
+
expect(reservedNameSquatWarnings([])).toEqual([]);
|
|
146
|
+
expect(reservedNameSquatWarnings(["default", "work", "adminx", "admin2"])).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("silent for 'list' (reserved for consistency, but not shadowed)", () => {
|
|
150
|
+
// `/vault/list/*` still routes per-vault — list is reserved at create
|
|
151
|
+
// time but a legacy squatter keeps working, so no scary boot warning.
|
|
152
|
+
expect(reservedNameSquatWarnings(["list"])).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
98
156
|
describe("decideInitVaultName", () => {
|
|
99
157
|
test("--vault-name=aaron resolves to name 'aaron'", () => {
|
|
100
158
|
const d = decideInitVaultName(["--vault-name", "aaron"], { isTTY: true });
|