@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
@@ -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 "../routes/browser-relay-websocket.js";
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
- * pairing endpoints, and unauthenticated endpoints that forward to the
52
- * runtime (OAuth callback is publicly reachable and forwards every
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 and browser-relay paths which use their
56
- * own authentication mechanisms (Twilio signature validation, etc.).
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
- (url.pathname.startsWith("/v1/") && url.pathname !== "/v1/browser-relay")
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<string, unknown>;
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
- * even when the broad runtime proxy is disabled.
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 remain available even when the broad runtime proxy is
5
- * disabled, so skills and clients can use gateway URLs exclusively.
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 remain available even when the broad runtime proxy is
5
- * disabled, so skills and clients can use gateway URLs exclusively.
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 (!platform || !deviceId || !refreshToken) {
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, platform, deviceId });
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 === "device_binding_mismatch"
379
+ : result.error === "revoked"
395
380
  ? 403
396
- : result.error === "revoked"
397
- ? 403
398
- : 401;
381
+ : 401;
399
382
 
400
383
  log.warn(
401
- { error: result.error, platform },
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 remain available even when the broad runtime proxy is
5
- * disabled, so skills and clients can use gateway URLs exclusively.
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";
@@ -54,7 +54,6 @@ const baseConfig: GatewayConfig = {
54
54
  routingEntries: [],
55
55
  runtimeInitialBackoffMs: 500,
56
56
  runtimeMaxRetries: 2,
57
- runtimeProxyEnabled: false,
58
57
  runtimeProxyRequireAuth: true,
59
58
  runtimeTimeoutMs: 30000,
60
59
  shutdownDrainMs: 5000,