@vellumai/vellum-gateway 0.4.54 → 0.4.56

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 (65) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +8 -8
  3. package/Dockerfile +1 -1
  4. package/README.md +6 -6
  5. package/package.json +1 -1
  6. package/src/__tests__/browser-relay-websocket.test.ts +6 -1
  7. package/src/__tests__/channel-verification-session-proxy.test.ts +6 -1
  8. package/src/__tests__/config-file-cache.test.ts +1 -1
  9. package/src/__tests__/config.test.ts +6 -1
  10. package/src/__tests__/contacts-control-plane-proxy.test.ts +6 -1
  11. package/src/__tests__/credential-watcher.test.ts +78 -27
  12. package/src/__tests__/feature-flags-route.test.ts +15 -44
  13. package/src/__tests__/guardian-init-lockfile.test.ts +199 -0
  14. package/src/__tests__/load-guards.test.ts +6 -1
  15. package/src/__tests__/oauth-callback.test.ts +6 -1
  16. package/src/__tests__/resolve-assistant.test.ts +6 -1
  17. package/src/__tests__/runtime-client.test.ts +45 -1
  18. package/src/__tests__/runtime-health-proxy.test.ts +6 -1
  19. package/src/__tests__/runtime-proxy-auth.test.ts +6 -1
  20. package/src/__tests__/runtime-proxy.test.ts +6 -1
  21. package/src/__tests__/slack-control-plane-proxy.test.ts +6 -1
  22. package/src/__tests__/slack-deliver-ratelimit.test.ts +6 -1
  23. package/src/__tests__/slack-deliver.test.ts +14 -2
  24. package/src/__tests__/slack-display-name.test.ts +6 -1
  25. package/src/__tests__/slack-normalize.test.ts +6 -1
  26. package/src/__tests__/slack-reaction-normalize.test.ts +6 -1
  27. package/src/__tests__/telegram-control-plane-proxy.test.ts +6 -1
  28. package/src/__tests__/telegram-deliver-auth.test.ts +6 -1
  29. package/src/__tests__/telegram-send-attachments.test.ts +22 -3
  30. package/src/__tests__/telegram-webhook-handler.test.ts +41 -1
  31. package/src/__tests__/twilio-relay-websocket.test.ts +6 -1
  32. package/src/__tests__/twilio-webhooks.test.ts +6 -1
  33. package/src/__tests__/whatsapp-download.test.ts +6 -1
  34. package/src/__tests__/whatsapp-webhook.test.ts +6 -1
  35. package/src/auth/subject.ts +7 -7
  36. package/src/auth/token-exchange.ts +1 -1
  37. package/src/auth/types.ts +1 -1
  38. package/src/channels/inbound-event.ts +1 -1
  39. package/src/config-file-cache.ts +1 -1
  40. package/src/config.ts +13 -3
  41. package/src/credential-watcher.ts +8 -35
  42. package/src/feature-flag-registry.json +161 -25
  43. package/src/http/middleware/auth.ts +17 -2
  44. package/src/http/router.ts +11 -3
  45. package/src/http/routes/channel-verification-session-proxy.ts +52 -2
  46. package/src/http/routes/config-file-utils.ts +73 -0
  47. package/src/http/routes/contacts-control-plane-route-match.ts +9 -1
  48. package/src/http/routes/feature-flags.ts +6 -62
  49. package/src/http/routes/privacy-config.ts +101 -0
  50. package/src/http/routes/slack-deliver.ts +6 -2
  51. package/src/http/routes/telegram-deliver.test.ts +6 -1
  52. package/src/http/routes/telegram-webhook.test.ts +6 -1
  53. package/src/http/routes/telegram-webhook.ts +6 -2
  54. package/src/http/routes/twilio-voice-webhook.test.ts +6 -1
  55. package/src/http/routes/whatsapp-deliver.test.ts +6 -1
  56. package/src/http/routes/whatsapp-webhook.test.ts +6 -1
  57. package/src/http/routes/whatsapp-webhook.ts +6 -2
  58. package/src/index.ts +36 -3
  59. package/src/runtime/client.ts +59 -3
  60. package/src/schema.ts +53 -0
  61. package/src/telegram/send.test.ts +6 -1
  62. package/src/telegram/send.ts +8 -2
  63. package/src/whatsapp/download.ts +5 -2
  64. package/src/whatsapp/send.ts +8 -2
  65. package/workspace/config.json +1 -3
package/AGENTS.md CHANGED
@@ -35,7 +35,7 @@ All assistant API requests from clients, CLI, skills, and user-facing tooling **
35
35
 
36
36
  Gateway inbound events use a channel-discriminated union model (`GatewayInboundEvent`) with explicit identity fields:
37
37
 
38
- - **`conversationExternalId`**: Delivery/thread address (e.g., Telegram chat ID, phone number). Used for conversation binding and message routing. **Not** used for trust classification.
38
+ - **`conversationExternalId`**: Delivery/conversation address (e.g., Telegram chat ID, phone number). Used for conversation binding and message routing. **Not** used for trust classification.
39
39
  - **`actorExternalId`**: Sender identity (e.g., Telegram user ID, WhatsApp phone number). Used for trust classification, guardian binding, and ACL enforcement. **Required** for all public channel ingress.
40
40
  - **"conversation"** is canonical vocabulary for delivery addresses. "thread" is reserved for provider-specific fields (Slack `thread_ts`, email thread IDs).
41
41
  - **"actor"** is canonical vocabulary for sender identity.
package/ARCHITECTURE.md CHANGED
@@ -215,13 +215,13 @@ Channel readiness endpoints are exposed directly by the gateway and forwarded to
215
215
 
216
216
  ### Channel Binding Lifecycle (Lane Separation)
217
217
 
218
- Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop thread list, and desktop conversations are never exposed to external channels. The `channelBinding` metadata on a conversation is used solely for routing inbound/outbound messages within that lane and for filtering sessions during desktop session restoration.
218
+ Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop conversation list, and desktop conversations are never exposed to external channels. The `channelBinding` metadata on a conversation is used solely for routing inbound/outbound messages within that lane and for filtering sessions during desktop session restoration.
219
219
 
220
220
  Channel bindings follow a three-phase lifecycle:
221
221
 
222
222
  1. **Bind** — An inbound message from an external channel (e.g., Telegram chat) arrives at the gateway, which normalizes it and forwards it to the runtime's `/v1/channels/inbound` endpoint. The runtime creates or reuses a conversation, establishing the channel binding (`sourceChannel` metadata on the conversation).
223
223
 
224
- 2. **Route** — Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound sessions during session restoration (`ThreadSessionRestorer`) so they never appear in the desktop thread list.
224
+ 2. **Route** — Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound sessions during session restoration (`ConversationRestorer`) so they never appear in the desktop conversation list.
225
225
 
226
226
  3. **Rebind** — If a message arrives on an external chat whose conversation was previously deleted, the channel inbound handler treats it as a new conversation and establishes a fresh binding. The external chat ID is reused, but the conversation is new.
227
227
 
@@ -741,7 +741,7 @@ sequenceDiagram
741
741
  alt ASK_GUARDIAN pattern detected
742
742
  Ctrl->>CallStore: createPendingQuestion()
743
743
  Ctrl->>GuardianDispatch: dispatchGuardianQuestion()
744
- GuardianDispatch->>Mac: notification_thread_created SSE
744
+ GuardianDispatch->>Mac: notification_conversation_created SSE
745
745
  GuardianDispatch->>TG: POST /deliver/{channel}
746
746
  Note over Mac,TG: First channel to respond wins
747
747
  Mac/TG->>Routes: guardian answer
@@ -923,9 +923,9 @@ When the LLM emits `[ASK_GUARDIAN: question]` during a voice call, the controlle
923
923
  1. **Request creation**: A `guardian_action_request` row is created with a unique 6-character hex request code, the question text, a `pending` status, and an expiry timestamp.
924
924
 
925
925
  2. **Delivery fan-out via notification pipeline**: The guardian dispatch calls `emitNotificationSignal()` and uses the same notification decision + broadcaster path as every other producer.
926
- - **Vellum**: Conversation pairing happens in the notification broadcaster. The resulting `notification_thread_created` event surfaces the thread in the desktop UI.
926
+ - **Vellum**: Conversation pairing happens in the notification broadcaster. The resulting `notification_conversation_created` event surfaces the conversation in the desktop UI.
927
927
  - **Telegram**: Delivery is handled by channel adapters selected by the notification decision and guarded by configured bindings.
928
- - Guardian dispatch records `guardian_action_deliveries` from pipeline delivery results. It also uses the per-dispatch `onThreadCreated` callback so vellum delivery rows are created as soon as thread pairing occurs (without waiting for slower channels).
928
+ - Guardian dispatch records `guardian_action_deliveries` from pipeline delivery results. It also uses the per-dispatch `onConversationCreated` callback so vellum delivery rows are created as soon as conversation pairing occurs (without waiting for slower channels).
929
929
 
930
930
  3. **Answer resolution**: The first channel to respond wins. Answer resolution uses an atomic `WHERE status = 'pending'` check on the `guardian_action_requests` table -- only the first writer succeeds in transitioning the request to `answered` status. The winning answer text and responding channel are recorded on the request row.
931
931
 
@@ -939,7 +939,7 @@ When the LLM emits `[ASK_GUARDIAN: question]` during a voice call, the controlle
939
939
 
940
940
  #### macOS Notification + Deep-Link Flow
941
941
 
942
- When a guardian question is dispatched while the macOS app is backgrounded, the Swift client posts a native `UNUserNotificationCenter` notification from the generic `notification_intent` payload (`NOTIFICATION_INTENT` category). The deep-link metadata includes the paired conversation ID, so tapping the notification routes directly to the guardian thread.
942
+ When a guardian question is dispatched while the macOS app is backgrounded, the Swift client posts a native `UNUserNotificationCenter` notification from the generic `notification_intent` payload (`NOTIFICATION_INTENT` category). The deep-link metadata includes the paired conversation ID, so tapping the notification routes directly to the guardian conversation.
943
943
 
944
944
  ### SQLite Tables
945
945
 
@@ -994,8 +994,8 @@ This makes ingress URL updates smoother in local tunnel workflows because Twilio
994
994
  | POST | `/v1/internal/twilio/voice-webhook` | Internal voice webhook used by gateway; accepts JSON `{ params, originalUrl, assistantId? }`, creates inbound session or returns TwiML |
995
995
  | GET | `/v1/calls/:callSessionId` | Get call status, including any pending question |
996
996
  | POST | `/v1/calls/:callSessionId/cancel` | Cancel an active call |
997
- | POST | `/v1/calls/:callSessionId/answer` | Answer a pending question via HTTP (alternative to in-thread bridge) |
998
- | POST | `/v1/calls/:callSessionId/instruction` | Relay a steering instruction to an active call's controller (alternative to in-thread bridge) |
997
+ | POST | `/v1/calls/:callSessionId/answer` | Answer a pending question via HTTP (alternative to in-conversation bridge) |
998
+ | POST | `/v1/calls/:callSessionId/instruction` | Relay a steering instruction to an active call's controller (alternative to in-conversation bridge) |
999
999
  | POST | `/v1/internal/twilio/status` | Internal status callback used by gateway; accepts JSON `{ params }` |
1000
1000
  | POST | `/v1/internal/twilio/connect-action` | Internal connect action callback used by gateway; accepts JSON `{ params }` |
1001
1001
  | WS | `/v1/calls/relay` | ConversationRelay WebSocket (bidirectional: prompt/interrupt/dtmf from Twilio, text tokens/end to Twilio) |
package/Dockerfile CHANGED
@@ -18,7 +18,7 @@ FROM debian:trixie-slim@sha256:1d3c811171a08a5adaa4a163fbafd96b61b87aa871bbc7aa1
18
18
 
19
19
  WORKDIR /app
20
20
 
21
- RUN apt-get update && apt-get install -y \
21
+ RUN apt-get update && apt-get upgrade -y && apt-get install -y \
22
22
  ca-certificates \
23
23
  && rm -rf /var/lib/apt/lists/*
24
24
 
package/README.md CHANGED
@@ -26,11 +26,11 @@ bun run dev
26
26
 
27
27
  ## Configuration
28
28
 
29
- | Variable | Required | Default | Description |
30
- | ------------------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
- | `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset). When not set as an env var, the gateway reads from the assistant's secure credential store: keychain broker first (UDS to the assistant daemon), then the encrypted file store (`~/.vellum/protected/keys.enc`). When the broker is unavailable (daemon not running or non-macOS), the encrypted store is used directly. |
32
- | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
33
- | `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
29
+ | Variable | Required | Default | Description |
30
+ | ------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset). When not set as an env var, the gateway reads from the assistant's secure credential store: keychain broker first (UDS to the assistant process), then the encrypted file store (`~/.vellum/protected/keys.enc`). When the broker is unavailable (assistant not running or non-macOS), the encrypted store is used directly. |
32
+ | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
33
+ | `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
34
34
 
35
35
  Most gateway behavior is now configured via hardcoded defaults or workspace config (`~/.vellum/workspace/config.json`) rather than environment variables. Channel operational settings (Telegram API base URL, timeouts, deliver auth bypass flags, runtime base URL, routing, proxy settings, attachment limits, shutdown drain) are managed via `workspace/config.json` through `ConfigFileCache`. See the channel-specific sections in `ARCHITECTURE.md` for details.
36
36
 
@@ -185,7 +185,7 @@ Use this flow when you are changing files under `gateway/` and need to validate
185
185
  ```bash
186
186
  # Terminal 1: restart assistant runtime HTTP server
187
187
  cd assistant
188
- bun run daemon:restart:http
188
+ bun run assistant:restart:http
189
189
 
190
190
  # Terminal 2: run gateway from local source with runtime proxy enabled
191
191
  cd gateway
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.4.54",
3
+ "version": "0.4.56",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -41,7 +41,12 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
41
41
  runtimeInitialBackoffMs: 500,
42
42
  maxWebhookPayloadBytes: 1048576,
43
43
  logFile: { dir: undefined, retentionDays: 30 },
44
- maxAttachmentBytes: 20971520,
44
+ maxAttachmentBytes: {
45
+ telegram: 50 * 1024 * 1024,
46
+ slack: 100 * 1024 * 1024,
47
+ whatsapp: 16 * 1024 * 1024,
48
+ default: 50 * 1024 * 1024,
49
+ },
45
50
  maxAttachmentConcurrency: 3,
46
51
  gatewayInternalBaseUrl: "http://127.0.0.1:7830",
47
52
  trustProxy: false,
@@ -35,7 +35,12 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
35
35
  runtimeInitialBackoffMs: 500,
36
36
  maxWebhookPayloadBytes: 1048576,
37
37
  logFile: { dir: undefined, retentionDays: 30 },
38
- maxAttachmentBytes: 20971520,
38
+ maxAttachmentBytes: {
39
+ telegram: 50 * 1024 * 1024,
40
+ slack: 100 * 1024 * 1024,
41
+ whatsapp: 16 * 1024 * 1024,
42
+ default: 50 * 1024 * 1024,
43
+ },
39
44
  maxAttachmentConcurrency: 3,
40
45
  gatewayInternalBaseUrl: "http://127.0.0.1:7830",
41
46
  trustProxy: false,
@@ -44,7 +44,7 @@ describe("ConfigFileCache: getString", () => {
44
44
  expect(cache.getString("email", "address")).toBe("a@b.com");
45
45
  });
46
46
 
47
- test("returns undefined for empty string", () => {
47
+ test("returns undefined for empty string value", () => {
48
48
  writeConfig({ email: { address: "" } });
49
49
  const cache = new ConfigFileCache();
50
50
  expect(cache.getString("email", "address")).toBeUndefined();
@@ -9,7 +9,12 @@ describe("config: hardcoded defaults", () => {
9
9
  expect(config.runtimeMaxRetries).toBe(2);
10
10
  expect(config.runtimeInitialBackoffMs).toBe(500);
11
11
  expect(config.maxWebhookPayloadBytes).toBe(1024 * 1024);
12
- expect(config.maxAttachmentBytes).toBe(20 * 1024 * 1024);
12
+ expect(config.maxAttachmentBytes).toEqual({
13
+ telegram: 20 * 1024 * 1024,
14
+ slack: 100 * 1024 * 1024,
15
+ whatsapp: 16 * 1024 * 1024,
16
+ default: 50 * 1024 * 1024,
17
+ });
13
18
  expect(config.maxAttachmentConcurrency).toBe(3);
14
19
  expect(config.runtimeProxyEnabled).toBe(false);
15
20
  expect(config.runtimeProxyRequireAuth).toBe(true);
@@ -35,7 +35,12 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
35
35
  runtimeInitialBackoffMs: 500,
36
36
  maxWebhookPayloadBytes: 1048576,
37
37
  logFile: { dir: undefined, retentionDays: 30 },
38
- maxAttachmentBytes: 20971520,
38
+ maxAttachmentBytes: {
39
+ telegram: 50 * 1024 * 1024,
40
+ slack: 100 * 1024 * 1024,
41
+ whatsapp: 16 * 1024 * 1024,
42
+ default: 50 * 1024 * 1024,
43
+ },
39
44
  maxAttachmentConcurrency: 3,
40
45
  gatewayInternalBaseUrl: "http://127.0.0.1:7830",
41
46
  trustProxy: false,
@@ -14,7 +14,7 @@ import {
14
14
  pbkdf2Sync,
15
15
  randomBytes as cryptoRandomBytes,
16
16
  } from "node:crypto";
17
- import { mkdirSync, writeFileSync, rmSync } from "node:fs";
17
+ import { mkdirSync, renameSync, writeFileSync, rmSync } from "node:fs";
18
18
  import { hostname, tmpdir, userInfo } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
@@ -114,39 +114,47 @@ function writeEncryptedStore(botToken: string, webhookSecret: string): void {
114
114
  writeFileSync(storePath, JSON.stringify(store));
115
115
  }
116
116
 
117
+ function metadataRecord(
118
+ credentialId: string,
119
+ service: string,
120
+ field: string,
121
+ ): Record<string, unknown> {
122
+ return {
123
+ credentialId,
124
+ service,
125
+ field,
126
+ allowedTools: [],
127
+ allowedDomains: [],
128
+ createdAt: Date.now(),
129
+ updatedAt: Date.now(),
130
+ };
131
+ }
132
+
117
133
  /**
118
- * Write credential metadata so readTelegramCredentials() knows to look
119
- * for bot_token and webhook_secret.
134
+ * Write credential metadata using the same atomic rename pattern as the
135
+ * production metadata store.
120
136
  */
121
- function writeCredentialMetadata(): void {
137
+ function writeCredentialMetadata(
138
+ credentials: Record<string, unknown>[] = [
139
+ metadataRecord("test-bt", "telegram", "bot_token"),
140
+ metadataRecord("test-ws", "telegram", "webhook_secret"),
141
+ ],
142
+ ): void {
122
143
  const dir = join(testDir, ".vellum", "workspace", "data", "credentials");
123
144
  mkdirSync(dir, { recursive: true });
145
+ const metadataPath = join(dir, "metadata.json");
146
+ const tmpPath = join(
147
+ dir,
148
+ `.tmp-${cryptoRandomBytes(4).toString("hex")}-metadata.json`,
149
+ );
124
150
  writeFileSync(
125
- join(dir, "metadata.json"),
151
+ tmpPath,
126
152
  JSON.stringify({
127
153
  version: 2,
128
- credentials: [
129
- {
130
- credentialId: "test-bt",
131
- service: "telegram",
132
- field: "bot_token",
133
- allowedTools: [],
134
- allowedDomains: [],
135
- createdAt: Date.now(),
136
- updatedAt: Date.now(),
137
- },
138
- {
139
- credentialId: "test-ws",
140
- service: "telegram",
141
- field: "webhook_secret",
142
- allowedTools: [],
143
- allowedDomains: [],
144
- createdAt: Date.now(),
145
- updatedAt: Date.now(),
146
- },
147
- ],
154
+ credentials,
148
155
  }),
149
156
  );
157
+ renameSync(tmpPath, metadataPath);
150
158
  }
151
159
 
152
160
  // ---------------------------------------------------------------------------
@@ -157,11 +165,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
157
165
  const gatewayRoot = join(__dirname, "..", "..");
158
166
  const gatewayEntry = join(gatewayRoot, "src", "index.ts");
159
167
 
160
- const port = 49152 + Math.floor(Math.random() * 16383);
161
-
162
168
  let gatewayProc: ChildProcess | null = null;
169
+ let port = 0;
163
170
 
164
171
  async function startGateway(): Promise<void> {
172
+ port = 49152 + Math.floor(Math.random() * 16383);
165
173
  gatewayProc = spawn("bun", ["run", gatewayEntry], {
166
174
  env: {
167
175
  ...process.env,
@@ -238,4 +246,47 @@ describe("gateway telegram hot-reload (e2e)", () => {
238
246
  });
239
247
  expect(after.status).toBe(401);
240
248
  }, 15_000);
249
+
250
+ test("gateway keeps reloading credentials after multiple atomic metadata rewrites when metadata.json already existed at startup", async () => {
251
+ mkdirSync(testDir, { recursive: true });
252
+
253
+ // Start in file-watch mode by creating metadata.json before boot, but
254
+ // omit Telegram entries so the integration is initially unconfigured.
255
+ writeCredentialMetadata([metadataRecord("baseline", "github", "token")]);
256
+ writeEncryptedStore("fake-bot-token:ABC123", "fake-webhook-secret");
257
+
258
+ await startGateway();
259
+
260
+ const base = `http://localhost:${port}`;
261
+
262
+ const before = await fetch(`${base}/webhooks/telegram`, {
263
+ method: "POST",
264
+ });
265
+ expect(before.status).toBe(503);
266
+
267
+ // First rewrite after startup stales a file-scoped fs.watch() subscription
268
+ // on macOS when metadata.json is atomically replaced.
269
+ writeCredentialMetadata([
270
+ metadataRecord("baseline", "github", "token"),
271
+ metadataRecord("other", "openai", "api_key"),
272
+ ]);
273
+
274
+ await new Promise((resolve) => setTimeout(resolve, 1200));
275
+
276
+ // Second rewrite adds Telegram credentials. The gateway must still see
277
+ // this update without requiring a restart.
278
+ writeCredentialMetadata([
279
+ metadataRecord("baseline", "github", "token"),
280
+ metadataRecord("other", "openai", "api_key"),
281
+ metadataRecord("test-bt", "telegram", "bot_token"),
282
+ metadataRecord("test-ws", "telegram", "webhook_secret"),
283
+ ]);
284
+
285
+ await new Promise((resolve) => setTimeout(resolve, 2000));
286
+
287
+ const after = await fetch(`${base}/webhooks/telegram`, {
288
+ method: "POST",
289
+ });
290
+ expect(after.status).toBe(401);
291
+ }, 15_000);
241
292
  });
@@ -35,20 +35,12 @@ const TEST_REGISTRY = {
35
35
  defaultEnabled: true,
36
36
  },
37
37
  {
38
- id: "guardian-verify-setup",
38
+ id: "contacts",
39
39
  scope: "assistant",
40
- key: "feature_flags.guardian-verify-setup.enabled",
41
- label: "Guardian Verification Setup",
42
- description: "Guardian verification setup",
43
- defaultEnabled: true,
44
- },
45
- {
46
- id: "hatch-new-assistant",
47
- scope: "assistant",
48
- key: "feature_flags.hatch-new-assistant.enabled",
49
- label: "Hatch New Assistant",
50
- description: "Hatch new assistant",
51
- defaultEnabled: true,
40
+ key: "feature_flags.contacts.enabled",
41
+ label: "Contacts",
42
+ description: "Contacts management",
43
+ defaultEnabled: false,
52
44
  },
53
45
  {
54
46
  id: "user-hosted-enabled",
@@ -160,13 +152,6 @@ describe("GET /v1/feature-flags handler", () => {
160
152
  );
161
153
  expect(browserFlag2).toBeDefined();
162
154
  expect(browserFlag2.label).toBe("Browser");
163
-
164
- const hatchFlag = body.flags.find(
165
- (f: { key: string }) =>
166
- f.key === "feature_flags.hatch-new-assistant.enabled",
167
- );
168
- expect(hatchFlag).toBeDefined();
169
- expect(hatchFlag.label).toBe("Hatch New Assistant");
170
155
  });
171
156
 
172
157
  test("does not include non-assistant-scope flags", async () => {
@@ -210,7 +195,6 @@ describe("GET /v1/feature-flags handler", () => {
210
195
  JSON.stringify({
211
196
  assistantFeatureFlagValues: {
212
197
  "feature_flags.browser.enabled": false,
213
- "feature_flags.guardian-verify-setup.enabled": false,
214
198
  },
215
199
  }),
216
200
  );
@@ -229,13 +213,6 @@ describe("GET /v1/feature-flags handler", () => {
229
213
  expect(browserFlag).toBeDefined();
230
214
  expect(browserFlag.enabled).toBe(false); // overridden from default true
231
215
  expect(browserFlag.defaultEnabled).toBe(true);
232
-
233
- const guardianFlag = body.flags.find(
234
- (f: { key: string }) =>
235
- f.key === "feature_flags.guardian-verify-setup.enabled",
236
- );
237
- expect(guardianFlag).toBeDefined();
238
- expect(guardianFlag.enabled).toBe(false); // overridden from default true
239
216
  });
240
217
 
241
218
  test("ignores non-boolean values in assistantFeatureFlagValues", async () => {
@@ -303,7 +280,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
303
280
  twilio: { phoneNumber: "+1234567890" },
304
281
  email: { address: "test@example.com" },
305
282
  assistantFeatureFlagValues: {
306
- "feature_flags.guardian-verify-setup.enabled": true,
283
+ "feature_flags.contacts.enabled": true,
307
284
  },
308
285
  }),
309
286
  );
@@ -326,9 +303,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
326
303
  expect(config.email).toEqual({ address: "test@example.com" });
327
304
  // New section should have both old and new values
328
305
  expect(
329
- config.assistantFeatureFlagValues[
330
- "feature_flags.guardian-verify-setup.enabled"
331
- ],
306
+ config.assistantFeatureFlagValues["feature_flags.contacts.enabled"],
332
307
  ).toBe(true);
333
308
  expect(
334
309
  config.assistantFeatureFlagValues["feature_flags.browser.enabled"],
@@ -383,7 +358,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
383
358
 
384
359
  const oldFormatKeys = [
385
360
  "skills.browser.enabled",
386
- "skills.guardian-verify-setup.enabled",
361
+ "skills.contacts.enabled",
387
362
  "skills.my-skill.enabled",
388
363
  ];
389
364
 
@@ -459,8 +434,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
459
434
 
460
435
  const validKeys = [
461
436
  "feature_flags.browser.enabled",
462
- "feature_flags.guardian-verify-setup.enabled",
463
- "feature_flags.hatch-new-assistant.enabled",
437
+ "feature_flags.contacts.enabled",
464
438
  ];
465
439
 
466
440
  for (const key of validKeys) {
@@ -538,21 +512,21 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
538
512
  // Write initial config
539
513
  const initial = {
540
514
  twilio: { phoneNumber: "+1234" },
541
- assistantFeatureFlagValues: { "feature_flags.browser.enabled": true },
515
+ assistantFeatureFlagValues: { "feature_flags.contacts.enabled": true },
542
516
  };
543
517
  writeFileSync(configPath, JSON.stringify(initial));
544
518
 
545
519
  const handler = createFeatureFlagsPatchHandler();
546
520
  await handler(
547
521
  new Request(
548
- "http://gateway.test/v1/feature-flags/feature_flags.guardian-verify-setup.enabled",
522
+ "http://gateway.test/v1/feature-flags/feature_flags.browser.enabled",
549
523
  {
550
524
  method: "PATCH",
551
525
  headers: { "content-type": "application/json" },
552
526
  body: JSON.stringify({ enabled: false }),
553
527
  },
554
528
  ),
555
- "feature_flags.guardian-verify-setup.enabled",
529
+ "feature_flags.browser.enabled",
556
530
  );
557
531
 
558
532
  // Verify the file is valid JSON and contains all expected data
@@ -560,12 +534,10 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
560
534
  const config = JSON.parse(raw);
561
535
  expect(config.twilio).toMatchObject({ phoneNumber: "+1234" });
562
536
  expect(
563
- config.assistantFeatureFlagValues["feature_flags.browser.enabled"],
537
+ config.assistantFeatureFlagValues["feature_flags.contacts.enabled"],
564
538
  ).toBe(true);
565
539
  expect(
566
- config.assistantFeatureFlagValues[
567
- "feature_flags.guardian-verify-setup.enabled"
568
- ],
540
+ config.assistantFeatureFlagValues["feature_flags.browser.enabled"],
569
541
  ).toBe(false);
570
542
 
571
543
  // Verify no temp files left behind
@@ -582,8 +554,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
582
554
  // Fire multiple concurrent PATCH requests at the same time
583
555
  const flagKeys = [
584
556
  "feature_flags.browser.enabled",
585
- "feature_flags.guardian-verify-setup.enabled",
586
- "feature_flags.hatch-new-assistant.enabled",
557
+ "feature_flags.contacts.enabled",
587
558
  ];
588
559
 
589
560
  const results = await Promise.all(