@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,217 @@
1
+ /**
2
+ * Policy enforcement for IPC-proxied routes.
3
+ *
4
+ * The gateway owns scope and principal-type enforcement for requests
5
+ * routed through the IPC proxy. Each protected route is registered by
6
+ * operationId — the same identifier the route schema cache uses for
7
+ * matching. Unregistered operationIds have no policy (open access once
8
+ * past JWT validation).
9
+ *
10
+ * This registry mirrors the daemon's route-policy.ts but is keyed by
11
+ * operationId rather than endpoint, and lives gateway-side so policy
12
+ * enforcement doesn't depend on the daemon.
13
+ */
14
+
15
+ import type { PrincipalType, Scope } from "./types.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface IpcRoutePolicy {
22
+ requiredScopes: readonly Scope[];
23
+ allowedPrincipalTypes: readonly PrincipalType[];
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Default principal types — most routes allow all four.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const ALL_PRINCIPALS: readonly PrincipalType[] = [
31
+ "actor",
32
+ "svc_gateway",
33
+ "svc_daemon",
34
+ "local",
35
+ ];
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Registry
39
+ // ---------------------------------------------------------------------------
40
+
41
+ type PolicyEntry =
42
+ | [operationId: string, scopes: Scope[]]
43
+ | [operationId: string, scopes: Scope[], principals: PrincipalType[]];
44
+
45
+ /**
46
+ * Compact policy table. Two-element tuples use ALL_PRINCIPALS;
47
+ * three-element tuples specify restricted principal types.
48
+ */
49
+ const POLICY_TABLE: PolicyEntry[] = [
50
+ // Admin / internal
51
+ ["admin_rollbackmigrations_post", ["internal.write"], ["svc_gateway"]],
52
+
53
+ // Calls
54
+ ["calls_answer", ["calls.write"]],
55
+ ["calls_cancel", ["calls.write"]],
56
+ ["calls_get", ["calls.read"]],
57
+ ["calls_instruction", ["calls.write"]],
58
+ ["calls_start", ["calls.write"]],
59
+
60
+ // Channel readiness
61
+ ["channels_readiness_get", ["settings.read"]],
62
+ ["channels_readiness_refresh_post", ["settings.write"]],
63
+
64
+ // Config / platform
65
+ ["config_platform_get", ["settings.read"]],
66
+ ["config_platform_put", ["settings.write"]],
67
+
68
+ // Diagnostics
69
+ ["diagnostics_envvars_get", ["settings.read"]],
70
+
71
+ // Dictation / STT / TTS
72
+ ["dictation_post", ["chat.write"]],
73
+ ["messages_tts", ["chat.read"]],
74
+ ["stt_providers", ["settings.read"]],
75
+ ["stt_transcribe", ["chat.write"]],
76
+ ["tts_synthesize", ["chat.read"]],
77
+
78
+ // Documents
79
+ ["getDocument", ["settings.read"]],
80
+ ["listDocuments", ["settings.read"]],
81
+ ["saveDocument", ["settings.write"]],
82
+
83
+ // Filing / heartbeat
84
+ ["getFilingConfig", ["settings.read"]],
85
+ ["getHeartbeatConfig", ["settings.read"]],
86
+ ["runFilingNow", ["settings.write"]],
87
+ ["runHeartbeatNow", ["settings.write"]],
88
+ ["updateHeartbeatConfig", ["settings.write"]],
89
+
90
+ // Integrations / ingress
91
+ ["integrations_ingress_config_get", ["settings.read"]],
92
+ ["integrations_ingress_config_put", ["settings.write"]],
93
+ ["integrations_oauth_start_post", ["settings.write"]],
94
+
95
+ // Integrations / Slack channel
96
+ ["integrations_slack_channel_config_get", ["settings.read"]],
97
+ ["integrations_slack_channel_config_post", ["settings.write"]],
98
+ ["integrations_slack_channel_config_delete", ["settings.write"]],
99
+
100
+ // Integrations / Telegram
101
+ ["integrations_telegram_config_get", ["settings.read"]],
102
+ ["integrations_telegram_config_post", ["settings.write"]],
103
+ ["integrations_telegram_config_delete", ["settings.write"]],
104
+ ["integrations_telegram_commands_post", ["settings.write"]],
105
+ ["integrations_telegram_setup_post", ["settings.write"]],
106
+
107
+ // Integrations / Twilio
108
+ ["integrations_twilio_config_get", ["settings.read"]],
109
+ ["integrations_twilio_credentials_post", ["settings.write"]],
110
+ ["integrations_twilio_credentials_delete", ["settings.write"]],
111
+ ["integrations_twilio_numbers_get", ["settings.read"]],
112
+ ["integrations_twilio_numbers_provision_post", ["settings.write"]],
113
+ ["integrations_twilio_numbers_assign_post", ["settings.write"]],
114
+ ["integrations_twilio_numbers_release_post", ["settings.write"]],
115
+
116
+ // Integrations / Vercel
117
+ ["integrations_vercel_config_get", ["settings.read"]],
118
+ ["integrations_vercel_config_post", ["settings.write"]],
119
+ ["integrations_vercel_config_delete", ["settings.write"]],
120
+
121
+ // Slack share
122
+ ["slack_channels_get", ["settings.read"]],
123
+ ["slack_share_post", ["settings.write"]],
124
+
125
+ // Memory items
126
+ ["createMemoryItem", ["settings.write"]],
127
+ ["deleteMemoryItem", ["settings.write"]],
128
+ ["getMemoryItem", ["settings.read"]],
129
+ ["listMemoryItems", ["settings.read"]],
130
+ ["updateMemoryItem", ["settings.write"]],
131
+
132
+ // Notification intent
133
+ ["notificationintentresult_post", ["settings.write"]],
134
+
135
+ // OAuth
136
+ ["oauth_apps_connect_post", ["settings.write"]],
137
+ ["oauth_apps_connections_get", ["settings.read"]],
138
+ ["oauth_apps_delete", ["settings.write"]],
139
+ ["oauth_apps_get", ["settings.read"]],
140
+ ["oauth_apps_post", ["settings.write"]],
141
+ ["oauth_connections_delete", ["settings.write"]],
142
+ ["oauth_providers_by_providerKey_get", ["settings.read"]],
143
+ ["oauth_providers_get", ["settings.read"]],
144
+ ["oauth_start_post", ["settings.write"]],
145
+
146
+ // Profiler (gateway-only)
147
+ ["profiler_runs_by_runId_delete", ["internal.write"], ["svc_gateway"]],
148
+ ["profiler_runs_by_runId_export_post", ["internal.write"], ["svc_gateway"]],
149
+ ["profiler_runs_by_runId_get", ["internal.write"], ["svc_gateway"]],
150
+ ["profiler_runs_get", ["internal.write"], ["svc_gateway"]],
151
+
152
+ // Recordings
153
+ ["recordings_pause", ["settings.write"]],
154
+ ["recordings_resume", ["settings.write"]],
155
+ ["recordings_start", ["settings.write"]],
156
+ ["recordings_status_get", ["settings.read"]],
157
+ ["recordings_status_post", ["settings.write"]],
158
+ ["recordings_stop", ["settings.write"]],
159
+
160
+ // Settings
161
+ ["settings_avatar_generate_post", ["settings.write"]],
162
+ ["settings_client_put", ["settings.write"]],
163
+ ["settings_voice_put", ["settings.write"]],
164
+
165
+ // Skills
166
+ ["checkSkillUpdates", ["settings.write"]],
167
+ ["configureSkill", ["settings.write"]],
168
+ ["createSkill", ["settings.write"]],
169
+ ["deleteSkill", ["settings.write"]],
170
+ ["disableSkill", ["settings.write"]],
171
+ ["draftSkill", ["settings.write"]],
172
+ ["enableSkill", ["settings.write"]],
173
+ ["getSkill", ["settings.read"]],
174
+ ["getSkillFileContent", ["settings.read"]],
175
+ ["getSkillFiles", ["settings.read"]],
176
+ ["inspectSkill", ["settings.read"]],
177
+ ["installSkill", ["settings.write"]],
178
+ ["listSkills", ["settings.read"]],
179
+ ["searchSkills", ["settings.read"]],
180
+ ["updateSkill", ["settings.write"]],
181
+
182
+ // Tools
183
+ ["tools_get", ["settings.read"]],
184
+ ["tools_simulate_permission_post", ["settings.read"]],
185
+
186
+ // Workspace files
187
+ ["workspacefiles_get", ["settings.read"]],
188
+ ["workspacefiles_read_get", ["settings.read"]],
189
+ ];
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Build the lookup map
193
+ // ---------------------------------------------------------------------------
194
+
195
+ const policyMap = new Map<string, IpcRoutePolicy>();
196
+
197
+ for (const entry of POLICY_TABLE) {
198
+ const [operationId, scopes, principals] = entry;
199
+ policyMap.set(operationId, {
200
+ requiredScopes: scopes,
201
+ allowedPrincipalTypes: principals ?? ALL_PRINCIPALS,
202
+ });
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Public API
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * Look up the IPC route policy for an operationId.
211
+ * Returns undefined for unregistered (unprotected) operations.
212
+ */
213
+ export function getIpcRoutePolicy(
214
+ operationId: string,
215
+ ): IpcRoutePolicy | undefined {
216
+ return policyMap.get(operationId);
217
+ }
@@ -0,0 +1,138 @@
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
+ }
@@ -0,0 +1,159 @@
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 { readConfigFileOrEmpty } from "../config-file-utils.js";
15
+ import { getLogger } from "../logger.js";
16
+ import { listSnapshotsInDir, type SnapshotEntry } from "./list-snapshots.js";
17
+ import { getLocalBackupsDir } from "./paths.js";
18
+ import { createSnapshotNow } from "./backup-worker.js";
19
+
20
+ const log = getLogger("backup-routes");
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ interface BackupDestination {
27
+ path: string;
28
+ encrypt: boolean;
29
+ }
30
+
31
+ function readBackupDestinations(): {
32
+ localDir: string;
33
+ offsiteDestinations: BackupDestination[];
34
+ } {
35
+ const raw = readConfigFileOrEmpty();
36
+ const backup = (raw.backup ?? {}) as Record<string, unknown>;
37
+
38
+ const localDirectory =
39
+ typeof backup.localDirectory === "string" ? backup.localDirectory : null;
40
+ const localDir = getLocalBackupsDir(localDirectory);
41
+
42
+ const offsiteRaw = (backup.offsite ?? {}) as Record<string, unknown>;
43
+ const offsiteEnabled = offsiteRaw.enabled !== false;
44
+ let offsiteDestinations: BackupDestination[] = [];
45
+
46
+ if (offsiteEnabled) {
47
+ if (Array.isArray(offsiteRaw.destinations)) {
48
+ offsiteDestinations = offsiteRaw.destinations
49
+ .filter(
50
+ (d): d is { path: string; encrypt?: boolean } =>
51
+ d &&
52
+ typeof d === "object" &&
53
+ typeof (d as Record<string, unknown>).path === "string",
54
+ )
55
+ .map((d) => ({ path: d.path, encrypt: d.encrypt !== false }));
56
+ }
57
+ // null destinations = iCloud default, but we don't list those unless
58
+ // they already have snapshots on disk.
59
+ }
60
+
61
+ return { localDir, offsiteDestinations };
62
+ }
63
+
64
+ function snapshotToJson(entry: SnapshotEntry): Record<string, unknown> {
65
+ return {
66
+ path: entry.path,
67
+ filename: entry.filename,
68
+ created_at: entry.createdAt.toISOString(),
69
+ size_bytes: entry.sizeBytes,
70
+ encrypted: entry.encrypted,
71
+ };
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Route handlers
76
+ // ---------------------------------------------------------------------------
77
+
78
+ interface BackupRouteDeps {
79
+ assistantRuntimeBaseUrl: string;
80
+ }
81
+
82
+ /**
83
+ * GET /v1/backups — list local and offsite snapshots.
84
+ */
85
+ export function createListBackupsHandler(_deps: BackupRouteDeps) {
86
+ return async function handleListBackups(_req: Request): Promise<Response> {
87
+ try {
88
+ const { localDir, offsiteDestinations } = readBackupDestinations();
89
+
90
+ const localSnapshots = await listSnapshotsInDir(localDir);
91
+ const offsitePools: Array<{
92
+ destination: BackupDestination;
93
+ snapshots: SnapshotEntry[];
94
+ }> = [];
95
+
96
+ for (const dest of offsiteDestinations) {
97
+ const snapshots = await listSnapshotsInDir(dest.path);
98
+ offsitePools.push({ destination: dest, snapshots });
99
+ }
100
+
101
+ return Response.json({
102
+ local: {
103
+ directory: localDir,
104
+ snapshots: localSnapshots.map(snapshotToJson),
105
+ },
106
+ offsite: offsitePools.map((pool) => ({
107
+ directory: pool.destination.path,
108
+ encrypted: pool.destination.encrypt,
109
+ snapshots: pool.snapshots.map(snapshotToJson),
110
+ })),
111
+ });
112
+ } catch (err) {
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ log.error({ err }, "Failed to list backups");
115
+ return Response.json(
116
+ { error: "Internal Server Error", message },
117
+ { status: 500 },
118
+ );
119
+ }
120
+ };
121
+ }
122
+
123
+ /**
124
+ * POST /v1/backups/create — manual snapshot trigger.
125
+ */
126
+ export function createBackupSnapshotHandler(deps: BackupRouteDeps) {
127
+ return async function handleCreateBackup(_req: Request): Promise<Response> {
128
+ try {
129
+ const result = await createSnapshotNow(deps);
130
+
131
+ return Response.json({
132
+ success: true,
133
+ local: snapshotToJson(result.local),
134
+ offsite: result.offsite.map((r) => ({
135
+ destination: r.destination.path,
136
+ status: r.entry ? "ok" : r.skipped ? "skipped" : "error",
137
+ entry: r.entry ? snapshotToJson(r.entry) : null,
138
+ error: r.error ?? null,
139
+ })),
140
+ duration_ms: result.durationMs,
141
+ });
142
+ } catch (err) {
143
+ const message = err instanceof Error ? err.message : String(err);
144
+ log.error({ err }, "Manual backup snapshot failed");
145
+
146
+ if (message.includes("already in progress")) {
147
+ return Response.json(
148
+ { error: "Conflict", message: "A backup snapshot is already in progress" },
149
+ { status: 409 },
150
+ );
151
+ }
152
+
153
+ return Response.json(
154
+ { error: "Internal Server Error", message },
155
+ { status: 500 },
156
+ );
157
+ }
158
+ };
159
+ }