@openparachute/vault 0.1.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/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- package/web/vite.config.ts +15 -0
package/src/triggers.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic webhook trigger system.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the hardcoded tts-hook and transcription-hook with a declarative
|
|
5
|
+
* config-driven approach. Each trigger defines a predicate (tags, content,
|
|
6
|
+
* metadata) and an action (webhook URL + send/response modes). When a note
|
|
7
|
+
* mutation matches, the trigger fires a webhook and applies the response.
|
|
8
|
+
*
|
|
9
|
+
* ## Two-phase marker discipline (inherited from the old hooks)
|
|
10
|
+
*
|
|
11
|
+
* 1. On entry: write `metadata.<trigger_name>_pending_at = <now>`.
|
|
12
|
+
* The predicate checks `missing_metadata` which includes the pending
|
|
13
|
+
* and rendered markers, so a concurrent update cannot start a second run.
|
|
14
|
+
* 2. On success: replace `_pending_at` with `_rendered_at` and apply the
|
|
15
|
+
* webhook response (content, metadata, attachments).
|
|
16
|
+
* 3. On failure: leave `_pending_at` set. Manual recovery required.
|
|
17
|
+
*
|
|
18
|
+
* ## Send modes
|
|
19
|
+
*
|
|
20
|
+
* - `json` (default): POST `{ trigger, event, note }` as JSON.
|
|
21
|
+
* Response: `{ content?, metadata?, attachments? }`.
|
|
22
|
+
* - `attachment`: Read the first audio attachment, POST as multipart/form-data.
|
|
23
|
+
* Response: `{ text }` (Whisper API shape). Written to note.content.
|
|
24
|
+
* - `content`: POST `{ input: note.content }` as JSON (OpenAI TTS shape).
|
|
25
|
+
* Response: binary audio bytes. Saved to assets + attachment.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { join, normalize } from "path";
|
|
29
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
|
|
30
|
+
import crypto from "node:crypto";
|
|
31
|
+
import type { Note, Store, Attachment } from "../core/src/types.ts";
|
|
32
|
+
import type { HookRegistry, HookEvent } from "../core/src/hooks.ts";
|
|
33
|
+
import type { TriggerConfig, TriggerWhen } from "./config.ts";
|
|
34
|
+
import { getVaultNameForStore } from "./vault-store.ts";
|
|
35
|
+
import { assetsDir } from "./routes.ts";
|
|
36
|
+
|
|
37
|
+
const DEFAULT_TIMEOUT = 60_000;
|
|
38
|
+
|
|
39
|
+
export interface WebhookResponse {
|
|
40
|
+
content?: string;
|
|
41
|
+
metadata?: Record<string, unknown>;
|
|
42
|
+
attachments?: Array<{
|
|
43
|
+
path: string;
|
|
44
|
+
mimeType: string;
|
|
45
|
+
meta?: Record<string, unknown>;
|
|
46
|
+
}>;
|
|
47
|
+
/** If set, the trigger is considered skipped (not failed). */
|
|
48
|
+
skipped_reason?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a HookRegistry predicate from a TriggerWhen config.
|
|
53
|
+
*/
|
|
54
|
+
export function buildPredicate(when: TriggerWhen, triggerName: string): (note: Note) => boolean {
|
|
55
|
+
const pendingKey = `${triggerName}_pending_at`;
|
|
56
|
+
const renderedKey = `${triggerName}_rendered_at`;
|
|
57
|
+
|
|
58
|
+
return (note: Note) => {
|
|
59
|
+
const meta = note.metadata as Record<string, unknown> | undefined;
|
|
60
|
+
|
|
61
|
+
// Always check our own markers (two-phase discipline)
|
|
62
|
+
if (meta?.[pendingKey] || meta?.[renderedKey]) return false;
|
|
63
|
+
|
|
64
|
+
// Tag filter
|
|
65
|
+
if (when.tags?.length) {
|
|
66
|
+
if (!when.tags.every((t) => note.tags?.includes(t))) return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Content filter
|
|
70
|
+
if (when.has_content === true) {
|
|
71
|
+
if (!note.content || !note.content.trim()) return false;
|
|
72
|
+
}
|
|
73
|
+
if (when.has_content === false) {
|
|
74
|
+
if (note.content && note.content.trim().length > 0) return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Missing metadata filter
|
|
78
|
+
if (when.missing_metadata?.length) {
|
|
79
|
+
for (const key of when.missing_metadata) {
|
|
80
|
+
if (meta?.[key] != null) return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Has metadata filter
|
|
85
|
+
if (when.has_metadata?.length) {
|
|
86
|
+
for (const key of when.has_metadata) {
|
|
87
|
+
if (meta?.[key] == null) return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return true;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Dispatch helpers — one per send mode
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
const AUDIO_MIME_TYPES = new Set(["audio/wav", "audio/mpeg", "audio/mp4", "audio/ogg", "audio/webm"]);
|
|
100
|
+
|
|
101
|
+
/** Resolve the assets directory for a store. */
|
|
102
|
+
function resolveAssetsDir(store: Store): string {
|
|
103
|
+
const vaultName = getVaultNameForStore(store as never);
|
|
104
|
+
return assetsDir(vaultName ?? "default");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Find the first audio attachment for a note and return its absolute path. */
|
|
108
|
+
function findAudioAttachment(
|
|
109
|
+
attachments: Attachment[],
|
|
110
|
+
assetsRoot: string,
|
|
111
|
+
): { attachment: Attachment; filePath: string } | null {
|
|
112
|
+
for (const att of attachments) {
|
|
113
|
+
if (!AUDIO_MIME_TYPES.has(att.mimeType)) continue;
|
|
114
|
+
const filePath = normalize(join(assetsRoot, att.path));
|
|
115
|
+
if (filePath.startsWith(normalize(assetsRoot)) && existsSync(filePath)) {
|
|
116
|
+
return { attachment: att, filePath };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Save binary audio to the assets dir, return relative path + MIME. */
|
|
123
|
+
function saveAudioToAssets(
|
|
124
|
+
assetsRoot: string,
|
|
125
|
+
audio: Buffer,
|
|
126
|
+
contentType: string,
|
|
127
|
+
): { relativePath: string; mimeType: string } {
|
|
128
|
+
const ext = contentType.includes("ogg") ? ".ogg"
|
|
129
|
+
: contentType.includes("mpeg") ? ".mp3"
|
|
130
|
+
: contentType.includes("wav") ? ".wav"
|
|
131
|
+
: contentType.includes("mp4") ? ".m4a"
|
|
132
|
+
: ".ogg"; // default to ogg
|
|
133
|
+
|
|
134
|
+
const date = new Date().toISOString().split("T")[0];
|
|
135
|
+
const dir = join(assetsRoot, date);
|
|
136
|
+
mkdirSync(dir, { recursive: true });
|
|
137
|
+
|
|
138
|
+
const filename = `${Date.now()}-${crypto.randomUUID()}${ext}`;
|
|
139
|
+
const filePath = join(dir, filename);
|
|
140
|
+
writeFileSync(filePath, audio);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
relativePath: `${date}/${filename}`,
|
|
144
|
+
mimeType: contentType || "audio/ogg",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface DispatchResult {
|
|
149
|
+
webhookResult: WebhookResponse;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** send=json (default): POST the note as JSON, expect standard webhook response. */
|
|
153
|
+
async function dispatchJson(
|
|
154
|
+
url: string,
|
|
155
|
+
trigger: TriggerConfig,
|
|
156
|
+
note: Note,
|
|
157
|
+
attachments: Attachment[],
|
|
158
|
+
existingMeta: Record<string, unknown>,
|
|
159
|
+
hookEvent: HookEvent | undefined,
|
|
160
|
+
signal: AbortSignal,
|
|
161
|
+
): Promise<DispatchResult> {
|
|
162
|
+
const resp = await fetch(url, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": "application/json" },
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
trigger: trigger.name,
|
|
167
|
+
event: hookEvent ?? "updated",
|
|
168
|
+
note: {
|
|
169
|
+
id: note.id,
|
|
170
|
+
content: note.content,
|
|
171
|
+
path: note.path,
|
|
172
|
+
tags: note.tags,
|
|
173
|
+
metadata: existingMeta,
|
|
174
|
+
attachments,
|
|
175
|
+
createdAt: note.createdAt,
|
|
176
|
+
updatedAt: note.updatedAt,
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
signal,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (!resp.ok) {
|
|
183
|
+
throw new Error(`webhook returned ${resp.status}: ${await resp.text().catch(() => "")}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const text = await resp.text();
|
|
187
|
+
return { webhookResult: text ? JSON.parse(text) : {} };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* send=attachment: Read the first audio attachment from the vault assets dir,
|
|
192
|
+
* POST it as multipart/form-data. Expects `{ text }` response (Whisper shape).
|
|
193
|
+
*/
|
|
194
|
+
async function dispatchAttachment(
|
|
195
|
+
url: string,
|
|
196
|
+
note: Note,
|
|
197
|
+
attachments: Attachment[],
|
|
198
|
+
store: Store,
|
|
199
|
+
signal: AbortSignal,
|
|
200
|
+
): Promise<DispatchResult> {
|
|
201
|
+
const assetsRoot = resolveAssetsDir(store);
|
|
202
|
+
const audio = findAudioAttachment(attachments, assetsRoot);
|
|
203
|
+
if (!audio) {
|
|
204
|
+
return { webhookResult: { skipped_reason: "no audio attachment found" } };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const fileBuffer = readFileSync(audio.filePath);
|
|
208
|
+
const filename = audio.attachment.path.split("/").pop() ?? "audio";
|
|
209
|
+
const file = new File([fileBuffer], filename, { type: audio.attachment.mimeType });
|
|
210
|
+
|
|
211
|
+
const form = new FormData();
|
|
212
|
+
form.append("file", file);
|
|
213
|
+
|
|
214
|
+
const resp = await fetch(url, { method: "POST", body: form, signal });
|
|
215
|
+
if (!resp.ok) {
|
|
216
|
+
throw new Error(`webhook returned ${resp.status}: ${await resp.text().catch(() => "")}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const result = await resp.json() as { text?: string };
|
|
220
|
+
const webhookResult: WebhookResponse = {};
|
|
221
|
+
if (result.text) {
|
|
222
|
+
webhookResult.content = result.text;
|
|
223
|
+
}
|
|
224
|
+
return { webhookResult };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* send=content: POST `{ input: note.content, model?, voice? }` as JSON
|
|
229
|
+
* (OpenAI TTS shape). Response is binary audio bytes. Saved as attachment.
|
|
230
|
+
*/
|
|
231
|
+
async function dispatchContent(
|
|
232
|
+
url: string,
|
|
233
|
+
note: Note,
|
|
234
|
+
store: Store,
|
|
235
|
+
signal: AbortSignal,
|
|
236
|
+
): Promise<DispatchResult> {
|
|
237
|
+
if (!note.content || !note.content.trim()) {
|
|
238
|
+
return { webhookResult: { skipped_reason: "note has no content to synthesize" } };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const resp = await fetch(url, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "Content-Type": "application/json" },
|
|
244
|
+
body: JSON.stringify({ input: note.content }),
|
|
245
|
+
signal,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (!resp.ok) {
|
|
249
|
+
throw new Error(`webhook returned ${resp.status}: ${await resp.text().catch(() => "")}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const contentType = resp.headers.get("Content-Type") ?? "audio/ogg";
|
|
253
|
+
const audioBuffer = Buffer.from(await resp.arrayBuffer());
|
|
254
|
+
const assetsRoot = resolveAssetsDir(store);
|
|
255
|
+
const { relativePath, mimeType } = saveAudioToAssets(assetsRoot, audioBuffer, contentType);
|
|
256
|
+
|
|
257
|
+
const webhookResult: WebhookResponse = {
|
|
258
|
+
attachments: [{ path: relativePath, mimeType }],
|
|
259
|
+
metadata: {
|
|
260
|
+
...(resp.headers.get("X-TTS-Provider") ? { tts_provider: resp.headers.get("X-TTS-Provider") } : {}),
|
|
261
|
+
...(resp.headers.get("X-TTS-Voice") ? { tts_voice: resp.headers.get("X-TTS-Voice") } : {}),
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
return { webhookResult };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Registration
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Register all triggers from config onto a HookRegistry.
|
|
273
|
+
* Returns a cleanup function that unregisters all hooks.
|
|
274
|
+
*/
|
|
275
|
+
export function registerTriggers(
|
|
276
|
+
hooks: HookRegistry,
|
|
277
|
+
triggers: TriggerConfig[],
|
|
278
|
+
logger: { error: (...args: unknown[]) => void; info?: (...args: unknown[]) => void } = console,
|
|
279
|
+
): () => void {
|
|
280
|
+
const unregisters: Array<() => void> = [];
|
|
281
|
+
|
|
282
|
+
for (const trigger of triggers) {
|
|
283
|
+
// Validate webhook URL at registration time so typos fail fast
|
|
284
|
+
try {
|
|
285
|
+
const url = new URL(trigger.action.webhook);
|
|
286
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
287
|
+
logger.error(`[triggers] skipping "${trigger.name}": webhook URL must use http or https (got ${url.protocol})`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
logger.error(`[triggers] skipping "${trigger.name}": invalid webhook URL "${trigger.action.webhook}"`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const predicate = buildPredicate(trigger.when, trigger.name);
|
|
296
|
+
const events = trigger.events ?? ["created", "updated"];
|
|
297
|
+
const pendingKey = `${trigger.name}_pending_at`;
|
|
298
|
+
const renderedKey = `${trigger.name}_rendered_at`;
|
|
299
|
+
const timeout = trigger.action.timeout ?? DEFAULT_TIMEOUT;
|
|
300
|
+
const sendMode = trigger.action.send ?? "json";
|
|
301
|
+
|
|
302
|
+
const unregister = hooks.onNote({
|
|
303
|
+
name: trigger.name,
|
|
304
|
+
event: events,
|
|
305
|
+
when: predicate,
|
|
306
|
+
handler: async (note: Note, store: Store, hookEvent?: HookEvent) => {
|
|
307
|
+
const existingMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
|
|
308
|
+
|
|
309
|
+
// Handler-side re-check (same race-window protection as the old hooks)
|
|
310
|
+
if (existingMeta[pendingKey] || existingMeta[renderedKey]) return;
|
|
311
|
+
|
|
312
|
+
const pendingAt = new Date().toISOString();
|
|
313
|
+
|
|
314
|
+
// Phase 1: claim
|
|
315
|
+
try {
|
|
316
|
+
store.updateNote(note.id, {
|
|
317
|
+
metadata: { ...existingMeta, [pendingKey]: pendingAt },
|
|
318
|
+
skipUpdatedAt: true,
|
|
319
|
+
});
|
|
320
|
+
} catch (err) {
|
|
321
|
+
logger.error(`[trigger:${trigger.name}] failed to claim note ${note.id}:`, err);
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Fire the webhook using the configured send mode
|
|
326
|
+
let webhookResult: WebhookResponse;
|
|
327
|
+
const attachments = store.getAttachments(note.id);
|
|
328
|
+
const controller = new AbortController();
|
|
329
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
330
|
+
try {
|
|
331
|
+
let result: DispatchResult;
|
|
332
|
+
switch (sendMode) {
|
|
333
|
+
case "attachment":
|
|
334
|
+
result = await dispatchAttachment(trigger.action.webhook, note, attachments, store, controller.signal);
|
|
335
|
+
break;
|
|
336
|
+
case "content":
|
|
337
|
+
result = await dispatchContent(trigger.action.webhook, note, store, controller.signal);
|
|
338
|
+
break;
|
|
339
|
+
case "json":
|
|
340
|
+
default:
|
|
341
|
+
result = await dispatchJson(trigger.action.webhook, trigger, note, attachments, existingMeta, hookEvent, controller.signal);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
webhookResult = result.webhookResult;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
logger.error(
|
|
347
|
+
`[trigger:${trigger.name}] webhook failed for note ${note.id}; note left in ${pendingKey} state (manual recovery required):`,
|
|
348
|
+
err,
|
|
349
|
+
);
|
|
350
|
+
throw err;
|
|
351
|
+
} finally {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Handle skipped result. We write `_rendered_at` even for skips so the
|
|
356
|
+
// predicate won't re-fire on future note edits — a permanently-skippable
|
|
357
|
+
// note (e.g. code-only content with no speakable text) would otherwise
|
|
358
|
+
// trigger an infinite webhook loop on every update.
|
|
359
|
+
if (webhookResult.skipped_reason) {
|
|
360
|
+
try {
|
|
361
|
+
store.updateNote(note.id, {
|
|
362
|
+
metadata: {
|
|
363
|
+
...existingMeta,
|
|
364
|
+
[pendingKey]: undefined,
|
|
365
|
+
[renderedKey]: new Date().toISOString(),
|
|
366
|
+
[`${trigger.name}_skipped_reason`]: webhookResult.skipped_reason,
|
|
367
|
+
},
|
|
368
|
+
skipUpdatedAt: true,
|
|
369
|
+
});
|
|
370
|
+
} catch (err) {
|
|
371
|
+
logger.error(`[trigger:${trigger.name}] failed to mark note ${note.id} as skipped:`, err);
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Phase 2: apply webhook response and mark as rendered
|
|
377
|
+
try {
|
|
378
|
+
// Add attachments first
|
|
379
|
+
if (webhookResult.attachments?.length) {
|
|
380
|
+
for (const att of webhookResult.attachments) {
|
|
381
|
+
store.addAttachment(note.id, att.path, att.mimeType, att.meta);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Read fresh metadata to avoid clobbering concurrent edits
|
|
386
|
+
const fresh = store.getNote(note.id);
|
|
387
|
+
const freshMeta = (fresh?.metadata as Record<string, unknown> | undefined) ?? existingMeta;
|
|
388
|
+
const { [pendingKey]: _drop, ...restMeta } = freshMeta;
|
|
389
|
+
|
|
390
|
+
store.updateNote(note.id, {
|
|
391
|
+
...(webhookResult.content !== undefined ? { content: webhookResult.content } : {}),
|
|
392
|
+
metadata: {
|
|
393
|
+
...restMeta,
|
|
394
|
+
...(webhookResult.metadata ?? {}),
|
|
395
|
+
[renderedKey]: new Date().toISOString(),
|
|
396
|
+
},
|
|
397
|
+
skipUpdatedAt: true,
|
|
398
|
+
});
|
|
399
|
+
} catch (err) {
|
|
400
|
+
logger.error(`[trigger:${trigger.name}] failed to apply webhook result for note ${note.id}:`, err);
|
|
401
|
+
throw err;
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
unregisters.push(unregister);
|
|
407
|
+
const modeStr = sendMode !== "json" ? ` (send=${sendMode})` : "";
|
|
408
|
+
logger.info?.(`[triggers] registered: ${trigger.name} → ${trigger.action.webhook}${modeStr}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return () => unregisters.forEach((fn) => fn());
|
|
412
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for TOTP 2FA + backup codes (src/two-factor.ts).
|
|
3
|
+
*
|
|
4
|
+
* Uses PARACHUTE_HOME override so enrollment/regeneration touches a tmp dir
|
|
5
|
+
* instead of the user's real ~/.parachute. Must set env BEFORE importing
|
|
6
|
+
* config-dependent modules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect, beforeEach, afterAll } from "bun:test";
|
|
10
|
+
import { rmSync, existsSync, mkdirSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import * as OTPAuth from "otpauth";
|
|
14
|
+
|
|
15
|
+
const testDir = join(tmpdir(), `vault-2fa-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
16
|
+
process.env.PARACHUTE_HOME = testDir;
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
enrollTotp,
|
|
20
|
+
disableTotp,
|
|
21
|
+
hasTotpEnrolled,
|
|
22
|
+
verifyTotpCode,
|
|
23
|
+
regenerateBackupCodes,
|
|
24
|
+
getBackupCodeCount,
|
|
25
|
+
verifyAndConsumeBackupCode,
|
|
26
|
+
getTotpSecret,
|
|
27
|
+
_resetTotpReplayCache,
|
|
28
|
+
} = await import("./two-factor.ts");
|
|
29
|
+
|
|
30
|
+
const { readGlobalConfig, writeGlobalConfig } = await import("./config.ts");
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
// Fresh per-test state
|
|
34
|
+
if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
|
|
35
|
+
mkdirSync(testDir, { recursive: true });
|
|
36
|
+
writeGlobalConfig({ port: 1940 });
|
|
37
|
+
_resetTotpReplayCache();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// TOTP
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe("TOTP verification", () => {
|
|
49
|
+
test("accepts the current code", () => {
|
|
50
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
51
|
+
const totp = new OTPAuth.TOTP({
|
|
52
|
+
issuer: "Parachute Vault",
|
|
53
|
+
label: "owner",
|
|
54
|
+
algorithm: "SHA1",
|
|
55
|
+
digits: 6,
|
|
56
|
+
period: 30,
|
|
57
|
+
secret: OTPAuth.Secret.fromBase32(secret),
|
|
58
|
+
});
|
|
59
|
+
const code = totp.generate();
|
|
60
|
+
expect(verifyTotpCode(secret, code)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("accepts prev/next window (±30s drift)", () => {
|
|
64
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
65
|
+
const totp = new OTPAuth.TOTP({
|
|
66
|
+
issuer: "Parachute Vault",
|
|
67
|
+
label: "owner",
|
|
68
|
+
algorithm: "SHA1",
|
|
69
|
+
digits: 6,
|
|
70
|
+
period: 30,
|
|
71
|
+
secret: OTPAuth.Secret.fromBase32(secret),
|
|
72
|
+
});
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const prev = totp.generate({ timestamp: now - 30_000 });
|
|
75
|
+
const next = totp.generate({ timestamp: now + 30_000 });
|
|
76
|
+
expect(verifyTotpCode(secret, prev)).toBe(true);
|
|
77
|
+
expect(verifyTotpCode(secret, next)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("rejects a code from 2 windows away", () => {
|
|
81
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
82
|
+
const totp = new OTPAuth.TOTP({
|
|
83
|
+
issuer: "Parachute Vault",
|
|
84
|
+
label: "owner",
|
|
85
|
+
algorithm: "SHA1",
|
|
86
|
+
digits: 6,
|
|
87
|
+
period: 30,
|
|
88
|
+
secret: OTPAuth.Secret.fromBase32(secret),
|
|
89
|
+
});
|
|
90
|
+
const farCode = totp.generate({ timestamp: Date.now() - 120_000 });
|
|
91
|
+
expect(verifyTotpCode(secret, farCode)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("rejects replay of the same code within its window", () => {
|
|
95
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
96
|
+
const totp = new OTPAuth.TOTP({
|
|
97
|
+
issuer: "Parachute Vault",
|
|
98
|
+
label: "owner",
|
|
99
|
+
algorithm: "SHA1",
|
|
100
|
+
digits: 6,
|
|
101
|
+
period: 30,
|
|
102
|
+
secret: OTPAuth.Secret.fromBase32(secret),
|
|
103
|
+
});
|
|
104
|
+
const code = totp.generate();
|
|
105
|
+
expect(verifyTotpCode(secret, code)).toBe(true);
|
|
106
|
+
// Same code in same window — rejected
|
|
107
|
+
expect(verifyTotpCode(secret, code)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("markUsed=false leaves the code available for re-verification", () => {
|
|
111
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
112
|
+
const totp = new OTPAuth.TOTP({
|
|
113
|
+
issuer: "Parachute Vault",
|
|
114
|
+
label: "owner",
|
|
115
|
+
algorithm: "SHA1",
|
|
116
|
+
digits: 6,
|
|
117
|
+
period: 30,
|
|
118
|
+
secret: OTPAuth.Secret.fromBase32(secret),
|
|
119
|
+
});
|
|
120
|
+
const code = totp.generate();
|
|
121
|
+
expect(verifyTotpCode(secret, code, false)).toBe(true);
|
|
122
|
+
expect(verifyTotpCode(secret, code, false)).toBe(true);
|
|
123
|
+
// But once markUsed is the default, it's consumed.
|
|
124
|
+
expect(verifyTotpCode(secret, code)).toBe(true);
|
|
125
|
+
expect(verifyTotpCode(secret, code)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("rejects malformed codes", () => {
|
|
129
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
130
|
+
expect(verifyTotpCode(secret, "abc123")).toBe(false);
|
|
131
|
+
expect(verifyTotpCode(secret, "12345")).toBe(false);
|
|
132
|
+
expect(verifyTotpCode(secret, "1234567")).toBe(false);
|
|
133
|
+
expect(verifyTotpCode(secret, "")).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Enrollment
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
describe("enrollment lifecycle", () => {
|
|
142
|
+
test("enroll generates secret + 6 backup codes and persists them", async () => {
|
|
143
|
+
expect(hasTotpEnrolled()).toBe(false);
|
|
144
|
+
const result = await enrollTotp();
|
|
145
|
+
|
|
146
|
+
expect(result.secret).toMatch(/^[A-Z2-7]+$/);
|
|
147
|
+
expect(result.otpauthUrl).toStartWith("otpauth://totp/");
|
|
148
|
+
expect(result.backupCodes).toHaveLength(6);
|
|
149
|
+
expect(new Set(result.backupCodes).size).toBe(6); // unique
|
|
150
|
+
for (const c of result.backupCodes) {
|
|
151
|
+
expect(c).toMatch(/^[a-z2-9]{8}$/);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
expect(hasTotpEnrolled()).toBe(true);
|
|
155
|
+
expect(getTotpSecret()).toBe(result.secret);
|
|
156
|
+
expect(getBackupCodeCount()).toBe(6);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("enroll is round-trippable via config reload", async () => {
|
|
160
|
+
const result = await enrollTotp();
|
|
161
|
+
// Force-reload from disk
|
|
162
|
+
const fresh = readGlobalConfig();
|
|
163
|
+
expect(fresh.totp_secret).toBe(result.secret);
|
|
164
|
+
expect(fresh.backup_codes).toHaveLength(6);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("disable removes secret and backup codes", async () => {
|
|
168
|
+
await enrollTotp();
|
|
169
|
+
disableTotp();
|
|
170
|
+
expect(hasTotpEnrolled()).toBe(false);
|
|
171
|
+
expect(getTotpSecret()).toBeNull();
|
|
172
|
+
expect(getBackupCodeCount()).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Backup codes
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
describe("backup codes", () => {
|
|
181
|
+
test("valid code verifies and is consumed", async () => {
|
|
182
|
+
const result = await enrollTotp();
|
|
183
|
+
const code = result.backupCodes[0];
|
|
184
|
+
|
|
185
|
+
expect(await verifyAndConsumeBackupCode(code)).toBe(true);
|
|
186
|
+
expect(getBackupCodeCount()).toBe(5);
|
|
187
|
+
// Second use fails
|
|
188
|
+
expect(await verifyAndConsumeBackupCode(code)).toBe(false);
|
|
189
|
+
expect(getBackupCodeCount()).toBe(5);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("invalid code does not consume any", async () => {
|
|
193
|
+
await enrollTotp();
|
|
194
|
+
expect(await verifyAndConsumeBackupCode("nope1234")).toBe(false);
|
|
195
|
+
expect(getBackupCodeCount()).toBe(6);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("case-insensitive / whitespace-tolerant", async () => {
|
|
199
|
+
const result = await enrollTotp();
|
|
200
|
+
const code = result.backupCodes[2];
|
|
201
|
+
// Uppercase with spaces
|
|
202
|
+
expect(await verifyAndConsumeBackupCode(` ${code.toUpperCase()} `)).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("regenerate invalidates old codes", async () => {
|
|
206
|
+
const result = await enrollTotp();
|
|
207
|
+
const oldCode = result.backupCodes[0];
|
|
208
|
+
const newCodes = await regenerateBackupCodes();
|
|
209
|
+
expect(newCodes).toHaveLength(6);
|
|
210
|
+
expect(getBackupCodeCount()).toBe(6);
|
|
211
|
+
expect(await verifyAndConsumeBackupCode(oldCode)).toBe(false);
|
|
212
|
+
expect(await verifyAndConsumeBackupCode(newCodes[0])).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("concurrent consumption of the same code — only one wins", async () => {
|
|
216
|
+
const result = await enrollTotp();
|
|
217
|
+
const code = result.backupCodes[0];
|
|
218
|
+
// Kick off two verify calls in parallel; serialization via the mutex
|
|
219
|
+
// should prevent both from succeeding.
|
|
220
|
+
const [a, b] = await Promise.all([
|
|
221
|
+
verifyAndConsumeBackupCode(code),
|
|
222
|
+
verifyAndConsumeBackupCode(code),
|
|
223
|
+
]);
|
|
224
|
+
expect([a, b].filter(Boolean).length).toBe(1);
|
|
225
|
+
expect(getBackupCodeCount()).toBe(5);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("concurrent consumption of distinct codes — both win", async () => {
|
|
229
|
+
const result = await enrollTotp();
|
|
230
|
+
const [a, b] = await Promise.all([
|
|
231
|
+
verifyAndConsumeBackupCode(result.backupCodes[0]),
|
|
232
|
+
verifyAndConsumeBackupCode(result.backupCodes[1]),
|
|
233
|
+
]);
|
|
234
|
+
expect(a).toBe(true);
|
|
235
|
+
expect(b).toBe(true);
|
|
236
|
+
expect(getBackupCodeCount()).toBe(4);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("all codes consumable exactly once", async () => {
|
|
240
|
+
const result = await enrollTotp();
|
|
241
|
+
for (const code of result.backupCodes) {
|
|
242
|
+
expect(await verifyAndConsumeBackupCode(code)).toBe(true);
|
|
243
|
+
}
|
|
244
|
+
expect(getBackupCodeCount()).toBe(0);
|
|
245
|
+
});
|
|
246
|
+
});
|