@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.
- package/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- 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
|
+
}
|