@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
@@ -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, readFileSync, watch, type FSWatcher } from "node:fs";
7
- import { dirname, join } from "node:path";
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 = readConfigFile(this.configPath);
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,
@@ -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 { join } from "node:path";
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
- migrateLegacyDb(dbPath);
168
+ if (process.env.NODE_ENV !== "test") {
169
+ migrateLegacyDb(dbPath);
170
+ }
109
171
  return dbPath;
110
172
  }
111
173
 
@@ -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
  // ---------------------------------------------------------------------------