@mycadet/gateway 0.12.10 → 0.13.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 (85) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  3. package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  7. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  8. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  9. package/openapi.json +1 -1
  10. package/package.json +1 -1
  11. package/src/__tests__/actor-token-revocation.test.ts +41 -34
  12. package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
  13. package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
  14. package/src/__tests__/credential-requests.test.ts +4 -4
  15. package/src/__tests__/devices-mine.test.ts +187 -0
  16. package/src/__tests__/edge-auth.test.ts +1 -20
  17. package/src/__tests__/edge-guardian-auth.test.ts +1 -17
  18. package/src/__tests__/feature-flag-resolver.test.ts +2 -30
  19. package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
  20. package/src/__tests__/feature-flags-route.test.ts +2 -191
  21. package/src/__tests__/guardian-bootstrap-lookups.test.ts +22 -29
  22. package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
  23. package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
  24. package/src/__tests__/guardian-integrity.test.ts +16 -20
  25. package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
  26. package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
  27. package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
  28. package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
  29. package/src/__tests__/refresh-device-binding.test.ts +0 -12
  30. package/src/__tests__/remote-web-pairing-token.test.ts +5 -15
  31. package/src/__tests__/runtime-client.test.ts +7 -7
  32. package/src/__tests__/speech-relay-websocket.test.ts +4 -4
  33. package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
  34. package/src/__tests__/twilio-webhooks.test.ts +2 -2
  35. package/src/auth/actor-token-revocation.ts +34 -9
  36. package/src/auth/guardian-bootstrap.ts +1 -1
  37. package/src/cli/__tests__/pairing.test.ts +95 -0
  38. package/src/cli/index.ts +1 -1
  39. package/src/cli/pairing.ts +81 -6
  40. package/src/contacts-mirror-op-reporter.ts +19 -36
  41. package/src/db/connection.ts +0 -10
  42. package/src/feature-flag-resolver.ts +2 -7
  43. package/src/feature-flag-watcher.ts +1 -14
  44. package/src/guardian-integrity-reporter.ts +19 -47
  45. package/src/http/middleware/auth.ts +33 -27
  46. package/src/http/routes/auth-token.ts +1 -1
  47. package/src/http/routes/devices-mine.ts +103 -0
  48. package/src/http/routes/feature-flags.ts +1 -8
  49. package/src/http/routes/guardian-pin.ts +2 -11
  50. package/src/http/routes/ipc-runtime-proxy.ts +1 -1
  51. package/src/http/routes/live-voice-websocket.ts +1 -1
  52. package/src/http/routes/runtime-audio-stream.ts +1 -1
  53. package/src/http/routes/runtime-proxy.ts +2 -2
  54. package/src/http/routes/speech-relay-websocket.ts +2 -2
  55. package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
  56. package/src/index.ts +11 -198
  57. package/src/ipc/feature-flag-handlers.ts +2 -9
  58. package/src/remote-web/pairing-challenge-store.ts +15 -0
  59. package/src/risk/command-registry/commands/assistant.ts +0 -24
  60. package/src/risk/command-registry.test.ts +2 -4
  61. package/src/schema.ts +44 -372
  62. package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
  63. package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
  64. package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
  65. package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
  66. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
  67. package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
  68. package/src/auth-fallback-count-tracker.ts +0 -113
  69. package/src/auth-fallback-reporter.ts +0 -127
  70. package/src/backup/backup-key.ts +0 -138
  71. package/src/backup/backup-routes.ts +0 -161
  72. package/src/backup/backup-worker.ts +0 -425
  73. package/src/backup/list-snapshots.ts +0 -97
  74. package/src/backup/local-writer.ts +0 -87
  75. package/src/backup/offsite-writer.ts +0 -182
  76. package/src/backup/paths.ts +0 -113
  77. package/src/backup/stream-crypt.ts +0 -258
  78. package/src/device-id.ts +0 -18
  79. package/src/feature-flag-remote-store.ts +0 -166
  80. package/src/http/routes/migration-proxy.ts +0 -636
  81. package/src/internal-telemetry-client.ts +0 -39
  82. package/src/ipc/debug-export-handlers.test.ts +0 -102
  83. package/src/ipc/debug-export-handlers.ts +0 -81
  84. package/src/remote-feature-flag-sync.ts +0 -440
  85. package/src/watchdog-reporter.ts +0 -110
@@ -1,425 +0,0 @@
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 { isCloudServedBuild } from "../feature-flag-resolver.js";
29
- import { getLogger } from "../logger.js";
30
- import { getGatewaySecurityDir } from "../paths.js";
31
- import { ensureBackupKey } from "./backup-key.js";
32
- import type { SnapshotEntry } from "./list-snapshots.js";
33
- import { pruneLocalSnapshots, writeLocalSnapshot } from "./local-writer.js";
34
- import type {
35
- BackupDestination,
36
- OffsiteWriteResult,
37
- } from "./offsite-writer.js";
38
- import {
39
- pruneOffsiteSnapshotsInAll,
40
- writeOffsiteSnapshotToAll,
41
- } from "./offsite-writer.js";
42
- import { getBackupKeyPath, getBackupRootDir } from "./paths.js";
43
-
44
- const log = getLogger("backup-worker");
45
-
46
- /** Tick interval: check every 5 minutes whether a backup is due. */
47
- const TICK_INTERVAL_MS = 5 * 60 * 1000;
48
-
49
- /** Timeout for the daemon export request (60 minutes for large workspaces). */
50
- const EXPORT_TIMEOUT_MS = 60 * 60 * 1000;
51
-
52
- /** File used to persist the last successful backup timestamp across restarts. */
53
- const LAST_RUN_FILENAME = "backup-last-run-at";
54
-
55
- // ---------------------------------------------------------------------------
56
- // Config reading
57
- // ---------------------------------------------------------------------------
58
-
59
- interface BackupConfig {
60
- enabled: boolean;
61
- intervalHours: number;
62
- retention: number;
63
- offsite: {
64
- enabled: boolean;
65
- destinations: BackupDestination[] | null;
66
- };
67
- localDirectory: string | null;
68
- }
69
-
70
- /** Default iCloud Drive destination (macOS) with encryption enabled. */
71
- function defaultOffsiteDestinations(): BackupDestination[] {
72
- if (process.platform !== "darwin") {
73
- return [];
74
- }
75
- const home = process.env.HOME || "";
76
- if (!home) {
77
- return [];
78
- }
79
- return [
80
- {
81
- path: join(
82
- home,
83
- "Library",
84
- "Mobile Documents",
85
- "com~apple~CloudDocs",
86
- "CadetAssistant",
87
- "backups",
88
- ),
89
- encrypt: true,
90
- },
91
- ];
92
- }
93
-
94
- function readBackupConfig(): BackupConfig {
95
- const raw = readConfigFileOrEmpty();
96
- const backup = (raw.backup ?? {}) as Record<string, unknown>;
97
-
98
- const enabled = backup.enabled === true;
99
- const intervalHours =
100
- typeof backup.intervalHours === "number" ? backup.intervalHours : 6;
101
- const retention = typeof backup.retention === "number" ? backup.retention : 3;
102
-
103
- const offsiteRaw = (backup.offsite ?? {}) as Record<string, unknown>;
104
- const offsiteEnabled = offsiteRaw.enabled !== false;
105
- let destinations: BackupDestination[] | null = null;
106
- if (Array.isArray(offsiteRaw.destinations)) {
107
- destinations = offsiteRaw.destinations
108
- .filter(
109
- (d): d is { path: string; encrypt?: boolean } =>
110
- d &&
111
- typeof d === "object" &&
112
- typeof (d as Record<string, unknown>).path === "string",
113
- )
114
- .map((d) => ({
115
- path: d.path,
116
- encrypt: d.encrypt !== false,
117
- }));
118
- }
119
-
120
- const localDirectory =
121
- typeof backup.localDirectory === "string" ? backup.localDirectory : null;
122
-
123
- return {
124
- enabled,
125
- intervalHours,
126
- retention,
127
- offsite: { enabled: offsiteEnabled, destinations },
128
- localDirectory,
129
- };
130
- }
131
-
132
- // ---------------------------------------------------------------------------
133
- // Checkpoint persistence
134
- // ---------------------------------------------------------------------------
135
-
136
- function getCheckpointPath(): string {
137
- return join(getGatewaySecurityDir(), LAST_RUN_FILENAME);
138
- }
139
-
140
- function readLastRunAt(): number {
141
- try {
142
- const raw = readFileSync(getCheckpointPath(), "utf-8").trim();
143
- const ts = Number(raw);
144
- return Number.isFinite(ts) ? ts : 0;
145
- } catch {
146
- return 0;
147
- }
148
- }
149
-
150
- function writeLastRunAt(timestamp: number): void {
151
- try {
152
- writeFileSync(getCheckpointPath(), String(timestamp), "utf-8");
153
- } catch (err) {
154
- log.warn({ err }, "Failed to persist backup checkpoint");
155
- }
156
- }
157
-
158
- // ---------------------------------------------------------------------------
159
- // Core backup pipeline
160
- // ---------------------------------------------------------------------------
161
-
162
- export interface BackupRunResult {
163
- local: SnapshotEntry;
164
- offsite: OffsiteWriteResult[];
165
- durationMs: number;
166
- }
167
-
168
- interface BackupDeps {
169
- /** Base URL of the assistant daemon (e.g. http://localhost:7821). */
170
- assistantRuntimeBaseUrl: string;
171
- /**
172
- * Deadline covering the export request and its body transfer. Defaults to
173
- * `EXPORT_TIMEOUT_MS`; tests shorten it to exercise a stalled transfer.
174
- */
175
- exportTimeoutMs?: number;
176
- }
177
-
178
- /**
179
- * Perform a single backup run:
180
- * 1. Export plaintext vbundle from daemon
181
- * 2. Write to local backup directory
182
- * 3. Encrypt + mirror to offsite destinations
183
- * 4. Apply retention
184
- */
185
- async function performBackup(
186
- config: BackupConfig,
187
- now: Date,
188
- deps: BackupDeps,
189
- ): Promise<BackupRunResult> {
190
- const startTimestamp = Date.now();
191
- const localDir = config.localDirectory ?? join(getBackupRootDir(), "local");
192
-
193
- // Resolve offsite destinations
194
- const destinations = config.offsite.enabled
195
- ? (config.offsite.destinations ?? defaultOffsiteDestinations())
196
- : [];
197
-
198
- // Ensure the backup key if any destination needs encryption
199
- const needsKey = destinations.some((d) => d.encrypt);
200
- const key: Buffer | null = needsKey
201
- ? await ensureBackupKey(getBackupKeyPath())
202
- : null;
203
-
204
- // Call the daemon's export endpoint to get a plaintext vbundle.
205
- //
206
- // Two separate deadlines have to be right here.
207
- //
208
- // `timeout: false` disables the runtime's own 300-second default. The daemon
209
- // builds the whole archive before it can write a single response header, and
210
- // on a large workspace that build runs for many minutes — so the default
211
- // fires before any header arrives and the export fails with `TimeoutError`.
212
- // Supplying `signal` is not enough: the runtime's default is not extended by
213
- // a caller's abort signal, so without this the 60-minute budget below is
214
- // silently capped at five.
215
- //
216
- // The abort deadline then has to cover the body, not just the handshake:
217
- // `fetch` resolves as soon as the response headers land, and the bundle
218
- // streams in afterwards. Clearing the timer on that resolution would leave
219
- // the download undeadlined, so a body stalling mid-transfer would hang
220
- // `performBackup` forever — and with it the `snapshotInProgress` mutex,
221
- // which only releases in this function's caller.
222
- const serviceToken = mintServiceToken();
223
- const controller = new AbortController();
224
- const timeoutId = setTimeout(
225
- () => controller.abort(),
226
- deps.exportTimeoutMs ?? EXPORT_TIMEOUT_MS,
227
- );
228
-
229
- // Stream the response body to a temp file
230
- const tempPath = join(tmpdir(), `cadet-backup-${randomUUID()}.vbundle`);
231
- try {
232
- const response = await fetchImpl(
233
- `${deps.assistantRuntimeBaseUrl}/v1/migrations/export`,
234
- {
235
- method: "POST",
236
- headers: {
237
- Authorization: `Bearer ${serviceToken}`,
238
- "Content-Type": "application/json",
239
- },
240
- body: JSON.stringify({ description: "Gateway backup worker" }),
241
- signal: controller.signal,
242
- timeout: false,
243
- },
244
- );
245
-
246
- if (!response.ok) {
247
- const body = await response.text().catch(() => "");
248
- throw new Error(
249
- `Daemon export failed (${response.status}): ${body.slice(0, 500)}`,
250
- );
251
- }
252
-
253
- const readableBody = response.body;
254
- if (!readableBody) {
255
- throw new Error("Daemon export returned an empty response body");
256
- }
257
-
258
- const writeStream = createWriteStream(tempPath);
259
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun's ReadableStream type doesn't match Node's web ReadableStream
260
- const nodeReadable = Readable.fromWeb(readableBody as any);
261
- // Aborting the controller tears the body stream down; passing the signal
262
- // to the pipeline makes that abort reject here rather than leaving the
263
- // write half waiting on a source that will never produce another chunk.
264
- await pipeline(nodeReadable, writeStream, { signal: controller.signal });
265
-
266
- // The transfer is done — everything past this point is local work under
267
- // its own failure handling, so the export deadline has served its purpose.
268
- clearTimeout(timeoutId);
269
-
270
- // Write the plaintext archive to the local backup directory
271
- const localResult = await writeLocalSnapshot(tempPath, localDir, now);
272
-
273
- // Mirror to offsite destinations (with encryption)
274
- const offsiteResults = await writeOffsiteSnapshotToAll(
275
- localResult.path,
276
- destinations,
277
- key,
278
- now,
279
- );
280
-
281
- // Apply retention
282
- await pruneLocalSnapshots(localDir, config.retention);
283
- await pruneOffsiteSnapshotsInAll(destinations, config.retention);
284
-
285
- log.info(
286
- {
287
- localPath: localResult.path,
288
- offsite: offsiteResults.map((r) => ({
289
- path: r.destination.path,
290
- status: r.entry ? "ok" : r.skipped ? "skipped" : "error",
291
- reason: r.skipped ?? r.error,
292
- })),
293
- },
294
- "Backup snapshot complete",
295
- );
296
-
297
- return {
298
- local: localResult,
299
- offsite: offsiteResults,
300
- durationMs: Date.now() - startTimestamp,
301
- };
302
- } catch (err) {
303
- // Clean up temp file on failure
304
- try {
305
- await unlink(tempPath);
306
- } catch {
307
- // best-effort
308
- }
309
- throw err;
310
- } finally {
311
- // No-op once the transfer cleared it; the safety net for every path that
312
- // leaves before that point.
313
- clearTimeout(timeoutId);
314
- }
315
- }
316
-
317
- // ---------------------------------------------------------------------------
318
- // Tick + worker lifecycle
319
- // ---------------------------------------------------------------------------
320
-
321
- /** Prevent concurrent backup runs. */
322
- let snapshotInProgress = false;
323
-
324
- /**
325
- * A single tick of the backup worker. Checks config, interval, and mutex
326
- * before delegating to performBackup.
327
- */
328
- export async function runBackupTick(deps: BackupDeps): Promise<void> {
329
- const config = readBackupConfig();
330
- if (!config.enabled) return;
331
-
332
- const now = new Date();
333
- const lastRunAt = readLastRunAt();
334
- const intervalMs = config.intervalHours * 3600_000;
335
-
336
- if (lastRunAt > 0 && now.getTime() - lastRunAt < intervalMs) {
337
- return; // Not due yet
338
- }
339
-
340
- if (snapshotInProgress) {
341
- log.info("Backup tick skipped — snapshot already in progress");
342
- return;
343
- }
344
-
345
- snapshotInProgress = true;
346
- try {
347
- await performBackup(config, now, deps);
348
- writeLastRunAt(now.getTime());
349
- } catch (err) {
350
- log.error({ err }, "Backup tick failed");
351
- } finally {
352
- snapshotInProgress = false;
353
- }
354
- }
355
-
356
- /**
357
- * Manual snapshot trigger. Bypasses the enabled + interval checks but
358
- * still honors the concurrency mutex.
359
- */
360
- export async function createSnapshotNow(
361
- deps: BackupDeps,
362
- ): Promise<BackupRunResult> {
363
- if (snapshotInProgress) {
364
- throw new Error("A backup snapshot is already in progress");
365
- }
366
-
367
- const config = readBackupConfig();
368
- const now = new Date();
369
-
370
- snapshotInProgress = true;
371
- try {
372
- const result = await performBackup(config, now, deps);
373
- writeLastRunAt(now.getTime());
374
- return result;
375
- } finally {
376
- snapshotInProgress = false;
377
- }
378
- }
379
-
380
- // ---------------------------------------------------------------------------
381
- // Worker handle
382
- // ---------------------------------------------------------------------------
383
-
384
- export interface BackupWorkerHandle {
385
- stop(): void;
386
- runOnce(): Promise<void>;
387
- }
388
-
389
- function createDisabledBackupWorkerHandle(): BackupWorkerHandle {
390
- return {
391
- stop: () => {},
392
- runOnce: () => Promise.resolve(),
393
- };
394
- }
395
-
396
- /**
397
- * Start the periodic backup worker. Returns a handle with stop() and
398
- * runOnce() methods.
399
- */
400
- export function startBackupWorker(deps: BackupDeps): BackupWorkerHandle {
401
- if (isCloudServedBuild()) {
402
- log.info("Backup worker disabled in platform mode");
403
- return createDisabledBackupWorkerHandle();
404
- }
405
-
406
- try {
407
- const timer = setInterval(async () => {
408
- try {
409
- await runBackupTick(deps);
410
- } catch (err) {
411
- log.error({ err }, "Backup worker tick unhandled error");
412
- }
413
- }, TICK_INTERVAL_MS);
414
-
415
- if (typeof timer.unref === "function") timer.unref();
416
-
417
- return {
418
- stop: () => clearInterval(timer),
419
- runOnce: () => runBackupTick(deps),
420
- };
421
- } catch (err) {
422
- log.warn({ err }, "Failed to start backup worker — continuing without it");
423
- return createDisabledBackupWorkerHandle();
424
- }
425
- }
@@ -1,97 +0,0 @@
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
- }
@@ -1,87 +0,0 @@
1
- /**
2
- * Local snapshot writer + retention pruner.
3
- *
4
- * The "local" destination is the on-device backup directory (typically under
5
- * `~/.cadet/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
- }