@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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { getWorkspaceDir } from "./credential-reader.js";
|
|
12
|
+
|
|
13
|
+
export const CONFIG_FILENAME = "config.json";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Serializes config writes so concurrent config mutations don't race on
|
|
17
|
+
* read-modify-write. Each write awaits the previous one before proceeding.
|
|
18
|
+
*
|
|
19
|
+
* This chain is shared across all gateway config mutations to prevent
|
|
20
|
+
* concurrent writes to the same config.json from overwriting each other's
|
|
21
|
+
* changes.
|
|
22
|
+
*/
|
|
23
|
+
let configWriteChain: Promise<void> = Promise.resolve();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Enqueue a write operation onto the shared config write chain.
|
|
27
|
+
* The callback runs only after all previously enqueued writes have finished.
|
|
28
|
+
*/
|
|
29
|
+
export function enqueueConfigWrite(
|
|
30
|
+
fn: () => void | Promise<void>,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const run = configWriteChain.then(fn);
|
|
33
|
+
configWriteChain = run.catch(() => {});
|
|
34
|
+
return run;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ConfigMutationResult<T> =
|
|
38
|
+
| { ok: true; value: T }
|
|
39
|
+
| { ok: false; reason: "malformed"; detail: string };
|
|
40
|
+
|
|
41
|
+
export function mutateConfigFile<T>(
|
|
42
|
+
mutate: (data: Record<string, unknown>) => T,
|
|
43
|
+
options?: {
|
|
44
|
+
shouldWrite?: (value: T) => boolean;
|
|
45
|
+
onWritten?: () => void;
|
|
46
|
+
},
|
|
47
|
+
): Promise<ConfigMutationResult<T>> {
|
|
48
|
+
let mutationResult: ConfigMutationResult<T> | undefined;
|
|
49
|
+
|
|
50
|
+
return enqueueConfigWrite(() => {
|
|
51
|
+
const result = readConfigFile();
|
|
52
|
+
if (!result.ok) {
|
|
53
|
+
mutationResult = result;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const value = mutate(result.data);
|
|
58
|
+
const shouldWrite = options?.shouldWrite?.(value) ?? true;
|
|
59
|
+
if (shouldWrite) {
|
|
60
|
+
writeConfigFileAtomic(result.data);
|
|
61
|
+
options?.onWritten?.();
|
|
62
|
+
}
|
|
63
|
+
mutationResult = { ok: true, value };
|
|
64
|
+
}).then(() => {
|
|
65
|
+
if (!mutationResult) {
|
|
66
|
+
throw new Error("Config mutation did not produce a result");
|
|
67
|
+
}
|
|
68
|
+
return mutationResult;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getConfigPath(): string {
|
|
73
|
+
return join(getWorkspaceDir(), CONFIG_FILENAME);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ConfigReadResult =
|
|
77
|
+
| { ok: true; data: Record<string, unknown> }
|
|
78
|
+
| { ok: false; reason: "malformed"; detail: string };
|
|
79
|
+
|
|
80
|
+
export function readConfigFile(): ConfigReadResult {
|
|
81
|
+
const cfgPath = getConfigPath();
|
|
82
|
+
if (!existsSync(cfgPath)) {
|
|
83
|
+
return { ok: true, data: {} };
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const raw = readFileSync(cfgPath, "utf-8");
|
|
87
|
+
const parsed = JSON.parse(raw);
|
|
88
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: "malformed",
|
|
92
|
+
detail: "Config file is not a JSON object",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, data: parsed };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { ok: false, reason: "malformed", detail: String(err) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function readConfigFileOrEmpty(options?: {
|
|
102
|
+
onMalformed?: (detail: string) => void;
|
|
103
|
+
}): Record<string, unknown> {
|
|
104
|
+
const result = readConfigFile();
|
|
105
|
+
if (result.ok) return result.data;
|
|
106
|
+
options?.onMalformed?.(result.detail);
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Atomically write the config file: write to a temporary file in the same
|
|
112
|
+
* directory, then rename. This avoids partial-file corruption if the process
|
|
113
|
+
* crashes mid-write.
|
|
114
|
+
*/
|
|
115
|
+
export function writeConfigFileAtomic(data: Record<string, unknown>): void {
|
|
116
|
+
const cfgPath = getConfigPath();
|
|
117
|
+
const dir = dirname(cfgPath);
|
|
118
|
+
if (!existsSync(dir)) {
|
|
119
|
+
mkdirSync(dir, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
const tmpPath = join(dir, `.config.${randomBytes(6).toString("hex")}.tmp`);
|
|
122
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
123
|
+
renameSync(tmpPath, cfgPath);
|
|
124
|
+
}
|
|
@@ -3,49 +3,39 @@
|
|
|
3
3
|
* Uses the same fs.watch() + debounce pattern as CredentialWatcher.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync,
|
|
7
|
-
import { dirname
|
|
6
|
+
import { existsSync, watch, type FSWatcher } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
CONFIG_FILENAME,
|
|
10
|
+
getConfigPath,
|
|
11
|
+
readConfigFileOrEmpty,
|
|
12
|
+
} from "./config-file-utils.js";
|
|
8
13
|
import { getLogger } from "./logger.js";
|
|
9
|
-
import { getWorkspaceDir } from "./credential-reader.js";
|
|
10
14
|
|
|
11
15
|
const log = getLogger("config-file-watcher");
|
|
12
16
|
|
|
13
17
|
const DEBOUNCE_MS = 500;
|
|
14
|
-
const CONFIG_FILENAME = "config.json";
|
|
15
18
|
|
|
16
19
|
export type ConfigChangeEvent = {
|
|
17
20
|
/** Full parsed config.json data. */
|
|
18
21
|
data: Record<string, unknown>;
|
|
19
22
|
/** Top-level keys whose serialized value changed since the last poll. */
|
|
20
23
|
changedKeys: Set<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Shallow object fields that changed for changed top-level object keys.
|
|
26
|
+
* Non-object replacements are represented by the top-level key only.
|
|
27
|
+
*/
|
|
28
|
+
changedFields: Map<string, Set<string>>;
|
|
21
29
|
};
|
|
22
30
|
|
|
23
31
|
export type ConfigChangeCallback = (event: ConfigChangeEvent) => void;
|
|
24
32
|
|
|
25
|
-
function getConfigPath(): string {
|
|
26
|
-
return join(getWorkspaceDir(), CONFIG_FILENAME);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function readConfigFile(path: string): Record<string, unknown> {
|
|
30
|
-
try {
|
|
31
|
-
if (!existsSync(path)) return {};
|
|
32
|
-
|
|
33
|
-
const raw = readFileSync(path, "utf-8");
|
|
34
|
-
const data = JSON.parse(raw);
|
|
35
|
-
if (!data || typeof data !== "object" || Array.isArray(data)) return {};
|
|
36
|
-
|
|
37
|
-
return data as Record<string, unknown>;
|
|
38
|
-
} catch (err) {
|
|
39
|
-
log.debug({ err }, "Failed to read config file");
|
|
40
|
-
return {};
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
33
|
export class ConfigFileWatcher {
|
|
45
34
|
private watcher: FSWatcher | null = null;
|
|
46
35
|
private watchingDirectory = false;
|
|
47
36
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
48
37
|
private lastSerialized: Map<string, string> = new Map();
|
|
38
|
+
private lastValues: Map<string, unknown> = new Map();
|
|
49
39
|
private callback: ConfigChangeCallback;
|
|
50
40
|
private configPath: string;
|
|
51
41
|
|
|
@@ -134,9 +124,14 @@ export class ConfigFileWatcher {
|
|
|
134
124
|
}
|
|
135
125
|
|
|
136
126
|
private pollOnce(): void {
|
|
137
|
-
const data =
|
|
127
|
+
const data = readConfigFileOrEmpty({
|
|
128
|
+
onMalformed: (detail) => {
|
|
129
|
+
log.debug({ err: detail }, "Failed to read config file");
|
|
130
|
+
},
|
|
131
|
+
});
|
|
138
132
|
|
|
139
133
|
const changedKeys = new Set<string>();
|
|
134
|
+
const changedFields = new Map<string, Set<string>>();
|
|
140
135
|
|
|
141
136
|
// Detect changed or added keys
|
|
142
137
|
const allKeys = new Set([
|
|
@@ -150,10 +145,19 @@ export class ConfigFileWatcher {
|
|
|
150
145
|
|
|
151
146
|
if (newVal !== oldVal) {
|
|
152
147
|
changedKeys.add(key);
|
|
148
|
+
const fieldChanges = diffObjectFields(
|
|
149
|
+
this.lastValues.get(key),
|
|
150
|
+
key in data ? data[key] : undefined,
|
|
151
|
+
);
|
|
152
|
+
if (fieldChanges.size > 0) {
|
|
153
|
+
changedFields.set(key, fieldChanges);
|
|
154
|
+
}
|
|
153
155
|
if (newVal !== undefined) {
|
|
154
156
|
this.lastSerialized.set(key, newVal);
|
|
157
|
+
this.lastValues.set(key, data[key]);
|
|
155
158
|
} else {
|
|
156
159
|
this.lastSerialized.delete(key);
|
|
160
|
+
this.lastValues.delete(key);
|
|
157
161
|
}
|
|
158
162
|
}
|
|
159
163
|
}
|
|
@@ -162,6 +166,34 @@ export class ConfigFileWatcher {
|
|
|
162
166
|
|
|
163
167
|
log.info({ changedKeys: [...changedKeys] }, "Config file changed");
|
|
164
168
|
|
|
165
|
-
this.callback({ data, changedKeys });
|
|
169
|
+
this.callback({ data, changedKeys, changedFields });
|
|
166
170
|
}
|
|
167
171
|
}
|
|
172
|
+
|
|
173
|
+
function diffObjectFields(oldValue: unknown, newValue: unknown): Set<string> {
|
|
174
|
+
if (!isPlainRecord(oldValue) && !isPlainRecord(newValue)) {
|
|
175
|
+
return new Set();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const oldRecord = isPlainRecord(oldValue) ? oldValue : {};
|
|
179
|
+
const newRecord = isPlainRecord(newValue) ? newValue : {};
|
|
180
|
+
const changed = new Set<string>();
|
|
181
|
+
const allKeys = new Set([
|
|
182
|
+
...Object.keys(oldRecord),
|
|
183
|
+
...Object.keys(newRecord),
|
|
184
|
+
]);
|
|
185
|
+
for (const key of allKeys) {
|
|
186
|
+
const oldSerialized =
|
|
187
|
+
key in oldRecord ? JSON.stringify(oldRecord[key]) : undefined;
|
|
188
|
+
const newSerialized =
|
|
189
|
+
key in newRecord ? JSON.stringify(newRecord[key]) : undefined;
|
|
190
|
+
if (oldSerialized !== newSerialized) {
|
|
191
|
+
changed.add(key);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return changed;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
198
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
199
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type GatewayConfig = {
|
|
|
11
11
|
assistantRuntimeBaseUrl: string;
|
|
12
12
|
defaultAssistantId: string | undefined;
|
|
13
13
|
gatewayInternalBaseUrl: string;
|
|
14
|
+
velayBaseUrl?: string;
|
|
14
15
|
logFile: LogFileConfig;
|
|
15
16
|
maxAttachmentBytes: Record<
|
|
16
17
|
"telegram" | "slack" | "whatsapp" | "default",
|
|
@@ -23,7 +24,6 @@ export type GatewayConfig = {
|
|
|
23
24
|
routingEntries: RoutingEntry[];
|
|
24
25
|
runtimeInitialBackoffMs: number;
|
|
25
26
|
runtimeMaxRetries: number;
|
|
26
|
-
runtimeProxyEnabled: boolean;
|
|
27
27
|
runtimeProxyRequireAuth: boolean;
|
|
28
28
|
runtimeTimeoutMs: number;
|
|
29
29
|
shutdownDrainMs: number;
|
|
@@ -93,6 +93,7 @@ export function loadConfig(): GatewayConfig {
|
|
|
93
93
|
const assistantRuntimeBaseUrl = `http://${assistantHost}:${runtimePort}`;
|
|
94
94
|
|
|
95
95
|
const gatewayInternalBaseUrl = `http://127.0.0.1:${port}`;
|
|
96
|
+
const velayBaseUrl = process.env.VELAY_BASE_URL?.trim() || undefined;
|
|
96
97
|
|
|
97
98
|
// Read operational settings from workspace config (Docker) or env vars (CLI).
|
|
98
99
|
const wsConfig = readWorkspaceConfig();
|
|
@@ -101,10 +102,6 @@ export function loadConfig(): GatewayConfig {
|
|
|
101
102
|
// Env vars take precedence over workspace config values. This allows the
|
|
102
103
|
// CLI to pass gateway settings directly via the process environment instead
|
|
103
104
|
// of writing to the workspace config file.
|
|
104
|
-
const runtimeProxyEnabled =
|
|
105
|
-
process.env.RUNTIME_PROXY_ENABLED === "true" ||
|
|
106
|
-
gw.runtimeProxyEnabled === true ||
|
|
107
|
-
gw.runtimeProxyEnabled === "true";
|
|
108
105
|
const runtimeProxyRequireAuth =
|
|
109
106
|
process.env.RUNTIME_PROXY_REQUIRE_AUTH !== undefined
|
|
110
107
|
? process.env.RUNTIME_PROXY_REQUIRE_AUTH !== "false"
|
|
@@ -147,8 +144,8 @@ export function loadConfig(): GatewayConfig {
|
|
|
147
144
|
routingEntryCount: routingEntries.length,
|
|
148
145
|
unmappedPolicy,
|
|
149
146
|
hasDefaultAssistant: !!defaultAssistantId,
|
|
147
|
+
hasVelayBaseUrl: !!velayBaseUrl,
|
|
150
148
|
port,
|
|
151
|
-
runtimeProxyEnabled,
|
|
152
149
|
runtimeProxyRequireAuth,
|
|
153
150
|
trustProxy: false,
|
|
154
151
|
},
|
|
@@ -159,6 +156,7 @@ export function loadConfig(): GatewayConfig {
|
|
|
159
156
|
assistantRuntimeBaseUrl,
|
|
160
157
|
defaultAssistantId,
|
|
161
158
|
gatewayInternalBaseUrl,
|
|
159
|
+
velayBaseUrl,
|
|
162
160
|
logFile,
|
|
163
161
|
maxAttachmentBytes: {
|
|
164
162
|
telegram: 20 * 1024 * 1024, // Telegram Bot API getFile (download) limit
|
|
@@ -173,7 +171,6 @@ export function loadConfig(): GatewayConfig {
|
|
|
173
171
|
routingEntries,
|
|
174
172
|
runtimeInitialBackoffMs: 500,
|
|
175
173
|
runtimeMaxRetries: 2,
|
|
176
|
-
runtimeProxyEnabled,
|
|
177
174
|
runtimeProxyRequireAuth,
|
|
178
175
|
runtimeTimeoutMs: 30000,
|
|
179
176
|
shutdownDrainMs: 5000,
|
package/src/db/connection.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
3
|
-
import { existsSync, mkdirSync, renameSync } from "node:fs";
|
|
4
|
-
import {
|
|
3
|
+
import { existsSync, mkdirSync, realpathSync, renameSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
5
6
|
import { getGatewaySecurityDir, getLegacyRootDir } from "../paths.js";
|
|
6
7
|
import * as schema from "./schema.js";
|
|
7
8
|
import { seedTrustRulesFromRegistry } from "./seed-trust-rules.js";
|
|
@@ -71,6 +72,64 @@ async function pushSchemaNoPrompt(
|
|
|
71
72
|
|
|
72
73
|
let db: GatewayDb | null = null;
|
|
73
74
|
|
|
75
|
+
function canonicalizePathThroughExistingParent(path: string): string {
|
|
76
|
+
const resolvedPath = resolve(path);
|
|
77
|
+
const pendingSegments: string[] = [];
|
|
78
|
+
let currentPath = resolvedPath;
|
|
79
|
+
|
|
80
|
+
while (true) {
|
|
81
|
+
try {
|
|
82
|
+
return resolve(realpathSync(currentPath), ...pendingSegments.reverse());
|
|
83
|
+
} catch {
|
|
84
|
+
const parentPath = dirname(currentPath);
|
|
85
|
+
if (parentPath === currentPath) {
|
|
86
|
+
return resolvedPath;
|
|
87
|
+
}
|
|
88
|
+
pendingSegments.push(basename(currentPath));
|
|
89
|
+
currentPath = parentPath;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertTestDbIsIsolated(): void {
|
|
95
|
+
if (
|
|
96
|
+
process.env.NODE_ENV !== "test" ||
|
|
97
|
+
process.env.VELLUM_ALLOW_REAL_GATEWAY_SECURITY_IN_TESTS === "1"
|
|
98
|
+
) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const securityDir = process.env.GATEWAY_SECURITY_DIR?.trim();
|
|
103
|
+
if (!securityDir) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
[
|
|
106
|
+
"Refusing to open the gateway DB during tests without GATEWAY_SECURITY_DIR.",
|
|
107
|
+
"Run gateway tests from the gateway package so the test preload can isolate state:",
|
|
108
|
+
" cd gateway && bun test src/path/to/file.test.ts",
|
|
109
|
+
].join("\n"),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const resolvedSecurityDir =
|
|
114
|
+
canonicalizePathThroughExistingParent(securityDir);
|
|
115
|
+
const realSecurityDir = canonicalizePathThroughExistingParent(
|
|
116
|
+
process.env.VELLUM_TEST_REAL_GATEWAY_SECURITY_DIR?.trim() ||
|
|
117
|
+
join(homedir(), ".vellum", "protected"),
|
|
118
|
+
);
|
|
119
|
+
if (
|
|
120
|
+
resolvedSecurityDir === realSecurityDir ||
|
|
121
|
+
resolvedSecurityDir.startsWith(realSecurityDir + sep)
|
|
122
|
+
) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
[
|
|
125
|
+
"Refusing to open the real gateway security DB during tests.",
|
|
126
|
+
`GATEWAY_SECURITY_DIR resolved to ${resolvedSecurityDir}.`,
|
|
127
|
+
"Use a temp gateway security directory for tests instead.",
|
|
128
|
+
].join("\n"),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
74
133
|
/**
|
|
75
134
|
* One-time migration: move gateway.sqlite from the legacy path
|
|
76
135
|
* (~/.vellum/data/gateway.sqlite) to the new PVC-backed path
|
|
@@ -100,12 +159,15 @@ function migrateLegacyDb(newPath: string): void {
|
|
|
100
159
|
}
|
|
101
160
|
|
|
102
161
|
function getDbPath(): string {
|
|
162
|
+
assertTestDbIsIsolated();
|
|
103
163
|
const securityDir = getGatewaySecurityDir();
|
|
104
164
|
if (!existsSync(securityDir)) {
|
|
105
165
|
mkdirSync(securityDir, { recursive: true });
|
|
106
166
|
}
|
|
107
167
|
const dbPath = join(securityDir, "gateway.sqlite");
|
|
108
|
-
|
|
168
|
+
if (process.env.NODE_ENV !== "test") {
|
|
169
|
+
migrateLegacyDb(dbPath);
|
|
170
|
+
}
|
|
109
171
|
return dbPath;
|
|
110
172
|
}
|
|
111
173
|
|
package/src/db/contact-store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { desc, eq, and } from "drizzle-orm";
|
|
1
|
+
import { desc, eq, and, sql } from "drizzle-orm";
|
|
2
2
|
import { type GatewayDb, getGatewayDb } from "./connection.js";
|
|
3
3
|
import { contacts, contactChannels } from "./schema.js";
|
|
4
4
|
|
|
@@ -65,4 +65,33 @@ export class ContactStore {
|
|
|
65
65
|
.orderBy(contactChannels.createdAt)
|
|
66
66
|
.all();
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Set lastSeenAt to now for a channel (gateway DB only).
|
|
71
|
+
*/
|
|
72
|
+
touchChannelLastSeen(channelId: string): void {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
this.db
|
|
75
|
+
.update(contactChannels)
|
|
76
|
+
.set({ lastSeenAt: now, updatedAt: now })
|
|
77
|
+
.where(eq(contactChannels.id, channelId))
|
|
78
|
+
.run();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Increment interaction count and set lastInteraction timestamp
|
|
83
|
+
* (gateway DB only).
|
|
84
|
+
*/
|
|
85
|
+
touchContactInteraction(channelId: string): void {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
this.db
|
|
88
|
+
.update(contactChannels)
|
|
89
|
+
.set({
|
|
90
|
+
lastInteraction: now,
|
|
91
|
+
interactionCount: sql`${contactChannels.interactionCount} + 1`,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
})
|
|
94
|
+
.where(eq(contactChannels.id, channelId))
|
|
95
|
+
.run();
|
|
96
|
+
}
|
|
68
97
|
}
|
|
@@ -21,6 +21,7 @@ import { getLogger } from "../../logger.js";
|
|
|
21
21
|
|
|
22
22
|
import * as m0001 from "./m0001-guardian-init-lock.js";
|
|
23
23
|
import * as m0002 from "./m0002-actor-token-tables-to-gateway.js";
|
|
24
|
+
import * as m0003 from "./m0003-recover-backup-key.js";
|
|
24
25
|
|
|
25
26
|
const log = getLogger("data-migrations");
|
|
26
27
|
|
|
@@ -34,6 +35,7 @@ type MigrationModule = {
|
|
|
34
35
|
const MIGRATIONS: { key: string; mod: MigrationModule }[] = [
|
|
35
36
|
{ key: "m0001-guardian-init-lock", mod: m0001 },
|
|
36
37
|
{ key: "m0002-actor-token-tables-to-gateway", mod: m0002 },
|
|
38
|
+
{ key: "m0003-recover-backup-key", mod: m0003 },
|
|
37
39
|
];
|
|
38
40
|
|
|
39
41
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time migration: recover backup.key into GATEWAY_SECURITY_DIR.
|
|
3
|
+
*
|
|
4
|
+
* The backup key may exist at either of two legacy locations depending on
|
|
5
|
+
* which version of the assistant created it:
|
|
6
|
+
*
|
|
7
|
+
* 1. ~/.vellum/workspace/.backup.key — migration 061 moved it here
|
|
8
|
+
* 2. ~/.vellum/protected/backup.key — original location (pre-061)
|
|
9
|
+
*
|
|
10
|
+
* This migration copies the key from whichever location has it into the
|
|
11
|
+
* canonical gateway security directory (GATEWAY_SECURITY_DIR), which in
|
|
12
|
+
* local mode resolves to ~/.vellum/protected/ and in Docker mode to a
|
|
13
|
+
* dedicated volume. If the key already exists at the target, we leave it
|
|
14
|
+
* alone — the gateway's ensureBackupKey handles first-time generation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { getLogger } from "../../logger.js";
|
|
21
|
+
import { getGatewaySecurityDir, getLegacyRootDir, getWorkspaceDir } from "../../paths.js";
|
|
22
|
+
|
|
23
|
+
import type { MigrationResult } from "./index.js";
|
|
24
|
+
|
|
25
|
+
const log = getLogger("m0003-recover-backup-key");
|
|
26
|
+
|
|
27
|
+
const BACKUP_KEY_FILENAME = "backup.key";
|
|
28
|
+
|
|
29
|
+
export function up(): MigrationResult {
|
|
30
|
+
const targetPath = join(getGatewaySecurityDir(), BACKUP_KEY_FILENAME);
|
|
31
|
+
|
|
32
|
+
if (existsSync(targetPath)) {
|
|
33
|
+
log.info({ targetPath }, "Backup key already exists at target — nothing to do");
|
|
34
|
+
return "done";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check both possible source locations
|
|
38
|
+
const workspacePath = join(getWorkspaceDir(), ".backup.key");
|
|
39
|
+
const legacyProtectedPath = join(getLegacyRootDir(), "protected", BACKUP_KEY_FILENAME);
|
|
40
|
+
|
|
41
|
+
// Prefer the workspace copy (migration 061 moved it there most recently)
|
|
42
|
+
const sourceCandidates = [workspacePath, legacyProtectedPath];
|
|
43
|
+
|
|
44
|
+
for (const source of sourceCandidates) {
|
|
45
|
+
// Skip if source is the same file as target (local mode where
|
|
46
|
+
// GATEWAY_SECURITY_DIR == ~/.vellum/protected/)
|
|
47
|
+
if (resolve(source) === resolve(targetPath)) {
|
|
48
|
+
log.info({ source }, "Source is the same as target — skipping");
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!existsSync(source)) continue;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(dirname(targetPath), { recursive: true, mode: 0o700 });
|
|
56
|
+
copyFileSync(source, targetPath);
|
|
57
|
+
log.info({ from: source, to: targetPath }, "Recovered backup key");
|
|
58
|
+
return "done";
|
|
59
|
+
} catch (err) {
|
|
60
|
+
log.error({ err, source }, "Failed to copy backup key — will retry");
|
|
61
|
+
return "skip";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
log.info("No existing backup key found at either legacy location — ensureBackupKey will generate one");
|
|
66
|
+
return "done";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function down(): MigrationResult {
|
|
70
|
+
return "done";
|
|
71
|
+
}
|
package/src/db/schema.ts
CHANGED
|
@@ -21,16 +21,45 @@ import {
|
|
|
21
21
|
|
|
22
22
|
export const slackActiveThreads = sqliteTable("slack_active_threads", {
|
|
23
23
|
threadTs: text("thread_ts").primaryKey(),
|
|
24
|
+
// Channel hosting the active thread. Nullable because SQLite's
|
|
25
|
+
// ALTER TABLE ADD COLUMN cannot add a NOT NULL column without a default
|
|
26
|
+
// (https://sqlite.org/lang_altertable.html#alter_table_add_column);
|
|
27
|
+
// legacy rows pre-dating this column carry NULL until they age out of
|
|
28
|
+
// the thread TTL window, and reconnect catch-up enumeration filters them.
|
|
29
|
+
channelId: text("channel_id"),
|
|
24
30
|
trackedAt: integer("tracked_at").notNull(),
|
|
25
31
|
expiresAt: integer("expires_at").notNull(),
|
|
26
32
|
});
|
|
27
33
|
|
|
28
34
|
export const slackSeenEvents = sqliteTable("slack_seen_events", {
|
|
35
|
+
// Generic dedup key. Holds either a Slack `event_id` (live path) or a
|
|
36
|
+
// synthetic `msg:${channel}:${ts}` key (reconnect catch-up path) so both
|
|
37
|
+
// paths dedup symmetrically against the same row. The physical column
|
|
38
|
+
// name `event_id` is a historical artefact; semantically this is a
|
|
39
|
+
// dedup key, not strictly an event ID.
|
|
29
40
|
eventId: text("event_id").primaryKey(),
|
|
30
41
|
seenAt: integer("seen_at").notNull(),
|
|
31
42
|
expiresAt: integer("expires_at").notNull(),
|
|
32
43
|
});
|
|
33
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Persistent high-watermark for Slack Socket Mode catch-up.
|
|
47
|
+
*
|
|
48
|
+
* Slack does not buffer events for disconnected Socket Mode clients
|
|
49
|
+
* (https://api.slack.com/apis/socket-mode), so missed @mentions and DMs
|
|
50
|
+
* during a reconnect window are recovered via `conversations.history` /
|
|
51
|
+
* `conversations.replies`. This row stores the latest accepted event
|
|
52
|
+
* timestamp so catch-up knows where to resume from. A single row keyed
|
|
53
|
+
* by `'global'` is used; per-channel watermarks would add precision but
|
|
54
|
+
* are not necessary because the compound `msg:${channel}:${ts}` dedup
|
|
55
|
+
* absorbs the resulting overlap.
|
|
56
|
+
*/
|
|
57
|
+
export const slackLastSeenTs = sqliteTable("slack_last_seen_ts", {
|
|
58
|
+
key: text("key").primaryKey(),
|
|
59
|
+
ts: text("ts").notNull(),
|
|
60
|
+
updatedAt: integer("updated_at").notNull(),
|
|
61
|
+
});
|
|
62
|
+
|
|
34
63
|
// ---------------------------------------------------------------------------
|
|
35
64
|
// Data migrations
|
|
36
65
|
// ---------------------------------------------------------------------------
|
|
@@ -100,6 +129,7 @@ export const autoApproveThresholds = sqliteTable("auto_approve_thresholds", {
|
|
|
100
129
|
id: integer("id").primaryKey().default(1),
|
|
101
130
|
interactive: text("interactive").notNull().default("medium"),
|
|
102
131
|
autonomous: text("autonomous").notNull().default("low"),
|
|
132
|
+
headless: text("headless").notNull().default("none"),
|
|
103
133
|
updatedAt: text("updated_at")
|
|
104
134
|
.notNull()
|
|
105
135
|
.default(sql`(datetime('now'))`),
|
|
@@ -195,6 +225,68 @@ export const trustRules = sqliteTable(
|
|
|
195
225
|
],
|
|
196
226
|
);
|
|
197
227
|
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Guardian verification rate limits
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
export const channelGuardianRateLimits = sqliteTable(
|
|
233
|
+
"channel_guardian_rate_limits",
|
|
234
|
+
{
|
|
235
|
+
id: text("id").primaryKey(),
|
|
236
|
+
channel: text("channel").notNull(),
|
|
237
|
+
actorExternalUserId: text("actor_external_user_id").notNull(),
|
|
238
|
+
actorChatId: text("actor_chat_id").notNull(),
|
|
239
|
+
attemptTimestampsJson: text("attempt_timestamps_json")
|
|
240
|
+
.notNull()
|
|
241
|
+
.default("[]"),
|
|
242
|
+
lockedUntil: integer("locked_until"),
|
|
243
|
+
createdAt: integer("created_at").notNull(),
|
|
244
|
+
updatedAt: integer("updated_at").notNull(),
|
|
245
|
+
},
|
|
246
|
+
(table) => [
|
|
247
|
+
uniqueIndex("idx_gw_channel_guardian_rate_limits_actor").on(
|
|
248
|
+
table.channel,
|
|
249
|
+
table.actorExternalUserId,
|
|
250
|
+
table.actorChatId,
|
|
251
|
+
),
|
|
252
|
+
],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Channel verification sessions (dual-write mirror of assistant table)
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
export const channelVerificationSessions = sqliteTable(
|
|
260
|
+
"channel_verification_sessions",
|
|
261
|
+
{
|
|
262
|
+
id: text("id").primaryKey(),
|
|
263
|
+
channel: text("channel").notNull(),
|
|
264
|
+
challengeHash: text("challenge_hash").notNull(),
|
|
265
|
+
expiresAt: integer("expires_at").notNull(),
|
|
266
|
+
status: text("status").notNull().default("pending"),
|
|
267
|
+
sourceConversationId: text("source_conversation_id"),
|
|
268
|
+
consumedByExternalUserId: text("consumed_by_external_user_id"),
|
|
269
|
+
consumedByChatId: text("consumed_by_chat_id"),
|
|
270
|
+
expectedExternalUserId: text("expected_external_user_id"),
|
|
271
|
+
expectedChatId: text("expected_chat_id"),
|
|
272
|
+
expectedPhoneE164: text("expected_phone_e164"),
|
|
273
|
+
identityBindingStatus: text("identity_binding_status").default("bound"),
|
|
274
|
+
destinationAddress: text("destination_address"),
|
|
275
|
+
lastSentAt: integer("last_sent_at"),
|
|
276
|
+
sendCount: integer("send_count").default(0),
|
|
277
|
+
nextResendAt: integer("next_resend_at"),
|
|
278
|
+
codeDigits: integer("code_digits").default(6),
|
|
279
|
+
maxAttempts: integer("max_attempts").default(3),
|
|
280
|
+
verificationPurpose: text("verification_purpose").default("guardian"),
|
|
281
|
+
bootstrapTokenHash: text("bootstrap_token_hash"),
|
|
282
|
+
createdAt: integer("created_at").notNull(),
|
|
283
|
+
updatedAt: integer("updated_at").notNull(),
|
|
284
|
+
},
|
|
285
|
+
(table) => [
|
|
286
|
+
index("idx_gw_cvs_channel_status").on(table.channel, table.status),
|
|
287
|
+
],
|
|
288
|
+
);
|
|
289
|
+
|
|
198
290
|
// ---------------------------------------------------------------------------
|
|
199
291
|
// Channel denial reply log (rate-limiting outbound denial replies)
|
|
200
292
|
// ---------------------------------------------------------------------------
|