@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 +1 -1
- package/src/__tests__/guardian-init-lockfile.test.ts +94 -0
- package/src/__tests__/route-schema-guard.test.ts +3 -0
- package/src/__tests__/signing-key-bootstrap.test.ts +143 -0
- package/src/auth/token-service.ts +1 -1
- package/src/config-file-watcher.ts +2 -2
- package/src/feature-flag-registry.json +0 -8
- package/src/http/routes/channel-verification-session-proxy.ts +15 -0
- package/src/http/routes/config-file-utils.ts +2 -2
- package/src/http/routes/signing-key-bootstrap.ts +59 -0
- package/src/http/routes/upgrade-broadcast-proxy.ts +90 -0
- package/src/index.ts +24 -0
- package/src/schema.ts +28 -1
package/package.json
CHANGED
|
@@ -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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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": {
|
|
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",
|