@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
|
@@ -1,455 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Route handler for `POST /v1/browser-extension-pair`.
|
|
3
|
-
*
|
|
4
|
-
* Mints a short-lived, scoped `host_browser_command` capability token for a
|
|
5
|
-
* chrome extension running on the same machine as the gateway.
|
|
6
|
-
*
|
|
7
|
-
* Security properties:
|
|
8
|
-
* - **Localhost-only**: enforced by both the TCP peer IP (via
|
|
9
|
-
* `server.requestIP`) and the `Host` header. Non-localhost callers
|
|
10
|
-
* receive a 403.
|
|
11
|
-
* - **Browser-origin rejection**: if an `Origin` header is present it
|
|
12
|
-
* must be either empty or explicitly on the
|
|
13
|
-
* `getAllowedExtensionOrigins()` allowlist. This defends against a
|
|
14
|
-
* malicious web page in another tab issuing a cross-origin POST from
|
|
15
|
-
* the user's browser — such a request would carry the page's origin
|
|
16
|
-
* and would be rejected here even if it somehow reached loopback.
|
|
17
|
-
* - **Strict rate limiting**: a dedicated per-peer sliding-window
|
|
18
|
-
* limiter caps pair requests at 10/minute per peer IP.
|
|
19
|
-
* - **Audit logs on denial**: every rejected request emits a structured
|
|
20
|
-
* warn log.
|
|
21
|
-
* - **Origin allowlist**: the body must include `extensionOrigin`
|
|
22
|
-
* matching an allowlist of known Vellum chrome extension ids.
|
|
23
|
-
*
|
|
24
|
-
* Request body: `{ extensionOrigin: string }` (also accepts the legacy
|
|
25
|
-
* `{ origin: string }` for backwards compatibility).
|
|
26
|
-
* Response body: `{ token, expiresAt, guardianId }` — `expiresAt` is an
|
|
27
|
-
* ISO 8601 timestamp string.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { readFileSync } from "node:fs";
|
|
31
|
-
import { join } from "node:path";
|
|
32
|
-
|
|
33
|
-
import { mintHostBrowserCapability } from "../../auth/capability-tokens.js";
|
|
34
|
-
import { assistantDbQuery } from "../../db/assistant-db-proxy.js";
|
|
35
|
-
import { getLogger } from "../../logger.js";
|
|
36
|
-
import { getGatewaySecurityDir } from "../../paths.js";
|
|
37
|
-
import { isLoopbackAddress } from "../../util/is-loopback-address.js";
|
|
38
|
-
|
|
39
|
-
const log = getLogger("browser-extension-pair");
|
|
40
|
-
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
// Constants
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
const BROWSER_EXTENSION_PAIR_RATE_LIMIT_MAX_REQUESTS = 10;
|
|
46
|
-
const BROWSER_EXTENSION_PAIR_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
47
|
-
|
|
48
|
-
const BROWSER_EXTENSION_PAIR_EXTENSION_ID_REGEX = /^[a-p]{32}$/;
|
|
49
|
-
|
|
50
|
-
/** Daemon's internal assistant scope identifier. */
|
|
51
|
-
const BROWSER_EXTENSION_PAIR_DAEMON_INTERNAL_ASSISTANT_ID = "self";
|
|
52
|
-
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// Rate limiter (dedicated, per-peer)
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
|
|
57
|
-
interface RateLimitEntry {
|
|
58
|
-
timestamps: number[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const rateLimitMap = new Map<string, RateLimitEntry>();
|
|
62
|
-
|
|
63
|
-
function checkRateLimit(peerIp: string): {
|
|
64
|
-
allowed: boolean;
|
|
65
|
-
limit: number;
|
|
66
|
-
resetAt: number;
|
|
67
|
-
} {
|
|
68
|
-
const now = Date.now();
|
|
69
|
-
const windowStart = now - BROWSER_EXTENSION_PAIR_RATE_LIMIT_WINDOW_MS;
|
|
70
|
-
|
|
71
|
-
let entry = rateLimitMap.get(peerIp);
|
|
72
|
-
if (!entry) {
|
|
73
|
-
entry = { timestamps: [] };
|
|
74
|
-
rateLimitMap.set(peerIp, entry);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Prune timestamps outside the window
|
|
78
|
-
entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
entry.timestamps.length >= BROWSER_EXTENSION_PAIR_RATE_LIMIT_MAX_REQUESTS
|
|
82
|
-
) {
|
|
83
|
-
const oldestInWindow = entry.timestamps[0] ?? now;
|
|
84
|
-
const resetAt = Math.ceil(
|
|
85
|
-
(oldestInWindow + BROWSER_EXTENSION_PAIR_RATE_LIMIT_WINDOW_MS) / 1000,
|
|
86
|
-
);
|
|
87
|
-
return {
|
|
88
|
-
allowed: false,
|
|
89
|
-
limit: BROWSER_EXTENSION_PAIR_RATE_LIMIT_MAX_REQUESTS,
|
|
90
|
-
resetAt,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
entry.timestamps.push(now);
|
|
95
|
-
return {
|
|
96
|
-
allowed: true,
|
|
97
|
-
limit: BROWSER_EXTENSION_PAIR_RATE_LIMIT_MAX_REQUESTS,
|
|
98
|
-
resetAt: Math.ceil(
|
|
99
|
-
(now + BROWSER_EXTENSION_PAIR_RATE_LIMIT_WINDOW_MS) / 1000,
|
|
100
|
-
),
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Test helper: clear the rate limiter state. */
|
|
105
|
-
export function resetBrowserExtensionPairRateLimiterForTests(): void {
|
|
106
|
-
rateLimitMap.clear();
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ---------------------------------------------------------------------------
|
|
110
|
-
// Chrome extension allowlist
|
|
111
|
-
// ---------------------------------------------------------------------------
|
|
112
|
-
|
|
113
|
-
type ChromeExtensionAllowlistConfig = {
|
|
114
|
-
version: number;
|
|
115
|
-
allowedExtensionIds: string[];
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
function parseAllowedExtensionIds(
|
|
119
|
-
value: unknown,
|
|
120
|
-
opts: { allowEmpty?: boolean } = {},
|
|
121
|
-
): string[] {
|
|
122
|
-
if (!Array.isArray(value)) {
|
|
123
|
-
throw new Error("allowedExtensionIds is not an array");
|
|
124
|
-
}
|
|
125
|
-
const ids = value
|
|
126
|
-
.filter((id): id is string => typeof id === "string")
|
|
127
|
-
.filter((id) => BROWSER_EXTENSION_PAIR_EXTENSION_ID_REGEX.test(id));
|
|
128
|
-
if (ids.length === 0 && !opts.allowEmpty) {
|
|
129
|
-
throw new Error("allowedExtensionIds has no valid extension ids");
|
|
130
|
-
}
|
|
131
|
-
return ids;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function loadAllowedExtensionIdsFromEnv(): string[] {
|
|
135
|
-
const raw =
|
|
136
|
-
process.env.VELLUM_CHROME_EXTENSION_IDS ??
|
|
137
|
-
process.env.VELLUM_CHROME_EXTENSION_ID;
|
|
138
|
-
if (!raw) return [];
|
|
139
|
-
const ids = raw
|
|
140
|
-
.split(/[,\s]+/)
|
|
141
|
-
.map((id) => id.trim())
|
|
142
|
-
.filter((id) => id.length > 0)
|
|
143
|
-
.filter((id) => BROWSER_EXTENSION_PAIR_EXTENSION_ID_REGEX.test(id));
|
|
144
|
-
return Array.from(new Set(ids));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function readIdsFromFile(
|
|
148
|
-
path: string,
|
|
149
|
-
opts: { allowEmpty?: boolean } = {},
|
|
150
|
-
): string[] {
|
|
151
|
-
const raw = readFileSync(path, "utf8");
|
|
152
|
-
const parsed = JSON.parse(raw) as Partial<ChromeExtensionAllowlistConfig>;
|
|
153
|
-
return parseAllowedExtensionIds(parsed.allowedExtensionIds, opts);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function loadAllowedExtensionOrigins(): ReadonlySet<string> {
|
|
157
|
-
const merged = new Set<string>();
|
|
158
|
-
const loadErrors: string[] = [];
|
|
159
|
-
|
|
160
|
-
// 1. Local override in $GATEWAY_SECURITY_DIR. Optional — a missing file
|
|
161
|
-
// is not an error.
|
|
162
|
-
const localOverridePath = join(
|
|
163
|
-
getGatewaySecurityDir(),
|
|
164
|
-
"chrome-extension-allowlist.local.json",
|
|
165
|
-
);
|
|
166
|
-
try {
|
|
167
|
-
for (const id of readIdsFromFile(localOverridePath, {
|
|
168
|
-
allowEmpty: true,
|
|
169
|
-
})) {
|
|
170
|
-
merged.add(`chrome-extension://${id}/`);
|
|
171
|
-
}
|
|
172
|
-
} catch (err) {
|
|
173
|
-
const isMissing =
|
|
174
|
-
err instanceof Error &&
|
|
175
|
-
"code" in err &&
|
|
176
|
-
(err as NodeJS.ErrnoException).code === "ENOENT";
|
|
177
|
-
if (!isMissing) {
|
|
178
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
179
|
-
loadErrors.push(`${localOverridePath}: ${detail}`);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// 2. Env-var fallback. Compiled Bun binaries run from a virtual FS root
|
|
184
|
-
// so file-based config paths can disappear in packaged builds;
|
|
185
|
-
// `VELLUM_CHROME_EXTENSION_IDS` is set at compile time for that case.
|
|
186
|
-
for (const id of loadAllowedExtensionIdsFromEnv()) {
|
|
187
|
-
merged.add(`chrome-extension://${id}/`);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (merged.size === 0) {
|
|
191
|
-
log.error(
|
|
192
|
-
{
|
|
193
|
-
localOverridePath,
|
|
194
|
-
loadErrors,
|
|
195
|
-
},
|
|
196
|
-
"Failed to load Chrome extension allowlist from any source; pairing will reject all origins",
|
|
197
|
-
);
|
|
198
|
-
} else if (loadErrors.length > 0) {
|
|
199
|
-
log.warn(
|
|
200
|
-
{ loadErrors },
|
|
201
|
-
"Chrome extension allowlist load errors — using env vars only",
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return merged;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
let _allowedExtensionOrigins: ReadonlySet<string> | null = null;
|
|
209
|
-
|
|
210
|
-
export function getAllowedExtensionOrigins(): ReadonlySet<string> {
|
|
211
|
-
if (!_allowedExtensionOrigins) {
|
|
212
|
-
_allowedExtensionOrigins = loadAllowedExtensionOrigins();
|
|
213
|
-
}
|
|
214
|
-
return _allowedExtensionOrigins;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Test helper: clear the cached allowlist. */
|
|
218
|
-
export function resetAllowedExtensionOriginsForTests(): void {
|
|
219
|
-
_allowedExtensionOrigins = null;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// ---------------------------------------------------------------------------
|
|
223
|
-
// Host header parsing
|
|
224
|
-
// ---------------------------------------------------------------------------
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Parse an HTTP `Host` header value and extract the hostname portion.
|
|
228
|
-
*/
|
|
229
|
-
export function parseHostHeader(raw: string): string | null {
|
|
230
|
-
if (raw.length === 0) return null;
|
|
231
|
-
if (raw.startsWith("[")) {
|
|
232
|
-
const end = raw.indexOf("]");
|
|
233
|
-
if (end < 0) return null;
|
|
234
|
-
const after = raw.substring(end + 1);
|
|
235
|
-
if (after.length > 0 && !after.startsWith(":")) return null;
|
|
236
|
-
return raw.substring(1, end);
|
|
237
|
-
}
|
|
238
|
-
const firstColon = raw.indexOf(":");
|
|
239
|
-
if (firstColon < 0) return raw;
|
|
240
|
-
const secondColon = raw.indexOf(":", firstColon + 1);
|
|
241
|
-
if (secondColon >= 0) {
|
|
242
|
-
return raw;
|
|
243
|
-
}
|
|
244
|
-
return raw.substring(0, firstColon);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function isLoopbackHostHeader(host: string | null): boolean {
|
|
248
|
-
if (!host) return true;
|
|
249
|
-
const parsed = parseHostHeader(host);
|
|
250
|
-
if (parsed === null) return false;
|
|
251
|
-
const hostname = parsed.toLowerCase();
|
|
252
|
-
if (hostname === "localhost") return true;
|
|
253
|
-
return isLoopbackAddress(hostname);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// ---------------------------------------------------------------------------
|
|
257
|
-
// Guardian resolution
|
|
258
|
-
// ---------------------------------------------------------------------------
|
|
259
|
-
|
|
260
|
-
interface GuardianLookupRow {
|
|
261
|
-
principal_id: string | null;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
async function resolveLocalGuardianId(): Promise<string> {
|
|
265
|
-
try {
|
|
266
|
-
const rows = await assistantDbQuery<GuardianLookupRow>(
|
|
267
|
-
`SELECT c.principal_id
|
|
268
|
-
FROM contacts c
|
|
269
|
-
INNER JOIN contact_channels cc ON cc.contact_id = c.id
|
|
270
|
-
WHERE c.role = 'guardian'
|
|
271
|
-
AND cc.type = 'vellum'
|
|
272
|
-
AND cc.status = 'active'
|
|
273
|
-
ORDER BY cc.verified_at DESC
|
|
274
|
-
LIMIT 1`,
|
|
275
|
-
);
|
|
276
|
-
if (rows[0]?.principal_id) {
|
|
277
|
-
return rows[0].principal_id;
|
|
278
|
-
}
|
|
279
|
-
} catch (err) {
|
|
280
|
-
log.warn(
|
|
281
|
-
{ err },
|
|
282
|
-
"Failed to look up local vellum guardian; falling back to 'local'",
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
return "local";
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// ---------------------------------------------------------------------------
|
|
289
|
-
// Audit logging
|
|
290
|
-
// ---------------------------------------------------------------------------
|
|
291
|
-
|
|
292
|
-
function auditDeny(
|
|
293
|
-
req: Request,
|
|
294
|
-
peerIp: string,
|
|
295
|
-
reason: string,
|
|
296
|
-
extra?: Record<string, unknown>,
|
|
297
|
-
): void {
|
|
298
|
-
const host = req.headers.get("host");
|
|
299
|
-
const origin = req.headers.get("origin");
|
|
300
|
-
log.warn(
|
|
301
|
-
{
|
|
302
|
-
audit: "browser-extension-pair-denied",
|
|
303
|
-
peerIp,
|
|
304
|
-
host,
|
|
305
|
-
origin,
|
|
306
|
-
reason,
|
|
307
|
-
...extra,
|
|
308
|
-
},
|
|
309
|
-
`pair_denied: ${reason}`,
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// ---------------------------------------------------------------------------
|
|
314
|
-
// Handler
|
|
315
|
-
// ---------------------------------------------------------------------------
|
|
316
|
-
|
|
317
|
-
function errorResponse(
|
|
318
|
-
code: string,
|
|
319
|
-
message: string,
|
|
320
|
-
status: number,
|
|
321
|
-
): Response {
|
|
322
|
-
return Response.json({ error: { code, message } }, { status });
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function getExternalAssistantId(): string {
|
|
326
|
-
return (
|
|
327
|
-
process.env.VELLUM_ASSISTANT_NAME?.trim() ||
|
|
328
|
-
BROWSER_EXTENSION_PAIR_DAEMON_INTERNAL_ASSISTANT_ID
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Handle POST /v1/browser-extension-pair.
|
|
334
|
-
*
|
|
335
|
-
* `clientIp` is the resolved peer IP from the gateway's `getClientIp()`
|
|
336
|
-
* helper (which reads `server.requestIP(req)`).
|
|
337
|
-
*/
|
|
338
|
-
export async function handleBrowserExtensionPair(
|
|
339
|
-
req: Request,
|
|
340
|
-
clientIp: string,
|
|
341
|
-
): Promise<Response> {
|
|
342
|
-
if (req.method !== "POST") {
|
|
343
|
-
return new Response("method not allowed", {
|
|
344
|
-
status: 405,
|
|
345
|
-
headers: { Allow: "POST" },
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Enforce localhost-only via peer IP.
|
|
350
|
-
if (!clientIp || !isLoopbackAddress(clientIp)) {
|
|
351
|
-
auditDeny(req, clientIp, "non_loopback_peer");
|
|
352
|
-
return errorResponse("FORBIDDEN", "endpoint is local-only", 403);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Secondary check: Host header.
|
|
356
|
-
const host = req.headers.get("host");
|
|
357
|
-
if (!isLoopbackHostHeader(host)) {
|
|
358
|
-
auditDeny(req, clientIp, "non_loopback_host_header");
|
|
359
|
-
return errorResponse("FORBIDDEN", "endpoint is local-only", 403);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Reject proxied requests.
|
|
363
|
-
if (req.headers.get("x-forwarded-for")) {
|
|
364
|
-
auditDeny(req, clientIp, "x_forwarded_for_present");
|
|
365
|
-
return errorResponse("FORBIDDEN", "endpoint is local-only", 403);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Strict rate limit by peer IP.
|
|
369
|
-
const rateResult = checkRateLimit(clientIp);
|
|
370
|
-
if (!rateResult.allowed) {
|
|
371
|
-
auditDeny(req, clientIp, "rate_limited", {
|
|
372
|
-
limit: rateResult.limit,
|
|
373
|
-
resetAt: rateResult.resetAt,
|
|
374
|
-
});
|
|
375
|
-
const retryAfter = Math.max(
|
|
376
|
-
1,
|
|
377
|
-
rateResult.resetAt - Math.ceil(Date.now() / 1000),
|
|
378
|
-
);
|
|
379
|
-
return Response.json(
|
|
380
|
-
{ error: { code: "RATE_LIMITED", message: "too many pair requests" } },
|
|
381
|
-
{
|
|
382
|
-
status: 429,
|
|
383
|
-
headers: {
|
|
384
|
-
"Retry-After": String(retryAfter),
|
|
385
|
-
"X-RateLimit-Limit": String(rateResult.limit),
|
|
386
|
-
"X-RateLimit-Remaining": "0",
|
|
387
|
-
"X-RateLimit-Reset": String(rateResult.resetAt),
|
|
388
|
-
},
|
|
389
|
-
},
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Browser-origin rejection.
|
|
394
|
-
const originHeader = req.headers.get("origin");
|
|
395
|
-
if (originHeader !== null && originHeader.length > 0) {
|
|
396
|
-
const withSlash = `${originHeader}/`;
|
|
397
|
-
if (
|
|
398
|
-
!getAllowedExtensionOrigins().has(originHeader) &&
|
|
399
|
-
!getAllowedExtensionOrigins().has(withSlash)
|
|
400
|
-
) {
|
|
401
|
-
auditDeny(req, clientIp, "browser_origin_not_allowlisted", {
|
|
402
|
-
originHeader,
|
|
403
|
-
});
|
|
404
|
-
return errorResponse("FORBIDDEN", "origin not allowed", 403);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
let body: unknown;
|
|
409
|
-
try {
|
|
410
|
-
body = await req.json();
|
|
411
|
-
} catch {
|
|
412
|
-
auditDeny(req, clientIp, "invalid_json_body");
|
|
413
|
-
return errorResponse("BAD_REQUEST", "invalid JSON body", 400);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (!body || typeof body !== "object") {
|
|
417
|
-
auditDeny(req, clientIp, "body_not_object");
|
|
418
|
-
return errorResponse("BAD_REQUEST", "body must be an object", 400);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const raw = body as { extensionOrigin?: unknown; origin?: unknown };
|
|
422
|
-
const extensionOrigin =
|
|
423
|
-
typeof raw.extensionOrigin === "string" && raw.extensionOrigin.length > 0
|
|
424
|
-
? raw.extensionOrigin
|
|
425
|
-
: typeof raw.origin === "string" && raw.origin.length > 0
|
|
426
|
-
? raw.origin
|
|
427
|
-
: null;
|
|
428
|
-
if (extensionOrigin === null) {
|
|
429
|
-
auditDeny(req, clientIp, "missing_extension_origin");
|
|
430
|
-
return errorResponse("BAD_REQUEST", "extensionOrigin is required", 400);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
if (!getAllowedExtensionOrigins().has(extensionOrigin)) {
|
|
434
|
-
auditDeny(req, clientIp, "extension_origin_not_allowlisted", {
|
|
435
|
-
extensionOrigin,
|
|
436
|
-
});
|
|
437
|
-
return errorResponse("FORBIDDEN", "extension origin not allowed", 403);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const guardianId = await resolveLocalGuardianId();
|
|
441
|
-
const { token, expiresAt } = mintHostBrowserCapability(guardianId);
|
|
442
|
-
const expiresAtIso = new Date(expiresAt).toISOString();
|
|
443
|
-
|
|
444
|
-
log.info(
|
|
445
|
-
{ extensionOrigin, guardianId, expiresAt: expiresAtIso },
|
|
446
|
-
"Browser extension paired successfully",
|
|
447
|
-
);
|
|
448
|
-
|
|
449
|
-
return Response.json({
|
|
450
|
-
token,
|
|
451
|
-
expiresAt: expiresAtIso,
|
|
452
|
-
guardianId,
|
|
453
|
-
assistantId: getExternalAssistantId(),
|
|
454
|
-
});
|
|
455
|
-
}
|