@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
|
@@ -5,18 +5,59 @@ import { resolveScopeProfile } from "../../auth/scopes.js";
|
|
|
5
5
|
import type { Scope } from "../../auth/types.js";
|
|
6
6
|
import type { AuthRateLimiter } from "../../auth-rate-limiter.js";
|
|
7
7
|
import { getLogger } from "../../logger.js";
|
|
8
|
-
import { isLoopbackPeer } from "
|
|
8
|
+
import { isLoopbackPeer } from "../../util/is-loopback-address.js";
|
|
9
9
|
|
|
10
10
|
const log = getLogger("auth");
|
|
11
11
|
|
|
12
12
|
type GetClientIp = () => string;
|
|
13
13
|
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// DISABLE_HTTP_AUTH — platform-managed deployments bypass JWT validation
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
let _httpAuthDisabled: boolean | undefined;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True when HTTP auth is disabled via DISABLE_HTTP_AUTH=true.
|
|
22
|
+
* Cached after first call so the env var is only read once.
|
|
23
|
+
*/
|
|
24
|
+
export function isHttpAuthDisabled(): boolean {
|
|
25
|
+
if (_httpAuthDisabled === undefined) {
|
|
26
|
+
_httpAuthDisabled =
|
|
27
|
+
process.env.DISABLE_HTTP_AUTH?.trim().toLowerCase() === "true";
|
|
28
|
+
}
|
|
29
|
+
return _httpAuthDisabled;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Log the auth bypass state at gateway startup.
|
|
34
|
+
* Call once from the main entrypoint after the logger is ready.
|
|
35
|
+
*/
|
|
36
|
+
export function logAuthBypassState(): void {
|
|
37
|
+
if (!isHttpAuthDisabled()) return;
|
|
38
|
+
const isPlatform =
|
|
39
|
+
process.env.IS_PLATFORM?.trim().toLowerCase() === "true" ||
|
|
40
|
+
process.env.IS_PLATFORM?.trim() === "1";
|
|
41
|
+
if (isPlatform) {
|
|
42
|
+
log.info(
|
|
43
|
+
"DISABLE_HTTP_AUTH is set — HTTP auth disabled (expected: platform handles auth)",
|
|
44
|
+
);
|
|
45
|
+
} else {
|
|
46
|
+
log.warn(
|
|
47
|
+
"DISABLE_HTTP_AUTH is set — HTTP API authentication is DISABLED. All endpoints are accessible without a bearer token.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
14
52
|
/**
|
|
15
53
|
* Build edge-auth guard functions that share a rate limiter and IP resolver.
|
|
16
54
|
*
|
|
17
55
|
* Both guards validate a JWT bearer token (aud=vellum-gateway) and record
|
|
18
56
|
* failures against the rate limiter. `requireEdgeAuthWithScope` additionally
|
|
19
57
|
* checks for a specific scope in the token's profile.
|
|
58
|
+
*
|
|
59
|
+
* When DISABLE_HTTP_AUTH is set (platform-managed deployments), all JWT
|
|
60
|
+
* checks are bypassed — the platform handles authentication upstream.
|
|
20
61
|
*/
|
|
21
62
|
export function createAuthMiddleware(
|
|
22
63
|
authRateLimiter: AuthRateLimiter,
|
|
@@ -31,6 +72,7 @@ export function createAuthMiddleware(
|
|
|
31
72
|
req: Request,
|
|
32
73
|
server?: Server<unknown>,
|
|
33
74
|
): Response | null {
|
|
75
|
+
if (isHttpAuthDisabled()) return null;
|
|
34
76
|
if (server && isLoopbackPeer(server, req)) {
|
|
35
77
|
return null;
|
|
36
78
|
}
|
|
@@ -65,6 +107,7 @@ export function createAuthMiddleware(
|
|
|
65
107
|
scope: Scope,
|
|
66
108
|
server?: Server<unknown>,
|
|
67
109
|
): Response | null {
|
|
110
|
+
if (isHttpAuthDisabled()) return null;
|
|
68
111
|
if (server && isLoopbackPeer(server, req)) {
|
|
69
112
|
return null;
|
|
70
113
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { KNOWN_EXTENSION_ORIGINS } from "../../chrome-extension-origins.js";
|
|
1
2
|
import { getLogger } from "../../logger.js";
|
|
2
3
|
|
|
3
4
|
const log = getLogger("cors");
|
|
@@ -23,6 +24,89 @@ const ALLOWED_METHODS = "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS";
|
|
|
23
24
|
const ALLOWED_HEADERS =
|
|
24
25
|
"Authorization, Content-Type, X-Session-Token, Vellum-Organization-Id, X-Trace-Id";
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Check whether the request `Origin` header matches a known Vellum Chrome
|
|
29
|
+
* extension. Returns the validated origin string, or null if CORS headers
|
|
30
|
+
* should not be added.
|
|
31
|
+
*
|
|
32
|
+
* Chrome enforces the `chrome-extension://<id>` origin at the network layer —
|
|
33
|
+
* an extension cannot impersonate another extension's origin. Chrome's Private
|
|
34
|
+
* Network Access (PNA) policy additionally requires a successful CORS preflight
|
|
35
|
+
* before any extension can reach a localhost endpoint. Together these mean that
|
|
36
|
+
* narrowing CORS to KNOWN_EXTENSION_ORIGINS blocks all other Chrome extensions
|
|
37
|
+
* from accessing the gateway — they fail the PNA preflight and Chrome never
|
|
38
|
+
* sends the actual request.
|
|
39
|
+
*
|
|
40
|
+
* Note: PNA enforcement only applies to browser contexts. A local process can
|
|
41
|
+
* set an arbitrary Origin header without going through Chrome. This is the
|
|
42
|
+
* residual risk; the loopback IP check in individual route handlers (e.g.
|
|
43
|
+
* pair.ts) is the defence-in-depth boundary for that case.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveExtensionOrigin(req: Request): string | null {
|
|
46
|
+
const origin = req.headers.get("origin");
|
|
47
|
+
if (!origin) return null;
|
|
48
|
+
if (!KNOWN_EXTENSION_ORIGINS.has(origin)) return null;
|
|
49
|
+
return origin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build CORS response headers for a known Vellum Chrome extension origin,
|
|
54
|
+
* including the Private Network Access header required for localhost fetches.
|
|
55
|
+
*/
|
|
56
|
+
export function extensionCorsHeaders(origin: string): Record<string, string> {
|
|
57
|
+
return {
|
|
58
|
+
"Access-Control-Allow-Origin": origin,
|
|
59
|
+
// GET for SSE (/v1/events), POST for pair + host-browser callbacks
|
|
60
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
61
|
+
// All headers the extension service worker sends across these routes:
|
|
62
|
+
// /v1/pair → content-type, x-vellum-interface-id
|
|
63
|
+
// /v1/events (SSE) → accept, x-vellum-client-id, x-vellum-interface-id
|
|
64
|
+
// /v1/host-browser-* → content-type, authorization
|
|
65
|
+
"Access-Control-Allow-Headers":
|
|
66
|
+
"Accept, Authorization, Content-Type, X-Vellum-Client-Id, X-Vellum-Interface-Id",
|
|
67
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
68
|
+
"Access-Control-Max-Age": "86400",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Handle a Private Network Access preflight from a Chrome extension.
|
|
74
|
+
* Returns 204 with all required CORS + PNA headers.
|
|
75
|
+
*/
|
|
76
|
+
export function handleExtensionPreflight(origin: string): Response {
|
|
77
|
+
log.debug({ origin }, "Chrome extension PNA preflight response");
|
|
78
|
+
return new Response(null, {
|
|
79
|
+
status: 204,
|
|
80
|
+
headers: extensionCorsHeaders(origin),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Append extension CORS headers to an existing response.
|
|
86
|
+
*/
|
|
87
|
+
export function withExtensionCorsHeaders(
|
|
88
|
+
response: Response,
|
|
89
|
+
origin: string,
|
|
90
|
+
): Response {
|
|
91
|
+
const headers = extensionCorsHeaders(origin);
|
|
92
|
+
try {
|
|
93
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
94
|
+
response.headers.set(key, value);
|
|
95
|
+
}
|
|
96
|
+
return response;
|
|
97
|
+
} catch {
|
|
98
|
+
const merged = new Headers(response.headers);
|
|
99
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
100
|
+
merged.set(key, value);
|
|
101
|
+
}
|
|
102
|
+
return new Response(response.body, {
|
|
103
|
+
status: response.status,
|
|
104
|
+
statusText: response.statusText,
|
|
105
|
+
headers: merged,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
26
110
|
/**
|
|
27
111
|
* Check whether the request `Origin` header matches a known webview origin.
|
|
28
112
|
* Returns the validated origin string, or null if CORS headers should not be
|
|
@@ -47,19 +47,17 @@ export function checkAuthRateLimit(
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Routes subject to the auth-failure rate limiter: authenticated endpoints
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* valid-looking request).
|
|
50
|
+
* Routes subject to the auth-failure rate limiter: authenticated endpoints
|
|
51
|
+
* and unauthenticated endpoints that forward to the runtime (OAuth callback
|
|
52
|
+
* is publicly reachable and forwards every valid-looking request).
|
|
54
53
|
*
|
|
55
|
-
* Excluded: Twilio webhook/relay
|
|
56
|
-
*
|
|
54
|
+
* Excluded: Twilio webhook/relay paths which use their own authentication
|
|
55
|
+
* mechanisms (Twilio signature validation, etc.).
|
|
57
56
|
*/
|
|
58
57
|
function isRateLimitedRoute(url: URL): boolean {
|
|
59
58
|
return (
|
|
60
59
|
url.pathname === "/integrations/status" ||
|
|
61
|
-
url.pathname.startsWith("/pairing/") ||
|
|
62
60
|
url.pathname === "/webhooks/oauth/callback" ||
|
|
63
|
-
|
|
61
|
+
url.pathname.startsWith("/v1/")
|
|
64
62
|
);
|
|
65
63
|
}
|
|
@@ -48,12 +48,14 @@ export function createGlobalThresholdGetHandler() {
|
|
|
48
48
|
return Response.json({
|
|
49
49
|
interactive: "medium",
|
|
50
50
|
autonomous: "low",
|
|
51
|
+
headless: "none",
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
return Response.json({
|
|
55
56
|
interactive: row.interactive,
|
|
56
57
|
autonomous: row.autonomous,
|
|
58
|
+
headless: row.headless,
|
|
57
59
|
});
|
|
58
60
|
} catch (err) {
|
|
59
61
|
log.error({ err }, "Failed to read global thresholds");
|
|
@@ -85,7 +87,10 @@ export function createGlobalThresholdPutHandler() {
|
|
|
85
87
|
);
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
const { interactive, autonomous } = body as Record<
|
|
90
|
+
const { interactive, autonomous, headless } = body as Record<
|
|
91
|
+
string,
|
|
92
|
+
unknown
|
|
93
|
+
>;
|
|
89
94
|
|
|
90
95
|
if (interactive !== undefined && !isValidThreshold(interactive)) {
|
|
91
96
|
return Response.json(
|
|
@@ -103,6 +108,14 @@ export function createGlobalThresholdPutHandler() {
|
|
|
103
108
|
{ status: 400 },
|
|
104
109
|
);
|
|
105
110
|
}
|
|
111
|
+
if (headless !== undefined && !isValidThreshold(headless)) {
|
|
112
|
+
return Response.json(
|
|
113
|
+
{
|
|
114
|
+
error: `"headless" must be one of: ${VALID_THRESHOLDS.join(", ")}`,
|
|
115
|
+
},
|
|
116
|
+
{ status: 400 },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
106
119
|
|
|
107
120
|
try {
|
|
108
121
|
const db = getGatewayDb();
|
|
@@ -112,12 +125,14 @@ export function createGlobalThresholdPutHandler() {
|
|
|
112
125
|
id: 1,
|
|
113
126
|
...(interactive ? { interactive } : {}),
|
|
114
127
|
...(autonomous ? { autonomous } : {}),
|
|
128
|
+
...(headless ? { headless } : {}),
|
|
115
129
|
})
|
|
116
130
|
.onConflictDoUpdate({
|
|
117
131
|
target: autoApproveThresholds.id,
|
|
118
132
|
set: {
|
|
119
133
|
...(interactive ? { interactive } : {}),
|
|
120
134
|
...(autonomous ? { autonomous } : {}),
|
|
135
|
+
...(headless ? { headless } : {}),
|
|
121
136
|
updatedAt: sql`datetime('now')`,
|
|
122
137
|
},
|
|
123
138
|
})
|
|
@@ -127,6 +142,7 @@ export function createGlobalThresholdPutHandler() {
|
|
|
127
142
|
return Response.json({
|
|
128
143
|
interactive: row.interactive,
|
|
129
144
|
autonomous: row.autonomous,
|
|
145
|
+
headless: row.headless,
|
|
130
146
|
});
|
|
131
147
|
} catch (err) {
|
|
132
148
|
log.error({ err }, "Failed to upsert global thresholds");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Gateway proxy endpoints for the brain graph knowledge-graph visualizer.
|
|
3
3
|
*
|
|
4
4
|
* Exposes GET /v1/brain-graph and GET /v1/brain-graph-ui through the gateway
|
|
5
|
-
*
|
|
5
|
+
* for dedicated auth handling.
|
|
6
6
|
*
|
|
7
7
|
* The brain-graph-ui endpoint proxies plain HTML from the daemon and injects
|
|
8
8
|
* an auth token meta tag before returning the page to the client. The daemon
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gateway proxy endpoints for channel readiness control-plane routes.
|
|
3
3
|
*
|
|
4
|
-
* These routes
|
|
5
|
-
*
|
|
4
|
+
* These routes are registered as explicit gateway routes for dedicated
|
|
5
|
+
* auth handling rather than falling through to the catch-all proxy.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { proxyForwardToResponse } from "@vellumai/assistant-client";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gateway proxy endpoints for channel verification session control-plane routes.
|
|
3
3
|
*
|
|
4
|
-
* These routes
|
|
5
|
-
*
|
|
4
|
+
* These routes are registered as explicit gateway routes for dedicated
|
|
5
|
+
* auth handling rather than falling through to the catch-all proxy.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
@@ -159,11 +159,20 @@ export function createChannelVerificationSessionProxyHandler(
|
|
|
159
159
|
const expectedSecrets = parseBootstrapSecrets();
|
|
160
160
|
const provided = req.headers.get("x-bootstrap-secret");
|
|
161
161
|
|
|
162
|
+
// Platform-managed mode: the gateway runs as an authenticated sidecar;
|
|
163
|
+
// the platform handles auth upstream (via vembda's /gateway-query proxy)
|
|
164
|
+
// before the request ever reaches the gateway. Skip the bare-metal
|
|
165
|
+
// loopback guard entirely — it would always reject because the request
|
|
166
|
+
// arrives from a pod IP, not from 127.0.0.1.
|
|
167
|
+
const isManaged =
|
|
168
|
+
process.env.IS_PLATFORM?.trim().toLowerCase() === "true" ||
|
|
169
|
+
process.env.IS_PLATFORM?.trim() === "1";
|
|
170
|
+
|
|
162
171
|
// Bare-metal mode: restrict to loopback callers only. Without a
|
|
163
172
|
// bootstrap secret the lockfile is the sole guard, so a remote
|
|
164
173
|
// client that can reach the gateway (e.g. via ngrok) must not be
|
|
165
174
|
// able to race the legitimate local user.
|
|
166
|
-
if (expectedSecrets.length === 0) {
|
|
175
|
+
if (!isManaged && expectedSecrets.length === 0) {
|
|
167
176
|
if (clientIp && !isLoopbackAddress(clientIp)) {
|
|
168
177
|
log.warn(
|
|
169
178
|
{ clientIp },
|
|
@@ -274,8 +283,7 @@ export function createChannelVerificationSessionProxyHandler(
|
|
|
274
283
|
guardianInitInFlight = false;
|
|
275
284
|
return Response.json(
|
|
276
285
|
{
|
|
277
|
-
error:
|
|
278
|
-
"Invalid platform. Bootstrap is macOS/CLI/web-only; iOS uses QR pairing.",
|
|
286
|
+
error: "Invalid platform. Bootstrap is macOS/CLI/web-only.",
|
|
279
287
|
},
|
|
280
288
|
{ status: 400 },
|
|
281
289
|
);
|
|
@@ -347,58 +355,33 @@ export function createChannelVerificationSessionProxyHandler(
|
|
|
347
355
|
async handleGuardianRefresh(req: Request): Promise<Response> {
|
|
348
356
|
try {
|
|
349
357
|
const body = (await req.json()) as Record<string, unknown>;
|
|
350
|
-
const platform =
|
|
351
|
-
typeof body.platform === "string" ? body.platform.trim() : "";
|
|
352
|
-
const deviceId =
|
|
353
|
-
typeof body.deviceId === "string" ? body.deviceId.trim() : "";
|
|
354
358
|
const refreshToken =
|
|
355
359
|
typeof body.refreshToken === "string" ? body.refreshToken : "";
|
|
356
360
|
|
|
357
|
-
if (!
|
|
358
|
-
return Response.json(
|
|
359
|
-
{
|
|
360
|
-
error: {
|
|
361
|
-
code: "BAD_REQUEST",
|
|
362
|
-
message:
|
|
363
|
-
"Missing required fields: platform, deviceId, refreshToken",
|
|
364
|
-
},
|
|
365
|
-
},
|
|
366
|
-
{ status: 400 },
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
platform !== "ios" &&
|
|
372
|
-
platform !== "macos" &&
|
|
373
|
-
platform !== "cli" &&
|
|
374
|
-
platform !== "web"
|
|
375
|
-
) {
|
|
361
|
+
if (!refreshToken) {
|
|
376
362
|
return Response.json(
|
|
377
363
|
{
|
|
378
364
|
error: {
|
|
379
365
|
code: "BAD_REQUEST",
|
|
380
|
-
message:
|
|
381
|
-
'Invalid platform. Must be "ios", "macos", "cli", or "web".',
|
|
366
|
+
message: "Missing required field: refreshToken",
|
|
382
367
|
},
|
|
383
368
|
},
|
|
384
369
|
{ status: 400 },
|
|
385
370
|
);
|
|
386
371
|
}
|
|
387
372
|
|
|
388
|
-
const result = rotateCredentials({ refreshToken
|
|
373
|
+
const result = rotateCredentials({ refreshToken });
|
|
389
374
|
|
|
390
375
|
if (!result.ok) {
|
|
391
376
|
const statusCode =
|
|
392
377
|
result.error === "refresh_reuse_detected"
|
|
393
378
|
? 403
|
|
394
|
-
: result.error === "
|
|
379
|
+
: result.error === "revoked"
|
|
395
380
|
? 403
|
|
396
|
-
:
|
|
397
|
-
? 403
|
|
398
|
-
: 401;
|
|
381
|
+
: 401;
|
|
399
382
|
|
|
400
383
|
log.warn(
|
|
401
|
-
{ error: result.error
|
|
384
|
+
{ error: result.error },
|
|
402
385
|
"Refresh token rotation failed",
|
|
403
386
|
);
|
|
404
387
|
return Response.json({ error: result.error }, { status: statusCode });
|
|
@@ -406,7 +389,6 @@ export function createChannelVerificationSessionProxyHandler(
|
|
|
406
389
|
|
|
407
390
|
log.info(
|
|
408
391
|
{
|
|
409
|
-
platform,
|
|
410
392
|
guardianPrincipalId: result.result.guardianPrincipalId,
|
|
411
393
|
},
|
|
412
394
|
"Refresh token rotation succeeded",
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway HTTP handler for the contact prompt submission endpoint.
|
|
3
|
+
*
|
|
4
|
+
* POST /v1/contacts/prompt/submit
|
|
5
|
+
*
|
|
6
|
+
* Called by the client after the user fills in a contact address in response
|
|
7
|
+
* to a `contact_request` broadcast from the daemon. This route:
|
|
8
|
+
* 1. Validates the submitted contact info.
|
|
9
|
+
* 2. Upserts the contact + channel via the assistant DB proxy (gateway owns writes).
|
|
10
|
+
* 3. Calls daemon IPC `resolve_contact_prompt` to unblock the waiting CLI.
|
|
11
|
+
* 4. Returns { accepted: true } to the client.
|
|
12
|
+
*
|
|
13
|
+
* Auth: edge (same as all ingress contact routes).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
assistantDbQuery,
|
|
18
|
+
assistantDbRun,
|
|
19
|
+
} from "../../db/assistant-db-proxy.js";
|
|
20
|
+
import { ipcCallAssistant } from "../../ipc/assistant-client.js";
|
|
21
|
+
import { getLogger } from "../../logger.js";
|
|
22
|
+
|
|
23
|
+
const log = getLogger("contact-prompt");
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
interface ContactPromptSubmitBody {
|
|
30
|
+
requestId: string;
|
|
31
|
+
address: string;
|
|
32
|
+
channelType: string;
|
|
33
|
+
role?: "guardian" | "trusted-contact" | "unknown";
|
|
34
|
+
displayName?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Handler
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export async function handleContactPromptSubmit(req: Request): Promise<Response> {
|
|
42
|
+
let body: ContactPromptSubmitBody;
|
|
43
|
+
try {
|
|
44
|
+
body = (await req.json()) as ContactPromptSubmitBody;
|
|
45
|
+
} catch {
|
|
46
|
+
return Response.json({ accepted: false, error: "Invalid JSON body" }, { status: 400 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { requestId, address, channelType, role, displayName } = body;
|
|
50
|
+
|
|
51
|
+
if (!requestId || typeof requestId !== "string") {
|
|
52
|
+
return Response.json({ accepted: false, error: "requestId is required" }, { status: 400 });
|
|
53
|
+
}
|
|
54
|
+
if (!address || typeof address !== "string") {
|
|
55
|
+
return Response.json({ accepted: false, error: "address is required" }, { status: 400 });
|
|
56
|
+
}
|
|
57
|
+
if (!channelType || typeof channelType !== "string") {
|
|
58
|
+
return Response.json({ accepted: false, error: "channelType is required" }, { status: 400 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const normalizedAddress = address.toLowerCase().trim();
|
|
62
|
+
const effectiveDisplayName = displayName ?? normalizedAddress;
|
|
63
|
+
// Map prompt roles to valid ContactRole values ("guardian" | "contact").
|
|
64
|
+
const effectiveRole: string = role === "guardian" ? "guardian" : "contact";
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
|
|
67
|
+
let contactId: string;
|
|
68
|
+
let channelId: string;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// Check if a channel with this (type, address) already exists.
|
|
72
|
+
const existing = await assistantDbQuery<{
|
|
73
|
+
channelId: string;
|
|
74
|
+
contactId: string;
|
|
75
|
+
}>(
|
|
76
|
+
`SELECT cc.id AS channelId, cc.contact_id AS contactId
|
|
77
|
+
FROM contact_channels cc
|
|
78
|
+
WHERE cc.type = ? AND cc.address = ?
|
|
79
|
+
LIMIT 1`,
|
|
80
|
+
[channelType, normalizedAddress],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (existing.length > 0) {
|
|
84
|
+
contactId = existing[0].contactId;
|
|
85
|
+
channelId = existing[0].channelId;
|
|
86
|
+
log.info(
|
|
87
|
+
{ channelType, address: normalizedAddress, contactId, channelId },
|
|
88
|
+
"contact-prompt-submit: channel already exists",
|
|
89
|
+
);
|
|
90
|
+
} else {
|
|
91
|
+
contactId = crypto.randomUUID();
|
|
92
|
+
channelId = crypto.randomUUID();
|
|
93
|
+
|
|
94
|
+
await assistantDbRun(
|
|
95
|
+
`INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
|
|
96
|
+
VALUES (?, ?, ?, 'human', ?, ?)`,
|
|
97
|
+
[contactId, effectiveDisplayName, effectiveRole, now, now],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await assistantDbRun(
|
|
102
|
+
`INSERT INTO contact_channels (id, contact_id, type, address, is_primary, status, policy, interaction_count, created_at, updated_at)
|
|
103
|
+
VALUES (?, ?, ?, ?, 1, 'unverified', 'allow', 0, ?, ?)`,
|
|
104
|
+
[channelId, contactId, channelType, normalizedAddress, now, now],
|
|
105
|
+
);
|
|
106
|
+
} catch (channelErr) {
|
|
107
|
+
// Compensating delete — remove the orphaned contact row.
|
|
108
|
+
log.error(
|
|
109
|
+
{ channelErr, contactId, channelType },
|
|
110
|
+
"contact-prompt-submit: channel INSERT failed, rolling back contact",
|
|
111
|
+
);
|
|
112
|
+
await assistantDbRun("DELETE FROM contacts WHERE id = ?", [contactId]);
|
|
113
|
+
|
|
114
|
+
// Notify daemon of failure so the CLI doesn't hang.
|
|
115
|
+
await ipcCallAssistant("resolve_contact_prompt", {
|
|
116
|
+
body: { requestId, error: "Failed to create contact channel" },
|
|
117
|
+
});
|
|
118
|
+
return Response.json(
|
|
119
|
+
{ accepted: false, error: "Failed to create contact channel" },
|
|
120
|
+
{ status: 500 },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
log.info(
|
|
125
|
+
{ channelType, address: normalizedAddress, contactId, channelId, role: effectiveRole },
|
|
126
|
+
"contact-prompt-submit: created new contact + channel",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
log.error({ err, requestId }, "contact-prompt-submit: DB error");
|
|
131
|
+
await ipcCallAssistant("resolve_contact_prompt", {
|
|
132
|
+
body: { requestId, error: "Database error" },
|
|
133
|
+
});
|
|
134
|
+
return Response.json({ accepted: false, error: "Database error" }, { status: 500 });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Notify daemon to unblock the waiting contacts/prompt IPC call.
|
|
138
|
+
const ipcResult = await ipcCallAssistant("resolve_contact_prompt", {
|
|
139
|
+
body: { requestId, contactId, channelId, channelType, address: normalizedAddress },
|
|
140
|
+
});
|
|
141
|
+
if (!ipcResult || (ipcResult as { resolved?: boolean }).resolved === false) {
|
|
142
|
+
log.warn(
|
|
143
|
+
{ requestId, contactId },
|
|
144
|
+
"contact-prompt-submit: resolve_contact_prompt IPC did not find a pending prompt — CLI may time out",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return Response.json({ accepted: true });
|
|
149
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gateway proxy endpoints for ingress contacts/invites control-plane routes.
|
|
3
3
|
*
|
|
4
|
-
* These routes
|
|
5
|
-
*
|
|
4
|
+
* These routes are registered as explicit gateway routes for dedicated
|
|
5
|
+
* auth handling rather than falling through to the catch-all proxy.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { proxyForward } from "@vellumai/assistant-client";
|