@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.
Files changed (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
@@ -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
+ });