@vellumai/vellum-gateway 0.5.5 → 0.5.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -80,6 +80,100 @@ afterEach(() => {
80
80
  writtenLockFiles = [];
81
81
  });
82
82
 
83
+ describe("guardian/init bootstrap secret", () => {
84
+ test("rejects requests without secret when GUARDIAN_BOOTSTRAP_SECRET is set", async () => {
85
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = "test-secret-abc123";
86
+ try {
87
+ const handler = createChannelVerificationSessionProxyHandler(makeConfig());
88
+ const res = await handler.handleGuardianInit(
89
+ new Request("http://localhost:7830/v1/guardian/init", {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ platform: "cli", deviceId: "test-device" }),
93
+ }),
94
+ );
95
+
96
+ expect(res.status).toBe(403);
97
+ const body = await res.json();
98
+ expect(body.error).toBe("Invalid bootstrap secret");
99
+ } finally {
100
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
101
+ }
102
+ });
103
+
104
+ test("rejects requests with wrong secret", async () => {
105
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = "test-secret-abc123";
106
+ try {
107
+ const handler = createChannelVerificationSessionProxyHandler(makeConfig());
108
+ const res = await handler.handleGuardianInit(
109
+ new Request("http://localhost:7830/v1/guardian/init", {
110
+ method: "POST",
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ "x-bootstrap-secret": "wrong-secret",
114
+ },
115
+ body: JSON.stringify({ platform: "cli", deviceId: "test-device" }),
116
+ }),
117
+ );
118
+
119
+ expect(res.status).toBe(403);
120
+ const body = await res.json();
121
+ expect(body.error).toBe("Invalid bootstrap secret");
122
+ } finally {
123
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
124
+ }
125
+ });
126
+
127
+ test("accepts requests with correct secret", async () => {
128
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = "test-secret-abc123";
129
+ fetchMock = mock(async () => {
130
+ return new Response(
131
+ JSON.stringify({ accessToken: "test-jwt", refreshToken: "test-rt" }),
132
+ { status: 200, headers: { "content-type": "application/json" } },
133
+ );
134
+ });
135
+
136
+ try {
137
+ const handler = createChannelVerificationSessionProxyHandler(makeConfig());
138
+ const res = await handler.handleGuardianInit(
139
+ new Request("http://localhost:7830/v1/guardian/init", {
140
+ method: "POST",
141
+ headers: {
142
+ "Content-Type": "application/json",
143
+ "x-bootstrap-secret": "test-secret-abc123",
144
+ },
145
+ body: JSON.stringify({ platform: "cli", deviceId: "test-device" }),
146
+ }),
147
+ );
148
+
149
+ expect(res.status).toBe(200);
150
+ } finally {
151
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
152
+ }
153
+ });
154
+
155
+ test("skips secret check when GUARDIAN_BOOTSTRAP_SECRET is not set", async () => {
156
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
157
+ fetchMock = mock(async () => {
158
+ return new Response(
159
+ JSON.stringify({ accessToken: "test-jwt", refreshToken: "test-rt" }),
160
+ { status: 200, headers: { "content-type": "application/json" } },
161
+ );
162
+ });
163
+
164
+ const handler = createChannelVerificationSessionProxyHandler(makeConfig());
165
+ const res = await handler.handleGuardianInit(
166
+ new Request("http://localhost:7830/v1/guardian/init", {
167
+ method: "POST",
168
+ headers: { "Content-Type": "application/json" },
169
+ body: JSON.stringify({ platform: "cli", deviceId: "test-device" }),
170
+ }),
171
+ );
172
+
173
+ expect(res.status).toBe(200);
174
+ });
175
+ });
176
+
83
177
  describe("guardian/init one-time-use lockfile", () => {
84
178
  test("first call succeeds and creates lock file", async () => {
85
179
  fetchMock = mock(async () => {
@@ -130,6 +130,9 @@ const EXCLUDED_FROM_SCHEMA = new Set([
130
130
 
131
131
  // Runtime proxy catch-all — documented as /{path} in the schema
132
132
  "catch-all",
133
+
134
+ // Internal Docker bootstrap endpoint — not a public API
135
+ "/internal/signing-key-bootstrap",
133
136
  ]);
134
137
 
135
138
  // ── Schema paths that don't map to a discrete route definition ──
@@ -0,0 +1,143 @@
1
+ import { describe, test, expect, mock, afterEach } from "bun:test";
2
+ import * as actualFs from "node:fs";
3
+ import { randomBytes } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import type { GatewayConfig } from "../config.js";
6
+
7
+ // Generate a realistic 32-byte signing key for tests.
8
+ const TEST_SIGNING_KEY = randomBytes(32);
9
+
10
+ // Compute the expected key path so we can intercept reads.
11
+ const TEST_SECURITY_DIR = "/tmp/test-gateway-security";
12
+ const SIGNING_KEY_PATH = join(TEST_SECURITY_DIR, "actor-token-signing-key");
13
+
14
+ // Track lockfile state.
15
+ let lockFileExists = false;
16
+ let writtenLockFiles: string[] = [];
17
+
18
+ mock.module("node:fs", () => ({
19
+ ...actualFs,
20
+ existsSync: (p: string) => {
21
+ if (typeof p === "string" && p.endsWith("signing-key-bootstrap.lock")) {
22
+ return lockFileExists;
23
+ }
24
+ return actualFs.existsSync(p);
25
+ },
26
+ readFileSync: (p: string, ...args: unknown[]) => {
27
+ if (p === SIGNING_KEY_PATH) {
28
+ return Buffer.from(TEST_SIGNING_KEY);
29
+ }
30
+ return (actualFs.readFileSync as (...a: unknown[]) => unknown)(p, ...args);
31
+ },
32
+ writeFileSync: (
33
+ p: string,
34
+ data: string | NodeJS.ArrayBufferView,
35
+ options?: actualFs.WriteFileOptions,
36
+ ) => {
37
+ if (typeof p === "string" && p.endsWith("signing-key-bootstrap.lock")) {
38
+ writtenLockFiles.push(p);
39
+ lockFileExists = true;
40
+ return;
41
+ }
42
+ return actualFs.writeFileSync(p, data, options);
43
+ },
44
+ }));
45
+
46
+ // Set GATEWAY_SECURITY_DIR so getSigningKeyPath() resolves to our test path.
47
+ process.env.GATEWAY_SECURITY_DIR = TEST_SECURITY_DIR;
48
+
49
+ const { createSigningKeyBootstrapHandler } = await import(
50
+ "../http/routes/signing-key-bootstrap.js"
51
+ );
52
+
53
+ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
54
+ return {
55
+ assistantRuntimeBaseUrl: "http://localhost:7821",
56
+ routingEntries: [],
57
+ defaultAssistantId: undefined,
58
+ unmappedPolicy: "reject",
59
+ port: 7830,
60
+ runtimeProxyEnabled: false,
61
+ runtimeProxyRequireAuth: true,
62
+ shutdownDrainMs: 5000,
63
+ runtimeTimeoutMs: 30000,
64
+ runtimeMaxRetries: 2,
65
+ runtimeInitialBackoffMs: 500,
66
+ maxWebhookPayloadBytes: 1048576,
67
+ logFile: { dir: undefined, retentionDays: 30 },
68
+ maxAttachmentBytes: {
69
+ telegram: 50 * 1024 * 1024,
70
+ slack: 100 * 1024 * 1024,
71
+ whatsapp: 16 * 1024 * 1024,
72
+ default: 50 * 1024 * 1024,
73
+ },
74
+ maxAttachmentConcurrency: 3,
75
+ gatewayInternalBaseUrl: "http://127.0.0.1:7830",
76
+ trustProxy: false,
77
+ ...overrides,
78
+ };
79
+ }
80
+
81
+ afterEach(() => {
82
+ lockFileExists = false;
83
+ writtenLockFiles = [];
84
+ });
85
+
86
+ describe("signing-key-bootstrap endpoint", () => {
87
+ test("first call returns 200 with hex-encoded 32-byte key", async () => {
88
+ const handler = createSigningKeyBootstrapHandler(makeConfig());
89
+ const res = await handler.handleGetSigningKey(
90
+ new Request("http://localhost:7830/internal/signing-key-bootstrap"),
91
+ );
92
+
93
+ expect(res.status).toBe(200);
94
+ const body = (await res.json()) as { key: string };
95
+ expect(typeof body.key).toBe("string");
96
+
97
+ // Verify the hex decodes to exactly 32 bytes.
98
+ const decoded = Buffer.from(body.key, "hex");
99
+ expect(decoded.length).toBe(32);
100
+
101
+ // Verify it matches the test key.
102
+ expect(decoded.equals(TEST_SIGNING_KEY)).toBe(true);
103
+ });
104
+
105
+ test("second call returns 403 'Bootstrap already completed'", async () => {
106
+ const handler = createSigningKeyBootstrapHandler(makeConfig());
107
+
108
+ // First call succeeds.
109
+ const res1 = await handler.handleGetSigningKey(
110
+ new Request("http://localhost:7830/internal/signing-key-bootstrap"),
111
+ );
112
+ expect(res1.status).toBe(200);
113
+
114
+ // Second call rejected.
115
+ const res2 = await handler.handleGetSigningKey(
116
+ new Request("http://localhost:7830/internal/signing-key-bootstrap"),
117
+ );
118
+ expect(res2.status).toBe(403);
119
+ const body = (await res2.json()) as { error: string };
120
+ expect(body.error).toBe("Bootstrap already completed");
121
+ });
122
+
123
+ test("lockfile is written after successful response", async () => {
124
+ const handler = createSigningKeyBootstrapHandler(makeConfig());
125
+ await handler.handleGetSigningKey(
126
+ new Request("http://localhost:7830/internal/signing-key-bootstrap"),
127
+ );
128
+
129
+ expect(writtenLockFiles.length).toBe(1);
130
+ expect(writtenLockFiles[0]).toContain("signing-key-bootstrap.lock");
131
+ });
132
+
133
+ test("returned hex decodes to exactly 32 bytes", async () => {
134
+ const handler = createSigningKeyBootstrapHandler(makeConfig());
135
+ const res = await handler.handleGetSigningKey(
136
+ new Request("http://localhost:7830/internal/signing-key-bootstrap"),
137
+ );
138
+
139
+ const body = (await res.json()) as { key: string };
140
+ const decoded = Buffer.from(body.key, "hex");
141
+ expect(decoded.length).toBe(32);
142
+ });
143
+ });
@@ -32,7 +32,7 @@ const log = getLogger("auth-token-service");
32
32
 
33
33
  let signingKey: Buffer | null = null;
34
34
 
35
- function getSigningKeyPath(): string {
35
+ export function getSigningKeyPath(): string {
36
36
  const securityDir = process.env.GATEWAY_SECURITY_DIR;
37
37
  if (securityDir) {
38
38
  return join(securityDir, "actor-token-signing-key");
@@ -6,7 +6,7 @@
6
6
  import { existsSync, readFileSync, watch, type FSWatcher } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
  import { getLogger } from "./logger.js";
9
- import { getRootDir } from "./credential-reader.js";
9
+ import { getWorkspaceDir } from "./credential-reader.js";
10
10
 
11
11
  const log = getLogger("config-file-watcher");
12
12
 
@@ -23,7 +23,7 @@ export type ConfigChangeEvent = {
23
23
  export type ConfigChangeCallback = (event: ConfigChangeEvent) => void;
24
24
 
25
25
  function getConfigPath(): string {
26
- return join(getRootDir(), "workspace", CONFIG_FILENAME);
26
+ return join(getWorkspaceDir(), CONFIG_FILENAME);
27
27
  }
28
28
 
29
29
  function readConfigFile(path: string): Record<string, unknown> {
@@ -25,14 +25,6 @@
25
25
  "description": "Show the Contacts tab in Settings for viewing and managing contacts",
26
26
  "defaultEnabled": true
27
27
  },
28
- {
29
- "id": "custom-inference-provider",
30
- "scope": "macos",
31
- "key": "custom_inference_provider_enabled",
32
- "label": "Custom Inference Provider",
33
- "description": "Allow selecting a specific LLM provider and model for inference in Your Own mode",
34
- "defaultEnabled": false
35
- },
36
28
  {
37
29
  "id": "email-channel",
38
30
  "scope": "assistant",
@@ -150,6 +150,21 @@ export function createChannelVerificationSessionProxyHandler(
150
150
  req: Request,
151
151
  clientIp?: string,
152
152
  ): Promise<Response> {
153
+ // When GUARDIAN_BOOTSTRAP_SECRET is set (Docker mode), require the
154
+ // caller to present the matching secret. This prevents unauthenticated
155
+ // remote callers from bootstrapping guardian tokens through the gateway.
156
+ const bootstrapSecret = process.env.GUARDIAN_BOOTSTRAP_SECRET;
157
+ if (bootstrapSecret) {
158
+ const provided = req.headers.get("x-bootstrap-secret");
159
+ if (provided !== bootstrapSecret) {
160
+ log.warn("Guardian init rejected — invalid or missing bootstrap secret");
161
+ return Response.json(
162
+ { error: "Invalid bootstrap secret" },
163
+ { status: 403 },
164
+ );
165
+ }
166
+ }
167
+
153
168
  const lockDir = process.env.GATEWAY_SECURITY_DIR || getRootDir();
154
169
  const lockPath = join(lockDir, "guardian-init.lock");
155
170
  if (existsSync(lockPath) || guardianInitInFlight) {
@@ -7,7 +7,7 @@ import {
7
7
  } from "node:fs";
8
8
  import { join, dirname } from "node:path";
9
9
  import { randomBytes } from "node:crypto";
10
- import { getRootDir } from "../../credential-reader.js";
10
+ import { getWorkspaceDir } from "../../credential-reader.js";
11
11
 
12
12
  /**
13
13
  * Serializes config writes so concurrent PATCH requests don't race on
@@ -28,7 +28,7 @@ export function enqueueConfigWrite(fn: () => void): void {
28
28
  }
29
29
 
30
30
  export function getConfigPath(): string {
31
- return join(getRootDir(), "workspace", "config.json");
31
+ return join(getWorkspaceDir(), "config.json");
32
32
  }
33
33
 
34
34
  export type ConfigReadResult =
@@ -0,0 +1,59 @@
1
+ /**
2
+ * One-shot endpoint that serves the gateway's actor-token signing key to
3
+ * the daemon during Docker bootstrap. Protected by a lockfile so the key
4
+ * can only be read once (across restarts).
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ import { getSigningKeyPath } from "../../auth/token-service.js";
11
+ import type { GatewayConfig } from "../../config.js";
12
+ import { getRootDir } from "../../credential-reader.js";
13
+ import { getLogger } from "../../logger.js";
14
+
15
+ const log = getLogger("signing-key-bootstrap");
16
+
17
+ export function createSigningKeyBootstrapHandler(_config: GatewayConfig) {
18
+ let inFlight = false;
19
+
20
+ return {
21
+ async handleGetSigningKey(_req: Request): Promise<Response> {
22
+ const lockDir = process.env.GATEWAY_SECURITY_DIR || getRootDir();
23
+ const lockPath = join(lockDir, "signing-key-bootstrap.lock");
24
+
25
+ if (existsSync(lockPath) || inFlight) {
26
+ log.warn("Signing key bootstrap rejected — already completed");
27
+ return Response.json(
28
+ { error: "Bootstrap already completed" },
29
+ { status: 403 },
30
+ );
31
+ }
32
+
33
+ inFlight = true;
34
+ try {
35
+ const keyPath = getSigningKeyPath();
36
+ const keyBytes = readFileSync(keyPath);
37
+
38
+ const response = Response.json({
39
+ key: Buffer.from(keyBytes).toString("hex"),
40
+ });
41
+
42
+ try {
43
+ writeFileSync(lockPath, new Date().toISOString(), { mode: 0o600 });
44
+ } catch (err) {
45
+ log.error({ err }, "Failed to write signing-key-bootstrap lock file");
46
+ }
47
+
48
+ return response;
49
+ } catch (err) {
50
+ inFlight = false;
51
+ log.error({ err }, "Failed to read signing key for bootstrap");
52
+ return Response.json(
53
+ { error: "Internal server error" },
54
+ { status: 500 },
55
+ );
56
+ }
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Gateway proxy for the daemon upgrade-broadcast control-plane endpoint.
3
+ *
4
+ * Follows the same forwarding pattern as channel-readiness-proxy.ts:
5
+ * strips hop-by-hop headers, replaces the client's edge JWT with a
6
+ * minted service token, and proxies the request to the daemon.
7
+ */
8
+
9
+ import { mintServiceToken } from "../../auth/token-exchange.js";
10
+ import type { GatewayConfig } from "../../config.js";
11
+ import { fetchImpl } from "../../fetch.js";
12
+ import { getLogger } from "../../logger.js";
13
+ import { stripHopByHop } from "../../util/strip-hop-by-hop.js";
14
+
15
+ const log = getLogger("upgrade-broadcast-proxy");
16
+
17
+ export function createUpgradeBroadcastProxyHandler(config: GatewayConfig) {
18
+ return async function handleUpgradeBroadcast(
19
+ req: Request,
20
+ ): Promise<Response> {
21
+ const start = performance.now();
22
+ const bodyBuffer = await req.arrayBuffer();
23
+
24
+ const upstream = `${config.assistantRuntimeBaseUrl}/v1/admin/upgrade-broadcast`;
25
+
26
+ const reqHeaders = stripHopByHop(new Headers(req.headers));
27
+ reqHeaders.delete("host");
28
+ reqHeaders.delete("authorization");
29
+
30
+ reqHeaders.set("authorization", `Bearer ${mintServiceToken()}`);
31
+ reqHeaders.set("content-length", String(bodyBuffer.byteLength));
32
+
33
+ const controller = new AbortController();
34
+ const timeoutId = setTimeout(() => {
35
+ controller.abort(
36
+ new DOMException(
37
+ "The operation was aborted due to timeout",
38
+ "TimeoutError",
39
+ ),
40
+ );
41
+ }, config.runtimeTimeoutMs);
42
+
43
+ let response: Response;
44
+ try {
45
+ response = await fetchImpl(upstream, {
46
+ method: "POST",
47
+ headers: reqHeaders,
48
+ body: bodyBuffer,
49
+ signal: controller.signal,
50
+ });
51
+ clearTimeout(timeoutId);
52
+ } catch (err) {
53
+ clearTimeout(timeoutId);
54
+ const duration = Math.round(performance.now() - start);
55
+ if (err instanceof DOMException && err.name === "TimeoutError") {
56
+ log.error({ duration }, "Upgrade broadcast proxy upstream timed out");
57
+ return Response.json({ error: "Gateway Timeout" }, { status: 504 });
58
+ }
59
+ log.error(
60
+ { err, duration },
61
+ "Upgrade broadcast proxy upstream connection failed",
62
+ );
63
+ return Response.json({ error: "Bad Gateway" }, { status: 502 });
64
+ }
65
+
66
+ const resHeaders = stripHopByHop(new Headers(response.headers));
67
+ const duration = Math.round(performance.now() - start);
68
+
69
+ if (response.status >= 400) {
70
+ const body = await response.text();
71
+ log.warn(
72
+ { status: response.status, duration },
73
+ "Upgrade broadcast proxy upstream error",
74
+ );
75
+ return new Response(body, {
76
+ status: response.status,
77
+ headers: resHeaders,
78
+ });
79
+ }
80
+
81
+ log.info(
82
+ { status: response.status, duration },
83
+ "Upgrade broadcast proxy completed",
84
+ );
85
+ return new Response(response.body, {
86
+ status: response.status,
87
+ headers: resHeaders,
88
+ });
89
+ };
90
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  CredentialWatcher,
20
20
  type CredentialChangeEvent,
21
21
  } from "./credential-watcher.js";
22
+ import { createSigningKeyBootstrapHandler } from "./http/routes/signing-key-bootstrap.js";
22
23
  import { createRuntimeProxyHandler } from "./http/routes/runtime-proxy.js";
23
24
  import {
24
25
  createBrowserRelayWebsocketHandler,
@@ -53,6 +54,7 @@ import { createSlackControlPlaneProxyHandler } from "./http/routes/slack-control
53
54
  import { createOAuthAppsProxyHandler } from "./http/routes/oauth-apps-proxy.js";
54
55
  import { createChannelReadinessProxyHandler } from "./http/routes/channel-readiness-proxy.js";
55
56
  import { createRuntimeHealthProxyHandler } from "./http/routes/runtime-health-proxy.js";
57
+ import { createUpgradeBroadcastProxyHandler } from "./http/routes/upgrade-broadcast-proxy.js";
56
58
  import { createBrainGraphProxyHandler } from "./http/routes/brain-graph-proxy.js";
57
59
  import {
58
60
  createTrustRulesListHandler,
@@ -202,6 +204,8 @@ async function main() {
202
204
  initSigningKey(signingKey);
203
205
  log.info("JWT signing key initialized");
204
206
 
207
+ const signingKeyBootstrap = createSigningKeyBootstrapHandler(config);
208
+
205
209
  // ── TTL caches ──
206
210
  // Instantiate caches for credential and config file reads.
207
211
  // Handlers read dynamic credentials and config.json values from these
@@ -279,6 +283,7 @@ async function main() {
279
283
  const oauthAppsProxy = createOAuthAppsProxyHandler(config);
280
284
  const channelReadinessProxy = createChannelReadinessProxyHandler(config);
281
285
  const runtimeHealthProxy = createRuntimeHealthProxyHandler(config);
286
+ const upgradeBroadcastProxy = createUpgradeBroadcastProxyHandler(config);
282
287
  const brainGraphProxy = createBrainGraphProxyHandler(config);
283
288
  const handleFeatureFlagsGet = createFeatureFlagsGetHandler();
284
289
  const handleFeatureFlagsPatch = createFeatureFlagsPatchHandler();
@@ -528,6 +533,14 @@ async function main() {
528
533
  contactsControlPlaneProxy.handleGetContact(req, params[0]),
529
534
  },
530
535
 
536
+ // ── Signing key bootstrap (one-shot, lockfile-protected) ──
537
+ {
538
+ path: "/internal/signing-key-bootstrap",
539
+ method: "GET",
540
+ auth: "none",
541
+ handler: (req) => signingKeyBootstrap.handleGetSigningKey(req),
542
+ },
543
+
531
544
  // ── Channel verification sessions ──
532
545
  {
533
546
  // Bootstrap endpoint — may be replaced with an SSH-based exchange in the
@@ -707,6 +720,14 @@ async function main() {
707
720
  handler: (req, params) => oauthAppsProxy.handleConnect(req, params[0]),
708
721
  },
709
722
 
723
+ // ── Upgrade broadcast ──
724
+ {
725
+ path: "/v1/admin/upgrade-broadcast",
726
+ method: "POST",
727
+ auth: "edge",
728
+ handler: (req) => upgradeBroadcastProxy(req),
729
+ },
730
+
710
731
  // ── Channel readiness ──
711
732
  {
712
733
  path: "/v1/channels/readiness",
@@ -884,6 +905,9 @@ async function main() {
884
905
  const server = Bun.serve({
885
906
  port: config.port,
886
907
  idleTimeout: 0,
908
+ // Match the daemon's 512 MB limit (assistant/src/runtime/http-server.ts)
909
+ // so large .vbundle imports proxied through the gateway aren't rejected.
910
+ maxRequestBodySize: 512 * 1024 * 1024,
887
911
  websocket: {
888
912
  open(ws) {
889
913
  if (isBrowserRelaySocketData(ws.data)) {
package/src/schema.ts CHANGED
@@ -1643,7 +1643,9 @@ export function buildSchema(): Record<string, unknown> {
1643
1643
  },
1644
1644
  },
1645
1645
  },
1646
- "400": { description: "Missing or invalid provider_key query parameter" },
1646
+ "400": {
1647
+ description: "Missing or invalid provider_key query parameter",
1648
+ },
1647
1649
  "401": {
1648
1650
  description: "Unauthorized — missing or invalid bearer token",
1649
1651
  },
@@ -2525,6 +2527,31 @@ export function buildSchema(): Record<string, unknown> {
2525
2527
  },
2526
2528
  },
2527
2529
  },
2530
+ "/v1/admin/upgrade-broadcast": {
2531
+ post: {
2532
+ summary: "Broadcast upgrade to connected clients",
2533
+ description:
2534
+ "Internal control-plane endpoint that proxies an upgrade-broadcast request to the assistant daemon. Authenticated with an edge JWT. The daemon notifies all connected clients that a new version is available.",
2535
+ operationId: "upgradeBroadcast",
2536
+ security: [{ BearerAuth: [] }],
2537
+ requestBody: {
2538
+ required: false,
2539
+ content: {
2540
+ "application/json": {
2541
+ schema: { type: "object", additionalProperties: true },
2542
+ },
2543
+ },
2544
+ },
2545
+ responses: {
2546
+ "200": { description: "Broadcast sent successfully" },
2547
+ "401": {
2548
+ description: "Unauthorized — missing or invalid bearer token",
2549
+ },
2550
+ "502": { description: "Failed to reach assistant daemon" },
2551
+ "504": { description: "Assistant daemon request timed out" },
2552
+ },
2553
+ },
2554
+ },
2528
2555
  "/{path}": {
2529
2556
  get: {
2530
2557
  summary: "Runtime proxy",