@vellumai/vellum-gateway 0.7.0 → 0.7.2

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 (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. package/src/pairing/pairing-store.ts +0 -218
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Gateway backup worker.
3
+ *
4
+ * Drives the backup pipeline on a configurable interval. On each tick:
5
+ * 1. Read backup config from workspace config.json
6
+ * 2. Check whether enough time has passed since the last successful run
7
+ * 3. Call the daemon's /v1/migrations/export to get a plaintext .vbundle
8
+ * 4. Write the plaintext archive to the local backup directory
9
+ * 5. Encrypt + mirror to offsite destinations (key never leaves this process)
10
+ * 6. Apply retention to all pools
11
+ *
12
+ * The backup key lives in GATEWAY_SECURITY_DIR and is never exposed to the
13
+ * assistant daemon. The daemon produces the data; the gateway handles the
14
+ * security envelope.
15
+ */
16
+
17
+ import { createWriteStream, readFileSync, writeFileSync } from "node:fs";
18
+ import { unlink } from "node:fs/promises";
19
+ import { randomUUID } from "node:crypto";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { Readable } from "node:stream";
23
+ import { pipeline } from "node:stream/promises";
24
+
25
+ import { mintServiceToken } from "../auth/token-exchange.js";
26
+ import { readConfigFileOrEmpty } from "../config-file-utils.js";
27
+ import { fetchImpl } from "../fetch.js";
28
+ import { getLogger } from "../logger.js";
29
+ import { getGatewaySecurityDir } from "../paths.js";
30
+ import { ensureBackupKey } from "./backup-key.js";
31
+ import type { SnapshotEntry } from "./list-snapshots.js";
32
+ import { pruneLocalSnapshots, writeLocalSnapshot } from "./local-writer.js";
33
+ import type { BackupDestination, OffsiteWriteResult } from "./offsite-writer.js";
34
+ import {
35
+ pruneOffsiteSnapshotsInAll,
36
+ writeOffsiteSnapshotToAll,
37
+ } from "./offsite-writer.js";
38
+ import { getBackupKeyPath, getLocalBackupsDir } from "./paths.js";
39
+
40
+ const log = getLogger("backup-worker");
41
+
42
+ /** Tick interval: check every 5 minutes whether a backup is due. */
43
+ const TICK_INTERVAL_MS = 5 * 60 * 1000;
44
+
45
+ /** Timeout for the daemon export request (60 minutes for large workspaces). */
46
+ const EXPORT_TIMEOUT_MS = 60 * 60 * 1000;
47
+
48
+ /** File used to persist the last successful backup timestamp across restarts. */
49
+ const LAST_RUN_FILENAME = "backup-last-run-at";
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Config reading
53
+ // ---------------------------------------------------------------------------
54
+
55
+ interface BackupConfig {
56
+ enabled: boolean;
57
+ intervalHours: number;
58
+ retention: number;
59
+ offsite: {
60
+ enabled: boolean;
61
+ destinations: BackupDestination[] | null;
62
+ };
63
+ localDirectory: string | null;
64
+ }
65
+
66
+ /** Default iCloud Drive destination (macOS) with encryption enabled. */
67
+ function defaultOffsiteDestinations(): BackupDestination[] {
68
+ const home = process.env.HOME || "";
69
+ if (!home) return [];
70
+ return [
71
+ {
72
+ path: join(
73
+ home,
74
+ "Library",
75
+ "Mobile Documents",
76
+ "com~apple~CloudDocs",
77
+ "VellumAssistant",
78
+ "backups",
79
+ ),
80
+ encrypt: true,
81
+ },
82
+ ];
83
+ }
84
+
85
+ function readBackupConfig(): BackupConfig {
86
+ const raw = readConfigFileOrEmpty();
87
+ const backup = (raw.backup ?? {}) as Record<string, unknown>;
88
+
89
+ const enabled = backup.enabled === true;
90
+ const intervalHours =
91
+ typeof backup.intervalHours === "number" ? backup.intervalHours : 6;
92
+ const retention =
93
+ typeof backup.retention === "number" ? backup.retention : 3;
94
+
95
+ const offsiteRaw = (backup.offsite ?? {}) as Record<string, unknown>;
96
+ const offsiteEnabled = offsiteRaw.enabled !== false;
97
+ let destinations: BackupDestination[] | null = null;
98
+ if (Array.isArray(offsiteRaw.destinations)) {
99
+ destinations = offsiteRaw.destinations
100
+ .filter(
101
+ (d): d is { path: string; encrypt?: boolean } =>
102
+ d && typeof d === "object" && typeof (d as Record<string, unknown>).path === "string",
103
+ )
104
+ .map((d) => ({
105
+ path: d.path,
106
+ encrypt: d.encrypt !== false,
107
+ }));
108
+ }
109
+
110
+ const localDirectory =
111
+ typeof backup.localDirectory === "string" ? backup.localDirectory : null;
112
+
113
+ return {
114
+ enabled,
115
+ intervalHours,
116
+ retention,
117
+ offsite: { enabled: offsiteEnabled, destinations },
118
+ localDirectory,
119
+ };
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Checkpoint persistence
124
+ // ---------------------------------------------------------------------------
125
+
126
+ function getCheckpointPath(): string {
127
+ return join(getGatewaySecurityDir(), LAST_RUN_FILENAME);
128
+ }
129
+
130
+ function readLastRunAt(): number {
131
+ try {
132
+ const raw = readFileSync(getCheckpointPath(), "utf-8").trim();
133
+ const ts = Number(raw);
134
+ return Number.isFinite(ts) ? ts : 0;
135
+ } catch {
136
+ return 0;
137
+ }
138
+ }
139
+
140
+ function writeLastRunAt(timestamp: number): void {
141
+ try {
142
+ writeFileSync(getCheckpointPath(), String(timestamp), "utf-8");
143
+ } catch (err) {
144
+ log.warn({ err }, "Failed to persist backup checkpoint");
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Core backup pipeline
150
+ // ---------------------------------------------------------------------------
151
+
152
+ export interface BackupRunResult {
153
+ local: SnapshotEntry;
154
+ offsite: OffsiteWriteResult[];
155
+ durationMs: number;
156
+ }
157
+
158
+ interface BackupDeps {
159
+ /** Base URL of the assistant daemon (e.g. http://localhost:7821). */
160
+ assistantRuntimeBaseUrl: string;
161
+ }
162
+
163
+ /**
164
+ * Perform a single backup run:
165
+ * 1. Export plaintext vbundle from daemon
166
+ * 2. Write to local backup directory
167
+ * 3. Encrypt + mirror to offsite destinations
168
+ * 4. Apply retention
169
+ */
170
+ async function performBackup(
171
+ config: BackupConfig,
172
+ now: Date,
173
+ deps: BackupDeps,
174
+ ): Promise<BackupRunResult> {
175
+ const startTimestamp = Date.now();
176
+ const localDir = getLocalBackupsDir(config.localDirectory);
177
+
178
+ // Resolve offsite destinations
179
+ const destinations = config.offsite.enabled
180
+ ? (config.offsite.destinations ?? defaultOffsiteDestinations())
181
+ : [];
182
+
183
+ // Ensure the backup key if any destination needs encryption
184
+ const needsKey = destinations.some((d) => d.encrypt);
185
+ const key: Buffer | null = needsKey
186
+ ? await ensureBackupKey(getBackupKeyPath())
187
+ : null;
188
+
189
+ // Call the daemon's export endpoint to get a plaintext vbundle
190
+ const serviceToken = mintServiceToken();
191
+ const controller = new AbortController();
192
+ const timeoutId = setTimeout(() => controller.abort(), EXPORT_TIMEOUT_MS);
193
+
194
+ let response: Response;
195
+ try {
196
+ response = await fetchImpl(
197
+ `${deps.assistantRuntimeBaseUrl}/v1/migrations/export`,
198
+ {
199
+ method: "POST",
200
+ headers: {
201
+ Authorization: `Bearer ${serviceToken}`,
202
+ "Content-Type": "application/json",
203
+ },
204
+ body: JSON.stringify({ description: "Gateway backup worker" }),
205
+ signal: controller.signal,
206
+ },
207
+ );
208
+ } finally {
209
+ clearTimeout(timeoutId);
210
+ }
211
+
212
+ if (!response.ok) {
213
+ const body = await response.text().catch(() => "");
214
+ throw new Error(
215
+ `Daemon export failed (${response.status}): ${body.slice(0, 500)}`,
216
+ );
217
+ }
218
+
219
+ // Stream the response body to a temp file
220
+ const tempPath = join(tmpdir(), `vellum-backup-${randomUUID()}.vbundle`);
221
+ try {
222
+ const readableBody = response.body;
223
+ if (!readableBody) {
224
+ throw new Error("Daemon export returned an empty response body");
225
+ }
226
+
227
+ const writeStream = createWriteStream(tempPath);
228
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun's ReadableStream type doesn't match Node's web ReadableStream
229
+ const nodeReadable = Readable.fromWeb(readableBody as any);
230
+ await pipeline(nodeReadable, writeStream);
231
+
232
+ // Write the plaintext archive to the local backup directory
233
+ const localResult = await writeLocalSnapshot(tempPath, localDir, now);
234
+
235
+ // Mirror to offsite destinations (with encryption)
236
+ const offsiteResults = await writeOffsiteSnapshotToAll(
237
+ localResult.path,
238
+ destinations,
239
+ key,
240
+ now,
241
+ );
242
+
243
+ // Apply retention
244
+ await pruneLocalSnapshots(localDir, config.retention);
245
+ await pruneOffsiteSnapshotsInAll(destinations, config.retention);
246
+
247
+ log.info(
248
+ {
249
+ localPath: localResult.path,
250
+ offsite: offsiteResults.map((r) => ({
251
+ path: r.destination.path,
252
+ status: r.entry ? "ok" : r.skipped ? "skipped" : "error",
253
+ reason: r.skipped ?? r.error,
254
+ })),
255
+ },
256
+ "Backup snapshot complete",
257
+ );
258
+
259
+ return {
260
+ local: localResult,
261
+ offsite: offsiteResults,
262
+ durationMs: Date.now() - startTimestamp,
263
+ };
264
+ } catch (err) {
265
+ // Clean up temp file on failure
266
+ try {
267
+ await unlink(tempPath);
268
+ } catch {
269
+ // best-effort
270
+ }
271
+ throw err;
272
+ }
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Tick + worker lifecycle
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /** Prevent concurrent backup runs. */
280
+ let snapshotInProgress = false;
281
+
282
+ /**
283
+ * A single tick of the backup worker. Checks config, interval, and mutex
284
+ * before delegating to performBackup.
285
+ */
286
+ export async function runBackupTick(deps: BackupDeps): Promise<void> {
287
+ const config = readBackupConfig();
288
+ if (!config.enabled) return;
289
+
290
+ const now = new Date();
291
+ const lastRunAt = readLastRunAt();
292
+ const intervalMs = config.intervalHours * 3600_000;
293
+
294
+ if (lastRunAt > 0 && now.getTime() - lastRunAt < intervalMs) {
295
+ return; // Not due yet
296
+ }
297
+
298
+ if (snapshotInProgress) {
299
+ log.info("Backup tick skipped — snapshot already in progress");
300
+ return;
301
+ }
302
+
303
+ snapshotInProgress = true;
304
+ try {
305
+ await performBackup(config, now, deps);
306
+ writeLastRunAt(now.getTime());
307
+ } catch (err) {
308
+ log.error({ err }, "Backup tick failed");
309
+ } finally {
310
+ snapshotInProgress = false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Manual snapshot trigger. Bypasses the enabled + interval checks but
316
+ * still honors the concurrency mutex.
317
+ */
318
+ export async function createSnapshotNow(
319
+ deps: BackupDeps,
320
+ ): Promise<BackupRunResult> {
321
+ if (snapshotInProgress) {
322
+ throw new Error("A backup snapshot is already in progress");
323
+ }
324
+
325
+ const config = readBackupConfig();
326
+ const now = new Date();
327
+
328
+ snapshotInProgress = true;
329
+ try {
330
+ const result = await performBackup(config, now, deps);
331
+ writeLastRunAt(now.getTime());
332
+ return result;
333
+ } finally {
334
+ snapshotInProgress = false;
335
+ }
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Worker handle
340
+ // ---------------------------------------------------------------------------
341
+
342
+ export interface BackupWorkerHandle {
343
+ stop(): void;
344
+ runOnce(): Promise<void>;
345
+ }
346
+
347
+ /**
348
+ * Start the periodic backup worker. Returns a handle with stop() and
349
+ * runOnce() methods.
350
+ */
351
+ export function startBackupWorker(deps: BackupDeps): BackupWorkerHandle {
352
+ try {
353
+ const timer = setInterval(async () => {
354
+ try {
355
+ await runBackupTick(deps);
356
+ } catch (err) {
357
+ log.error({ err }, "Backup worker tick unhandled error");
358
+ }
359
+ }, TICK_INTERVAL_MS);
360
+
361
+ if (typeof timer.unref === "function") timer.unref();
362
+
363
+ return {
364
+ stop: () => clearInterval(timer),
365
+ runOnce: () => runBackupTick(deps),
366
+ };
367
+ } catch (err) {
368
+ log.warn({ err }, "Failed to start backup worker — continuing without it");
369
+ return {
370
+ stop: () => {},
371
+ runOnce: () => Promise.resolve(),
372
+ };
373
+ }
374
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Helpers for listing on-disk backup snapshots.
3
+ *
4
+ * A "snapshot" is any file inside a backup destination directory whose
5
+ * name matches the canonical `backup-YYYYMMDD-HHMMSS.vbundle[.enc]` pattern.
6
+ * Anything else is silently ignored.
7
+ */
8
+
9
+ import { readdir, stat, unlink } from "node:fs/promises";
10
+ import { dirname, join } from "node:path";
11
+
12
+ import { parseBackupTimestamp } from "./paths.js";
13
+
14
+ export interface SnapshotEntry {
15
+ path: string;
16
+ filename: string;
17
+ createdAt: Date;
18
+ sizeBytes: number;
19
+ encrypted: boolean;
20
+ }
21
+
22
+ /**
23
+ * Lists all backup snapshots in a directory, newest-first.
24
+ * Returns `[]` when the directory does not exist.
25
+ */
26
+ export async function listSnapshotsInDir(
27
+ dir: string,
28
+ ): Promise<SnapshotEntry[]> {
29
+ let names: string[];
30
+ try {
31
+ names = await readdir(dir);
32
+ } catch (err) {
33
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
34
+ throw err;
35
+ }
36
+
37
+ const entries: SnapshotEntry[] = [];
38
+ for (const name of names) {
39
+ const createdAt = parseBackupTimestamp(name);
40
+ if (createdAt == null) continue;
41
+ const fullPath = join(dir, name);
42
+ let stats;
43
+ try {
44
+ stats = await stat(fullPath);
45
+ } catch (err) {
46
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
47
+ throw err;
48
+ }
49
+ if (!stats.isFile()) continue;
50
+ entries.push({
51
+ path: fullPath,
52
+ filename: name,
53
+ createdAt,
54
+ sizeBytes: stats.size,
55
+ encrypted: name.endsWith(".vbundle.enc"),
56
+ });
57
+ }
58
+
59
+ entries.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
60
+ return entries;
61
+ }
62
+
63
+ /**
64
+ * Apply retention policy to a backup directory.
65
+ * Lists snapshots newest-first, keeps the first `retention`, deletes the rest.
66
+ */
67
+ export async function pruneDir(
68
+ dir: string,
69
+ retention: number,
70
+ ): Promise<{
71
+ kept: SnapshotEntry[];
72
+ deleted: SnapshotEntry[];
73
+ skipped?: boolean;
74
+ }> {
75
+ try {
76
+ await stat(dirname(dir));
77
+ } catch (err) {
78
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
79
+ return { kept: [], deleted: [], skipped: true };
80
+ }
81
+ throw err;
82
+ }
83
+
84
+ const snapshots = await listSnapshotsInDir(dir);
85
+ const kept = snapshots.slice(0, retention);
86
+ const deleted = snapshots.slice(retention);
87
+
88
+ for (const entry of deleted) {
89
+ try {
90
+ await unlink(entry.path);
91
+ } catch (err) {
92
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
93
+ }
94
+ }
95
+
96
+ return { kept, deleted };
97
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Local snapshot writer + retention pruner.
3
+ *
4
+ * The "local" destination is the on-device backup directory (typically under
5
+ * `~/.vellum/backups/local`). It always stores plaintext `.vbundle` files —
6
+ * the encrypted variant is reserved for offsite destinations where the user
7
+ * cannot rely on filesystem-level access controls.
8
+ */
9
+
10
+ import { randomBytes } from "node:crypto";
11
+ import { copyFile, mkdir, rename, stat, unlink } from "node:fs/promises";
12
+ import { basename, join } from "node:path";
13
+
14
+ import { pruneDir, type SnapshotEntry } from "./list-snapshots.js";
15
+ import { formatBackupFilename } from "./paths.js";
16
+
17
+ async function resolveUniqueDestPath(
18
+ localDir: string,
19
+ filename: string,
20
+ ): Promise<string> {
21
+ const primary = join(localDir, filename);
22
+ try {
23
+ await stat(primary);
24
+ } catch (err) {
25
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return primary;
26
+ throw err;
27
+ }
28
+ const extIdx = filename.indexOf(".vbundle");
29
+ const base = filename.slice(0, extIdx);
30
+ const ext = filename.slice(extIdx);
31
+ for (let attempt = 0; attempt < 5; attempt++) {
32
+ const token = randomBytes(3).toString("hex");
33
+ const candidate = join(localDir, `${base}-${token}${ext}`);
34
+ try {
35
+ await stat(candidate);
36
+ } catch (err) {
37
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return candidate;
38
+ throw err;
39
+ }
40
+ }
41
+ throw new Error(
42
+ `Unable to find a unique backup filename under ${localDir} for ${filename}`,
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Move a freshly-built `.vbundle` temp file into the local backup directory.
48
+ */
49
+ export async function writeLocalSnapshot(
50
+ tempVBundlePath: string,
51
+ localDir: string,
52
+ now: Date,
53
+ ): Promise<SnapshotEntry> {
54
+ await mkdir(localDir, { recursive: true, mode: 0o700 });
55
+
56
+ const baseFilename = formatBackupFilename(now, { encrypted: false });
57
+ const destPath = await resolveUniqueDestPath(localDir, baseFilename);
58
+ const filename = basename(destPath);
59
+
60
+ try {
61
+ await rename(tempVBundlePath, destPath);
62
+ } catch (err) {
63
+ if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
64
+ await copyFile(tempVBundlePath, destPath);
65
+ await unlink(tempVBundlePath);
66
+ }
67
+
68
+ const stats = await stat(destPath);
69
+ return {
70
+ path: destPath,
71
+ filename,
72
+ createdAt: now,
73
+ sizeBytes: stats.size,
74
+ encrypted: false,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Apply retention policy to the local backup directory.
80
+ */
81
+ export async function pruneLocalSnapshots(
82
+ localDir: string,
83
+ retention: number,
84
+ ): Promise<{ kept: SnapshotEntry[]; deleted: SnapshotEntry[] }> {
85
+ const { kept, deleted } = await pruneDir(localDir, retention);
86
+ return { kept, deleted };
87
+ }