@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,113 +0,0 @@
1
- // Accumulates counts of requests served via the legacy loopback auth fallback,
2
- // keyed by (guard, path, failureKind). The auth hot path calls `increment`
3
- // (an O(1) Map bump, no I/O); a background reporter periodically `drain`s the
4
- // accumulated counts and ships them to the daemon telemetry route. This is the
5
- // data complement to AuthFallbackLogThrottle's throttled log line: the log is a
6
- // sampled human signal, these counts are the exact volume.
7
-
8
- import { getLogger } from "./logger.js";
9
-
10
- const log = getLogger("auth-fallback-tracker");
11
-
12
- // Bounds memory if a caller floods distinct paths. Cardinality is normally
13
- // tiny (edge routes × failure kinds), so this only trips under abuse.
14
- const MAX_TRACKED_KEYS = 10_000;
15
-
16
- // Composite-key separator, mirroring AuthFallbackLogThrottle's `${guard} ${path}`
17
- // key. Safe because guard values are fixed slugs, URL pathnames percent-encode
18
- // spaces, and failureKind values are underscore tokens — none contain a space.
19
- const SEP = " ";
20
-
21
- /** One aggregated count for a single (guard, path, failureKind). */
22
- export interface AuthFallbackCount {
23
- guard: string;
24
- path: string;
25
- failureKind: string;
26
- count: number;
27
- }
28
-
29
- /** A drained window of counts, ready to ship. */
30
- export interface AuthFallbackBatch {
31
- windowStart: number;
32
- windowEnd: number;
33
- counts: AuthFallbackCount[];
34
- }
35
-
36
- export class AuthFallbackCountTracker {
37
- private counts = new Map<string, AuthFallbackCount>();
38
- private windowStartedAt: number;
39
- private warnedAtCap = false;
40
-
41
- constructor(now: number = Date.now()) {
42
- this.windowStartedAt = now;
43
- }
44
-
45
- /** Record one fallback. O(1), no I/O — safe on the auth hot path. */
46
- increment(guard: string, path: string, failureKind: string): void {
47
- this.add(guard, path, failureKind, 1);
48
- }
49
-
50
- /**
51
- * Drain and reset the accumulated counts. Returns the window covered and the
52
- * per-key counts. When nothing was tracked the window start is left anchored
53
- * (an empty drain doesn't shift the accumulation window).
54
- */
55
- drain(now: number = Date.now()): AuthFallbackBatch {
56
- const counts = [...this.counts.values()];
57
- const windowStart = this.windowStartedAt;
58
- if (counts.length === 0) {
59
- return { windowStart, windowEnd: now, counts };
60
- }
61
- this.counts = new Map();
62
- this.windowStartedAt = now;
63
- this.warnedAtCap = false;
64
- return { windowStart, windowEnd: now, counts };
65
- }
66
-
67
- /**
68
- * Fold a previously-drained batch back in — used by the reporter to avoid
69
- * losing a window when the daemon POST fails. Subject to the same key cap.
70
- */
71
- merge(counts: AuthFallbackCount[]): void {
72
- for (const c of counts) {
73
- this.add(c.guard, c.path, c.failureKind, c.count);
74
- }
75
- }
76
-
77
- /** Read-only view of the current counts (for tests/inspection). */
78
- snapshot(): AuthFallbackCount[] {
79
- return [...this.counts.values()].map((c) => ({ ...c }));
80
- }
81
-
82
- /** Clear all state (for test isolation). */
83
- reset(now: number = Date.now()): void {
84
- this.counts = new Map();
85
- this.windowStartedAt = now;
86
- this.warnedAtCap = false;
87
- }
88
-
89
- private add(
90
- guard: string,
91
- path: string,
92
- failureKind: string,
93
- delta: number,
94
- ): void {
95
- const key = `${guard}${SEP}${path}${SEP}${failureKind}`;
96
- const existing = this.counts.get(key);
97
- if (existing) {
98
- existing.count += delta;
99
- return;
100
- }
101
- if (this.counts.size >= MAX_TRACKED_KEYS) {
102
- if (!this.warnedAtCap) {
103
- this.warnedAtCap = true;
104
- log.warn(
105
- { maxKeys: MAX_TRACKED_KEYS },
106
- "Auth-fallback tracker at key cap — new (guard, path, failureKind) keys dropped until next flush",
107
- );
108
- }
109
- return;
110
- }
111
- this.counts.set(key, { guard, path, failureKind, count: delta });
112
- }
113
- }
@@ -1,127 +0,0 @@
1
- // Periodically drains AuthFallbackCountTracker and ships the accumulated counts
2
- // to the daemon's internal telemetry route, which persists them and forwards
3
- // them to the cloud. Best-effort background work: failures are logged and the
4
- // drained counts are merged back so a transient daemon blip doesn't lose a
5
- // window. Deliberately does NOT touch the runtime circuit breaker — this is
6
- // non-critical traffic and must never affect chat routing.
7
-
8
- import type {
9
- AuthFallbackCount,
10
- AuthFallbackCountTracker,
11
- } from "./auth-fallback-count-tracker.js";
12
- import type { fetchImpl } from "./fetch.js";
13
- import { postInternalTelemetry } from "./internal-telemetry-client.js";
14
- import { getLogger } from "./logger.js";
15
-
16
- const log = getLogger("auth-fallback-reporter");
17
-
18
- const ROUTE_PATH = "/v1/internal/telemetry/auth-fallback";
19
- const DEFAULT_FLUSH_INTERVAL_MS = 60_000;
20
-
21
- function getFlushIntervalMs(): number {
22
- const raw = process.env.AUTH_FALLBACK_FLUSH_INTERVAL_MS;
23
- if (raw) {
24
- const parsed = parseInt(raw, 10);
25
- if (!isNaN(parsed) && parsed > 0) return parsed;
26
- }
27
- return DEFAULT_FLUSH_INTERVAL_MS;
28
- }
29
-
30
- export type AuthFallbackReporterConfig = {
31
- tracker: AuthFallbackCountTracker;
32
- /** Daemon runtime base URL (`config.assistantRuntimeBaseUrl`). */
33
- baseUrl: string;
34
- /** Flush cadence; defaults to AUTH_FALLBACK_FLUSH_INTERVAL_MS or 60s. */
35
- intervalMs?: number;
36
- /** Injectable for tests. Defaults to the shared fetch wrapper. */
37
- fetchImpl?: typeof fetchImpl;
38
- /** Injectable for tests. Defaults to minting a real service token. */
39
- mintToken?: () => string;
40
- };
41
-
42
- /** Shape the daemon route expects (`failure_kind` is snake_case on the wire). */
43
- type WireCount = {
44
- guard: string;
45
- path: string;
46
- failure_kind: string;
47
- count: number;
48
- };
49
-
50
- export class AuthFallbackReporter {
51
- private readonly tracker: AuthFallbackCountTracker;
52
- private readonly baseUrl: string;
53
- private readonly intervalMs: number;
54
- private readonly doFetch: typeof fetchImpl | undefined;
55
- private readonly mintToken: (() => string) | undefined;
56
- private timer: ReturnType<typeof setInterval> | null = null;
57
-
58
- constructor(config: AuthFallbackReporterConfig) {
59
- this.tracker = config.tracker;
60
- this.baseUrl = config.baseUrl;
61
- this.intervalMs = config.intervalMs ?? getFlushIntervalMs();
62
- this.doFetch = config.fetchImpl;
63
- this.mintToken = config.mintToken;
64
- }
65
-
66
- start(): void {
67
- if (this.timer) return;
68
- this.timer = setInterval(() => {
69
- void this.flush();
70
- }, this.intervalMs);
71
- log.info({ intervalMs: this.intervalMs }, "Auth-fallback reporter started");
72
- }
73
-
74
- /** Stop the timer and flush whatever is buffered one last time. */
75
- async stop(): Promise<void> {
76
- if (this.timer) {
77
- clearInterval(this.timer);
78
- this.timer = null;
79
- }
80
- await this.flush();
81
- }
82
-
83
- /**
84
- * Drain the tracker and POST the counts to the daemon. On any failure the
85
- * drained counts are merged back so they retry on the next flush.
86
- */
87
- async flush(): Promise<void> {
88
- const batch = this.tracker.drain();
89
- if (batch.counts.length === 0) return;
90
-
91
- const body = {
92
- window_start: batch.windowStart,
93
- window_end: batch.windowEnd,
94
- counts: batch.counts.map(
95
- (c: AuthFallbackCount): WireCount => ({
96
- guard: c.guard,
97
- path: c.path,
98
- failure_kind: c.failureKind,
99
- count: c.count,
100
- }),
101
- ),
102
- };
103
-
104
- try {
105
- const resp = await postInternalTelemetry({
106
- baseUrl: this.baseUrl,
107
- path: ROUTE_PATH,
108
- body,
109
- fetchImpl: this.doFetch,
110
- mintToken: this.mintToken,
111
- });
112
- if (!resp.ok) {
113
- log.warn(
114
- { status: resp.status, keys: batch.counts.length },
115
- "Auth-fallback flush rejected — re-queueing counts for next flush",
116
- );
117
- this.tracker.merge(batch.counts);
118
- }
119
- } catch (err) {
120
- log.warn(
121
- { err, keys: batch.counts.length },
122
- "Auth-fallback flush failed — re-queueing counts for next flush",
123
- );
124
- this.tracker.merge(batch.counts);
125
- }
126
- }
127
- }
@@ -1,138 +0,0 @@
1
- /**
2
- * Backup key management.
3
- *
4
- * The backup key is a 32-byte random secret used to authenticate / encrypt
5
- * workspace backups. It is generated once per install and persisted to disk
6
- * in the gateway security directory — outside the workspace and outside the
7
- * assistant sandbox boundary.
8
- *
9
- * This module is intentionally pure: callers pass the full `keyPath` rather
10
- * than resolving a default location. That keeps the helpers trivially
11
- * testable against temp directories and avoids any coupling to gateway
12
- * startup, workspace layout, or global path helpers.
13
- *
14
- * On-disk invariants:
15
- * - Parent directory is created with mode `0o700`.
16
- * - Key file is written atomically (temp + `link`) with mode `0o600`, so
17
- * concurrent callers converge on the first winner's bytes.
18
- * - Key file is exactly 32 bytes; any other size is treated as corruption.
19
- */
20
-
21
- import { randomBytes } from "node:crypto";
22
- import {
23
- chmod,
24
- link,
25
- mkdir,
26
- readFile,
27
- stat,
28
- unlink,
29
- writeFile,
30
- } from "node:fs/promises";
31
- import { dirname } from "node:path";
32
-
33
- /** Required length of the backup key file, in bytes. */
34
- const BACKUP_KEY_LENGTH = 32;
35
-
36
- /**
37
- * Check whether a filesystem path exists without throwing.
38
- *
39
- * Only `ENOENT` is treated as "missing". Any other errno (EIO, ESTALE,
40
- * EACCES, ...) is rethrown — we must not silently treat a transient I/O
41
- * failure as "file is absent" because that can cause an existing backup
42
- * key to be rotated away under the caller's feet, breaking decryption of
43
- * data encrypted with the prior key.
44
- */
45
- async function pathExists(path: string): Promise<boolean> {
46
- try {
47
- await stat(path);
48
- return true;
49
- } catch (err) {
50
- if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return false;
51
- throw err;
52
- }
53
- }
54
-
55
- /**
56
- * Read the backup key from disk if it exists.
57
- *
58
- * Returns the raw 32-byte buffer, or `null` if the file is missing. Intended
59
- * for read-only callers (e.g. restore paths) that should not create a new
60
- * key as a side effect.
61
- *
62
- * Throws if the file exists but is not exactly 32 bytes — callers should
63
- * treat that as a corruption signal rather than silently regenerating.
64
- */
65
- export async function readBackupKey(keyPath: string): Promise<Buffer | null> {
66
- if (!(await pathExists(keyPath))) return null;
67
- const buf = await readFile(keyPath);
68
- if (buf.length !== BACKUP_KEY_LENGTH) {
69
- throw new Error(
70
- `Backup key at ${keyPath} has invalid length ${buf.length} (expected ${BACKUP_KEY_LENGTH})`,
71
- );
72
- }
73
- return buf;
74
- }
75
-
76
- /**
77
- * Ensure a backup key exists at `keyPath`, returning its bytes.
78
- *
79
- * - If the file exists, it is read and validated. A wrong-size file throws,
80
- * so a corrupt key is never silently replaced.
81
- * - Otherwise, the parent directory is created (mode `0o700`), a fresh
82
- * 32-byte random key is generated, written to a unique tmp file, and
83
- * atomically published to `keyPath` via `link()`.
84
- *
85
- * Concurrency: callers that race here must all converge on the same bytes
86
- * — otherwise one caller encrypts data with bytes that will never be
87
- * persisted and can never be decrypted.
88
- *
89
- * We use the canonical Unix atomic-create idiom: write full contents to
90
- * a per-call tmp file, then `link(tmp, keyPath)`. `link` fails with
91
- * `EEXIST` if `keyPath` already exists, which makes exactly one racing
92
- * caller the winner; the rest read the winner's bytes. `rename(2)` by
93
- * contrast overwrites the destination and is not race-safe here — two
94
- * renames can leave either caller's bytes on disk regardless of who
95
- * generated them, so a lost caller would return bytes that don't match
96
- * what's persisted. `link` avoids that entirely.
97
- */
98
- export async function ensureBackupKey(keyPath: string): Promise<Buffer> {
99
- const existing = await readBackupKey(keyPath);
100
- if (existing) return existing;
101
-
102
- const parent = dirname(keyPath);
103
- await mkdir(parent, { recursive: true, mode: 0o700 });
104
-
105
- const key = randomBytes(BACKUP_KEY_LENGTH);
106
- const tmpPath = `${keyPath}.tmp.${process.pid}.${randomBytes(8).toString("hex")}`;
107
- try {
108
- // `wx` fails if tmpPath somehow exists (stale orphan or collision) so
109
- // we never silently overwrite another writer's in-flight tmp file.
110
- await writeFile(tmpPath, key, { flag: "wx", mode: 0o600 });
111
- // Some platforms / umasks ignore the `mode` option on writeFile, so
112
- // enforce 0o600 explicitly before publishing.
113
- await chmod(tmpPath, 0o600);
114
- try {
115
- // Atomic publish: only one racing caller's link() succeeds.
116
- await link(tmpPath, keyPath);
117
- return key;
118
- } catch (err) {
119
- if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") throw err;
120
- // Another caller won the race. Return their bytes, not ours.
121
- const winner = await readBackupKey(keyPath);
122
- if (!winner) {
123
- throw new Error(
124
- `link() reported EEXIST but ${keyPath} is unreadable`,
125
- );
126
- }
127
- return winner;
128
- }
129
- } finally {
130
- // Remove our tmp file whether we won (tmp is a hard link to keyPath,
131
- // safe to unlink), lost, or errored. Best-effort.
132
- try {
133
- await unlink(tmpPath);
134
- } catch {
135
- // ignore
136
- }
137
- }
138
- }
@@ -1,161 +0,0 @@
1
- /**
2
- * Gateway HTTP routes for backup operations.
3
- *
4
- * These routes are the guardian-facing API for backup management. The
5
- * assistant daemon has no backup CLI or routes — all backup operations
6
- * go through the gateway, which owns the encryption key and performs
7
- * the encrypt/decrypt operations.
8
- *
9
- * Routes:
10
- * GET /v1/backups — list local + offsite snapshots
11
- * POST /v1/backups/create — manual snapshot trigger
12
- */
13
-
14
- import { join } from "node:path";
15
-
16
- import { readConfigFileOrEmpty } from "../config-file-utils.js";
17
- import { getLogger } from "../logger.js";
18
- import { listSnapshotsInDir, type SnapshotEntry } from "./list-snapshots.js";
19
- import { getBackupRootDir } from "./paths.js";
20
- import { createSnapshotNow } from "./backup-worker.js";
21
-
22
- const log = getLogger("backup-routes");
23
-
24
- // ---------------------------------------------------------------------------
25
- // Helpers
26
- // ---------------------------------------------------------------------------
27
-
28
- interface BackupDestination {
29
- path: string;
30
- encrypt: boolean;
31
- }
32
-
33
- function readBackupDestinations(): {
34
- localDir: string;
35
- offsiteDestinations: BackupDestination[];
36
- } {
37
- const raw = readConfigFileOrEmpty();
38
- const backup = (raw.backup ?? {}) as Record<string, unknown>;
39
-
40
- const localDirectory =
41
- typeof backup.localDirectory === "string" ? backup.localDirectory : null;
42
- const localDir = localDirectory ?? join(getBackupRootDir(), "local");
43
-
44
- const offsiteRaw = (backup.offsite ?? {}) as Record<string, unknown>;
45
- const offsiteEnabled = offsiteRaw.enabled !== false;
46
- let offsiteDestinations: BackupDestination[] = [];
47
-
48
- if (offsiteEnabled) {
49
- if (Array.isArray(offsiteRaw.destinations)) {
50
- offsiteDestinations = offsiteRaw.destinations
51
- .filter(
52
- (d): d is { path: string; encrypt?: boolean } =>
53
- d &&
54
- typeof d === "object" &&
55
- typeof (d as Record<string, unknown>).path === "string",
56
- )
57
- .map((d) => ({ path: d.path, encrypt: d.encrypt !== false }));
58
- }
59
- // null destinations = iCloud default, but we don't list those unless
60
- // they already have snapshots on disk.
61
- }
62
-
63
- return { localDir, offsiteDestinations };
64
- }
65
-
66
- function snapshotToJson(entry: SnapshotEntry): Record<string, unknown> {
67
- return {
68
- path: entry.path,
69
- filename: entry.filename,
70
- created_at: entry.createdAt.toISOString(),
71
- size_bytes: entry.sizeBytes,
72
- encrypted: entry.encrypted,
73
- };
74
- }
75
-
76
- // ---------------------------------------------------------------------------
77
- // Route handlers
78
- // ---------------------------------------------------------------------------
79
-
80
- interface BackupRouteDeps {
81
- assistantRuntimeBaseUrl: string;
82
- }
83
-
84
- /**
85
- * GET /v1/backups — list local and offsite snapshots.
86
- */
87
- export function createListBackupsHandler(_deps: BackupRouteDeps) {
88
- return async function handleListBackups(_req: Request): Promise<Response> {
89
- try {
90
- const { localDir, offsiteDestinations } = readBackupDestinations();
91
-
92
- const localSnapshots = await listSnapshotsInDir(localDir);
93
- const offsitePools: Array<{
94
- destination: BackupDestination;
95
- snapshots: SnapshotEntry[];
96
- }> = [];
97
-
98
- for (const dest of offsiteDestinations) {
99
- const snapshots = await listSnapshotsInDir(dest.path);
100
- offsitePools.push({ destination: dest, snapshots });
101
- }
102
-
103
- return Response.json({
104
- local: {
105
- directory: localDir,
106
- snapshots: localSnapshots.map(snapshotToJson),
107
- },
108
- offsite: offsitePools.map((pool) => ({
109
- directory: pool.destination.path,
110
- encrypted: pool.destination.encrypt,
111
- snapshots: pool.snapshots.map(snapshotToJson),
112
- })),
113
- });
114
- } catch (err) {
115
- const message = err instanceof Error ? err.message : String(err);
116
- log.error({ err }, "Failed to list backups");
117
- return Response.json(
118
- { error: "Internal Server Error", message },
119
- { status: 500 },
120
- );
121
- }
122
- };
123
- }
124
-
125
- /**
126
- * POST /v1/backups/create — manual snapshot trigger.
127
- */
128
- export function createBackupSnapshotHandler(deps: BackupRouteDeps) {
129
- return async function handleCreateBackup(_req: Request): Promise<Response> {
130
- try {
131
- const result = await createSnapshotNow(deps);
132
-
133
- return Response.json({
134
- success: true,
135
- local: snapshotToJson(result.local),
136
- offsite: result.offsite.map((r) => ({
137
- destination: r.destination.path,
138
- status: r.entry ? "ok" : r.skipped ? "skipped" : "error",
139
- entry: r.entry ? snapshotToJson(r.entry) : null,
140
- error: r.error ?? null,
141
- })),
142
- duration_ms: result.durationMs,
143
- });
144
- } catch (err) {
145
- const message = err instanceof Error ? err.message : String(err);
146
- log.error({ err }, "Manual backup snapshot failed");
147
-
148
- if (message.includes("already in progress")) {
149
- return Response.json(
150
- { error: "Conflict", message: "A backup snapshot is already in progress" },
151
- { status: 409 },
152
- );
153
- }
154
-
155
- return Response.json(
156
- { error: "Internal Server Error", message },
157
- { status: 500 },
158
- );
159
- }
160
- };
161
- }