@vellumai/vellum-gateway 0.7.2 → 0.7.3

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 (38) hide show
  1. package/ARCHITECTURE.md +20 -21
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/src/__tests__/config-file-watcher.test.ts +1 -1
  5. package/src/__tests__/contact-prompt-submit.test.ts +349 -0
  6. package/src/__tests__/ipc-route-policy.test.ts +24 -0
  7. package/src/__tests__/nonbash-trust-rule-overrides.test.ts +50 -0
  8. package/src/__tests__/remote-feature-flag-sync.test.ts +16 -14
  9. package/src/__tests__/slack-display-name.test.ts +6 -2
  10. package/src/__tests__/slack-normalize.test.ts +36 -56
  11. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +4 -2
  12. package/src/__tests__/telegram-webhook-manager.test.ts +8 -25
  13. package/src/__tests__/twilio-webhooks.test.ts +2 -6
  14. package/src/__tests__/upsert-verified-contact-channel.test.ts +173 -0
  15. package/src/auth/guardian-bootstrap.ts +49 -0
  16. package/src/auth/ipc-route-policy.ts +5 -0
  17. package/src/db/contact-store.ts +27 -1
  18. package/src/email/register-callback.test.ts +4 -4
  19. package/src/email/register-callback.ts +12 -16
  20. package/src/feature-flag-registry.json +27 -3
  21. package/src/handlers/handle-inbound.ts +12 -0
  22. package/src/http/routes/contact-prompt.ts +134 -23
  23. package/src/http/routes/contacts-control-plane-proxy.ts +34 -5
  24. package/src/http/routes/ipc-runtime-proxy.ts +18 -0
  25. package/src/http/routes/twilio-voice-webhook.test.ts +22 -1
  26. package/src/http/routes/twilio-voice-webhook.ts +53 -0
  27. package/src/index.ts +4 -2
  28. package/src/ipc/velay-handlers.ts +31 -0
  29. package/src/remote-feature-flag-sync.ts +10 -8
  30. package/src/risk/command-registry/commands/assistant.ts +1 -0
  31. package/src/risk/skill-risk-classifier.ts +12 -3
  32. package/src/runtime/client.ts +25 -12
  33. package/src/slack/normalize.test.ts +3 -3
  34. package/src/slack/normalize.ts +6 -69
  35. package/src/slack/socket-mode.ts +1 -5
  36. package/src/telegram/webhook-manager.ts +9 -13
  37. package/src/velay/client.ts +27 -16
  38. package/src/verification/contact-helpers.ts +6 -3
@@ -22,6 +22,8 @@ import {
22
22
  contacts as gwContacts,
23
23
  contactChannels as gwContactChannels,
24
24
  } from "../db/schema.js";
25
+ import { readCredential } from "../credential-reader.js";
26
+ import { credentialKey } from "../credential-key.js";
25
27
  import { getLogger } from "../logger.js";
26
28
 
27
29
  import { CURRENT_POLICY_EPOCH } from "./policy.js";
@@ -480,6 +482,51 @@ function mintRefreshToken(
480
482
  // Public: guardian bootstrap
481
483
  // ---------------------------------------------------------------------------
482
484
 
485
+ /**
486
+ * Attempt to fetch the assistant owner's display name from the platform.
487
+ *
488
+ * Only runs when IS_PLATFORM=true. Reads platform_base_url and
489
+ * assistant_api_key from the credential store, then calls
490
+ * GET /v1/internal/gateway/guardian/ with a 5-second timeout.
491
+ * Returns null on any missing credential, timeout, or network/parse failure —
492
+ * callers fall back to a generated principal ID in that case.
493
+ */
494
+ async function fetchPlatformOwnerDisplayName(): Promise<string | null> {
495
+ const isPlatform =
496
+ process.env.IS_PLATFORM?.trim().toLowerCase() === "true" ||
497
+ process.env.IS_PLATFORM?.trim() === "1";
498
+ if (!isPlatform) return null;
499
+
500
+ const [platformBaseUrl, assistantApiKey] = await Promise.all([
501
+ readCredential(credentialKey("vellum", "platform_base_url")),
502
+ readCredential(credentialKey("vellum", "assistant_api_key")),
503
+ ]);
504
+
505
+ if (!platformBaseUrl || !assistantApiKey) {
506
+ return null;
507
+ }
508
+
509
+ try {
510
+ const url = `${platformBaseUrl.replace(/\/+$/, "")}/v1/internal/gateway/guardian/`;
511
+ const response = await fetch(url, {
512
+ headers: { Authorization: `Api-Key ${assistantApiKey}` },
513
+ signal: AbortSignal.timeout(5_000),
514
+ });
515
+ if (!response.ok) {
516
+ log.warn(
517
+ { status: response.status },
518
+ "Failed to fetch platform owner display name",
519
+ );
520
+ return null;
521
+ }
522
+ const data = (await response.json()) as { display_name?: string | null };
523
+ return data.display_name?.trim() || null;
524
+ } catch (err) {
525
+ log.warn({ err }, "Failed to fetch platform owner display name");
526
+ return null;
527
+ }
528
+ }
529
+
483
530
  /**
484
531
  * Ensure a vellum guardian binding exists. If one already exists, returns
485
532
  * its principalId. Otherwise creates a new binding with a fresh principal
@@ -497,6 +544,7 @@ export async function ensureVellumGuardianBinding(): Promise<string> {
497
544
  return existing.principalId;
498
545
  }
499
546
 
547
+ const displayName = await fetchPlatformOwnerDisplayName();
500
548
  const guardianPrincipalId = `vellum-principal-${uuid()}`;
501
549
  await createGuardianBinding({
502
550
  channel: "vellum",
@@ -504,6 +552,7 @@ export async function ensureVellumGuardianBinding(): Promise<string> {
504
552
  deliveryChatId: "local",
505
553
  guardianPrincipalId,
506
554
  verifiedVia: "bootstrap",
555
+ ...(displayName ? { displayName } : {}),
507
556
  });
508
557
  return guardianPrincipalId;
509
558
  }
@@ -49,6 +49,11 @@ type PolicyEntry =
49
49
  const POLICY_TABLE: PolicyEntry[] = [
50
50
  // Admin / internal
51
51
  ["admin_rollbackmigrations_post", ["internal.write"], ["svc_gateway"]],
52
+ ["internal_mcp_auth_start", ["internal.write"], ["svc_gateway"]],
53
+ ["internal_mcp_auth_status", ["internal.write"], ["svc_gateway"]],
54
+ ["internal_mcp_reload", ["internal.write"], ["svc_gateway"]],
55
+ ["internal_oauth_connect_start", ["internal.write"], ["svc_gateway"]],
56
+ ["internal_oauth_connect_status", ["internal.write"], ["svc_gateway"]],
52
57
 
53
58
  // Calls
54
59
  ["calls_answer", ["calls.write"]],
@@ -1,4 +1,4 @@
1
- import { desc, eq, and, sql } from "drizzle-orm";
1
+ import { desc, eq, and, ne, or, sql } from "drizzle-orm";
2
2
  import { type GatewayDb, getGatewayDb } from "./connection.js";
3
3
  import { contacts, contactChannels } from "./schema.js";
4
4
 
@@ -66,6 +66,32 @@ export class ContactStore {
66
66
  .all();
67
67
  }
68
68
 
69
+ /**
70
+ * Looks up a non-revoked phone channel whose externalUserId or address
71
+ * matches the given phone number. Used to detect callers whose number is
72
+ * registered but not yet verified via DTMF challenge.
73
+ */
74
+ getContactByPhoneNumber(
75
+ phoneNumber: string,
76
+ ): { contact: Contact; channel: ContactChannel } | undefined {
77
+ return this.db
78
+ .select({ contact: contacts, channel: contactChannels })
79
+ .from(contacts)
80
+ .innerJoin(contactChannels, eq(contactChannels.contactId, contacts.id))
81
+ .where(
82
+ and(
83
+ eq(contactChannels.type, "phone"),
84
+ ne(contactChannels.status, "revoked"),
85
+ or(
86
+ eq(contactChannels.externalUserId, phoneNumber),
87
+ eq(contactChannels.address, phoneNumber),
88
+ ),
89
+ ),
90
+ )
91
+ .limit(1)
92
+ .get();
93
+ }
94
+
69
95
  /**
70
96
  * Set lastSeenAt to now for a channel (gateway DB only).
71
97
  */
@@ -15,7 +15,7 @@ import {
15
15
  afterEach(() => {
16
16
  resetMockFetch();
17
17
  delete process.env.VELLUM_PLATFORM_URL;
18
- delete process.env.PLATFORM_INTERNAL_API_KEY;
18
+ delete process.env.ASSISTANT_API_KEY;
19
19
  });
20
20
 
21
21
  function makeConfigFile(
@@ -118,9 +118,9 @@ describe("registerEmailCallbackRoute", () => {
118
118
  });
119
119
  });
120
120
 
121
- test("uses credential cache for assistant ID", async () => {
121
+ test("uses env assistant key with credential cache for assistant ID", async () => {
122
122
  process.env.VELLUM_PLATFORM_URL = "https://env-platform.example.com";
123
- process.env.PLATFORM_INTERNAL_API_KEY = "internal-key";
123
+ process.env.ASSISTANT_API_KEY = "env-key";
124
124
 
125
125
  const callbackUrl =
126
126
  "https://env-platform.example.com/v1/gateway/callbacks/11111111-2222-3333-4444-555555555555/webhooks/email/";
@@ -145,7 +145,7 @@ describe("registerEmailCallbackRoute", () => {
145
145
  const calls = getMockFetchCalls();
146
146
  expect(calls).toHaveLength(1);
147
147
  const headers = calls[0].init.headers as Record<string, string>;
148
- expect(headers?.["Authorization"]).toBe("Bearer internal-key");
148
+ expect(headers?.["Authorization"]).toBe("Api-Key env-key");
149
149
  });
150
150
 
151
151
  test("throws on non-ok response", async () => {
@@ -22,9 +22,9 @@ interface PlatformCallbackRouteResponse {
22
22
  * webhooks are forwarded to this gateway instance.
23
23
  *
24
24
  * Follows the same pattern as Telegram's managed callback route registration
25
- * in `telegram/webhook-manager.ts`. Requires platform credentials (base URL,
26
- * API key, assistant ID) either from the credential cache or environment
27
- * variables.
25
+ * in `telegram/webhook-manager.ts`. Requires platform credentials (base URL,
26
+ * assistant API key, assistant ID). The base URL can fall back to
27
+ * `VELLUM_PLATFORM_URL`; the key can fall back to `ASSISTANT_API_KEY`.
28
28
  *
29
29
  * Self-hosted assistants with a configured ``ingress.publicBaseUrl`` send
30
30
  * their own base URL so the callback route points directly at the gateway
@@ -37,7 +37,6 @@ export async function registerEmailCallbackRoute(caches?: {
37
37
  credentials?: CredentialCache;
38
38
  configFile?: ConfigFileCache;
39
39
  }): Promise<string | undefined> {
40
- // Read from credential cache when available
41
40
  const [platformBaseUrlRaw, assistantApiKeyRaw, assistantIdRaw] =
42
41
  caches?.credentials
43
42
  ? await Promise.all([
@@ -49,29 +48,26 @@ export async function registerEmailCallbackRoute(caches?: {
49
48
  ])
50
49
  : [undefined, undefined, undefined];
51
50
 
52
- // Fall back to env vars when credential cache values are missing, matching
53
- // the daemon's resolvePlatformCallbackRegistrationContext() behaviour.
51
+ // Fall back to env vars when managed pod credentials are not yet cached,
52
+ // matching the daemon's resolvePlatformCallbackRegistrationContext().
54
53
  const platformBaseUrl = (
55
54
  platformBaseUrlRaw?.trim() ||
56
55
  process.env.VELLUM_PLATFORM_URL?.trim() ||
57
56
  ""
58
57
  ).replace(/\/+$/, "");
59
58
 
60
- const platformInternalApiKey =
61
- process.env.PLATFORM_INTERNAL_API_KEY?.trim() || undefined;
62
- const assistantApiKey = !platformInternalApiKey
63
- ? assistantApiKeyRaw?.trim() || undefined
64
- : undefined;
65
- const authToken = platformInternalApiKey || assistantApiKey;
66
- const authScheme = platformInternalApiKey ? "Bearer" : "Api-Key";
59
+ const assistantCredential =
60
+ assistantApiKeyRaw?.trim() ||
61
+ process.env.ASSISTANT_API_KEY?.trim() ||
62
+ undefined;
67
63
 
68
64
  const assistantId = assistantIdRaw?.trim() || undefined;
69
65
 
70
- if (!platformBaseUrl || !authToken || !assistantId) {
66
+ if (!platformBaseUrl || !assistantCredential || !assistantId) {
71
67
  log.debug(
72
68
  {
73
69
  hasPlatformBaseUrl: !!platformBaseUrl,
74
- hasApiKey: !!authToken,
70
+ hasApiKey: !!assistantCredential,
75
71
  hasAssistantId: !!assistantId,
76
72
  },
77
73
  "Email callback route registration unavailable — missing credentials",
@@ -109,7 +105,7 @@ export async function registerEmailCallbackRoute(caches?: {
109
105
  {
110
106
  method: "POST",
111
107
  headers: {
112
- Authorization: `${authScheme} ${authToken}`,
108
+ Authorization: `Api-Key ${assistantCredential}`,
113
109
  "Content-Type": "application/json",
114
110
  },
115
111
  body: JSON.stringify(requestBody),
@@ -241,13 +241,21 @@
241
241
  "description": "Expose the developer-only Compaction Playground tab in macOS Settings and enable the /playground/* HTTP endpoints for exercising compaction conditions. Dev-only; default off.",
242
242
  "defaultEnabled": false
243
243
  },
244
+ {
245
+ "id": "safe-storage-limits",
246
+ "scope": "assistant",
247
+ "key": "safe-storage-limits",
248
+ "label": "Safe Storage Limits",
249
+ "description": "Enable disk pressure protection flows that block background work and remote actors while storage is critically low.",
250
+ "defaultEnabled": false
251
+ },
244
252
  {
245
253
  "id": "memory-v2-enabled",
246
254
  "scope": "assistant",
247
255
  "key": "memory-v2-enabled",
248
256
  "label": "Memory v2 (concept-page activation model)",
249
- "description": "Enables the v2 memory subsystem: prose concept pages with bidirectional edges, activation-based retrieval, and hourly LLM-driven consolidation. v1 graph + PKB stays write-active until cutover.",
250
- "defaultEnabled": false
257
+ "description": "Enables the v2 memory subsystem: prose concept pages with bidirectional edges, activation-based retrieval, and hourly LLM-driven consolidation. When on, v1 graph extraction/maintenance and PKB filing are suppressed; flipping the flag back off re-engages the full v1 pipeline.",
258
+ "defaultEnabled": true
251
259
  },
252
260
  {
253
261
  "id": "account-deletion",
@@ -255,7 +263,7 @@
255
263
  "key": "account-deletion",
256
264
  "label": "Account Deletion",
257
265
  "description": "Surfaces the user-initiated account deletion flow in client settings.",
258
- "defaultEnabled": false
266
+ "defaultEnabled": true
259
267
  },
260
268
  {
261
269
  "id": "app-control",
@@ -272,6 +280,22 @@
272
280
  "label": "Analyze Conversation",
273
281
  "description": "Show the 'Analyze' / 'Analyze conversation' option in conversation context menus and the conversation title actions dropdown.",
274
282
  "defaultEnabled": false
283
+ },
284
+ {
285
+ "id": "pro-plan-adjust",
286
+ "scope": "assistant",
287
+ "key": "pro-plan-adjust",
288
+ "label": "Pro Plan Adjust",
289
+ "description": "Show the rich Plan card (current plan, features, Manage/Upgrade CTA) at the top of the macOS Settings → Billing tab. The 'Configure Auto Top Ups' CTA is gated separately on `auto-credit-topup`.",
290
+ "defaultEnabled": false
291
+ },
292
+ {
293
+ "id": "auto-credit-topup",
294
+ "scope": "assistant",
295
+ "key": "auto-credit-topup",
296
+ "label": "Auto Credit Top-Up",
297
+ "description": "Show the 'Configure Auto Top Ups' CTA in the macOS Settings → Billing tab. Mirrors the platform web flag of the same name that gates the auto-reload card and /v1/organizations/billing/auto-top-up/ API.",
298
+ "defaultEnabled": false
275
299
  }
276
300
  ]
277
301
  }
@@ -14,6 +14,7 @@ import {
14
14
  import type { RuntimeInboundResponse } from "../runtime/client.js";
15
15
  import type { GatewayInboundEvent } from "../types.js";
16
16
  import { tryTextVerificationIntercept } from "../verification/text-verification.js";
17
+ import { upsertVerifiedContactChannel } from "../verification/contact-helpers.js";
17
18
 
18
19
  const log = getLogger("handle-inbound");
19
20
 
@@ -183,6 +184,17 @@ export async function handleInbound(
183
184
  );
184
185
  }
185
186
 
187
+ if (response.activatedContact) {
188
+ const { sourceChannel, externalUserId, externalChatId, displayName } =
189
+ response.activatedContact;
190
+ void upsertVerifiedContactChannel({
191
+ sourceChannel,
192
+ externalUserId,
193
+ externalChatId: externalChatId ?? externalUserId,
194
+ displayName,
195
+ }).catch(() => {});
196
+ }
197
+
186
198
  return { forwarded: true, rejected: false, runtimeResponse: response };
187
199
  } catch (err) {
188
200
  // Let CircuitBreakerOpenError propagate so webhook handlers can
@@ -13,10 +13,17 @@
13
13
  * Auth: edge (same as all ingress contact routes).
14
14
  */
15
15
 
16
+ import { eq } from "drizzle-orm";
17
+
16
18
  import {
17
19
  assistantDbQuery,
18
20
  assistantDbRun,
19
21
  } from "../../db/assistant-db-proxy.js";
22
+ import { getGatewayDb } from "../../db/connection.js";
23
+ import {
24
+ contactChannels as gwContactChannels,
25
+ contacts as gwContacts,
26
+ } from "../../db/schema.js";
20
27
  import { ipcCallAssistant } from "../../ipc/assistant-client.js";
21
28
  import { getLogger } from "../../logger.js";
22
29
 
@@ -68,48 +75,152 @@ export async function handleContactPromptSubmit(req: Request): Promise<Response>
68
75
  let channelId: string;
69
76
 
70
77
  try {
71
- // Check if a channel with this (type, address) already exists.
72
- const existing = await assistantDbQuery<{
73
- channelId: string;
74
- contactId: string;
75
- }>(
76
- `SELECT cc.id AS channelId, cc.contact_id AS contactId
77
- FROM contact_channels cc
78
- WHERE cc.type = ? AND cc.address = ?
79
- LIMIT 1`,
78
+ // -----------------------------------------------------------------------
79
+ // Phase 1: Resolve contact
80
+ //
81
+ // Guardian prompts always bind to the existing guardian contact — there
82
+ // must only ever be one. Non-guardian prompts reuse an existing contact
83
+ // (found via a matching channel address) or create a new one.
84
+ // -----------------------------------------------------------------------
85
+ let createdNewContact = false;
86
+
87
+ if (effectiveRole === "guardian") {
88
+ const guardianRows = await assistantDbQuery<{ id: string }>(
89
+ `SELECT id FROM contacts WHERE role = 'guardian' ORDER BY created_at ASC LIMIT 1`,
90
+ [],
91
+ );
92
+ if (guardianRows.length > 0) {
93
+ contactId = guardianRows[0].id;
94
+ } else {
95
+ // Bootstrap hasn't run yet — create the guardian contact.
96
+ log.warn(
97
+ { channelType, address: normalizedAddress },
98
+ "contact-prompt-submit: no guardian contact found, creating one",
99
+ );
100
+ contactId = crypto.randomUUID();
101
+ createdNewContact = true;
102
+ await assistantDbRun(
103
+ `INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
104
+ VALUES (?, ?, 'guardian', 'human', ?, ?)`,
105
+ [contactId, effectiveDisplayName, now, now],
106
+ );
107
+ try {
108
+ getGatewayDb()
109
+ .insert(gwContacts)
110
+ .values({ id: contactId, displayName: effectiveDisplayName, role: "guardian", createdAt: now, updatedAt: now })
111
+ .onConflictDoNothing()
112
+ .run();
113
+ } catch (gwErr) {
114
+ log.warn({ err: gwErr }, "contact-prompt-submit: gateway DB guardian contact INSERT dual-write failed");
115
+ }
116
+ }
117
+ } else {
118
+ // Reuse an existing contact if this channel address is already known.
119
+ const existingForChannel = await assistantDbQuery<{ contactId: string }>(
120
+ `SELECT contact_id AS contactId FROM contact_channels WHERE type = ? AND address = ? LIMIT 1`,
121
+ [channelType, normalizedAddress],
122
+ );
123
+ if (existingForChannel.length > 0) {
124
+ contactId = existingForChannel[0].contactId;
125
+ } else {
126
+ contactId = crypto.randomUUID();
127
+ createdNewContact = true;
128
+ await assistantDbRun(
129
+ `INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
130
+ VALUES (?, ?, ?, 'human', ?, ?)`,
131
+ [contactId, effectiveDisplayName, effectiveRole, now, now],
132
+ );
133
+ try {
134
+ getGatewayDb()
135
+ .insert(gwContacts)
136
+ .values({ id: contactId, displayName: effectiveDisplayName, role: effectiveRole, createdAt: now, updatedAt: now })
137
+ .onConflictDoNothing()
138
+ .run();
139
+ } catch (gwErr) {
140
+ log.warn({ err: gwErr }, "contact-prompt-submit: gateway DB contact INSERT dual-write failed");
141
+ }
142
+ }
143
+ }
144
+
145
+ // -----------------------------------------------------------------------
146
+ // Phase 2: Resolve channel
147
+ //
148
+ // If a channel for (type, address) already points to our contact, reuse it.
149
+ // If it points to a different contact and we are binding as guardian, that
150
+ // is a conflict the caller must resolve — return 409. Otherwise create a
151
+ // new channel bound to the resolved contact.
152
+ // -----------------------------------------------------------------------
153
+ const existingChannel = await assistantDbQuery<{ id: string; contactId: string }>(
154
+ `SELECT id, contact_id AS contactId FROM contact_channels WHERE type = ? AND address = ? LIMIT 1`,
80
155
  [channelType, normalizedAddress],
81
156
  );
82
157
 
83
- if (existing.length > 0) {
84
- contactId = existing[0].contactId;
85
- channelId = existing[0].channelId;
158
+ if (existingChannel.length > 0 && existingChannel[0].contactId === contactId) {
159
+ channelId = existingChannel[0].id;
86
160
  log.info(
87
161
  { channelType, address: normalizedAddress, contactId, channelId },
88
162
  "contact-prompt-submit: channel already exists",
89
163
  );
164
+ } else if (existingChannel.length > 0) {
165
+ // Channel exists but belongs to a different contact. The caller must
166
+ // clean up the stale binding before a guardian channel can be created.
167
+ log.warn(
168
+ { channelType, address: normalizedAddress, contactId, existingContactId: existingChannel[0].contactId },
169
+ "contact-prompt-submit: channel already assigned to another contact",
170
+ );
171
+ await ipcCallAssistant("resolve_contact_prompt", {
172
+ body: { requestId, error: "Channel already assigned to another contact" },
173
+ });
174
+ return Response.json(
175
+ { accepted: false, error: "Channel already assigned to another contact" },
176
+ { status: 409 },
177
+ );
90
178
  } else {
91
- contactId = crypto.randomUUID();
92
179
  channelId = crypto.randomUUID();
93
180
 
94
- await assistantDbRun(
95
- `INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
96
- VALUES (?, ?, ?, 'human', ?, ?)`,
97
- [contactId, effectiveDisplayName, effectiveRole, now, now],
98
- );
99
-
100
181
  try {
101
182
  await assistantDbRun(
102
183
  `INSERT INTO contact_channels (id, contact_id, type, address, is_primary, status, policy, interaction_count, created_at, updated_at)
103
184
  VALUES (?, ?, ?, ?, 1, 'unverified', 'allow', 0, ?, ?)`,
104
185
  [channelId, contactId, channelType, normalizedAddress, now, now],
105
186
  );
187
+ try {
188
+ getGatewayDb()
189
+ .insert(gwContactChannels)
190
+ .values({
191
+ id: channelId,
192
+ contactId,
193
+ type: channelType,
194
+ address: normalizedAddress,
195
+ isPrimary: true,
196
+ status: "unverified",
197
+ policy: "allow",
198
+ interactionCount: 0,
199
+ createdAt: now,
200
+ updatedAt: now,
201
+ })
202
+ .onConflictDoNothing()
203
+ .run();
204
+ } catch (gwErr) {
205
+ log.warn({ err: gwErr }, "contact-prompt-submit: gateway DB channel INSERT dual-write failed");
206
+ }
106
207
  } catch (channelErr) {
107
- // Compensating delete — remove the orphaned contact row.
208
+ // Compensating delete — only remove the contact if we created it here.
108
209
  log.error(
109
210
  { channelErr, contactId, channelType },
110
211
  "contact-prompt-submit: channel INSERT failed, rolling back contact",
111
212
  );
112
- await assistantDbRun("DELETE FROM contacts WHERE id = ?", [contactId]);
213
+ if (createdNewContact) {
214
+ await assistantDbRun("DELETE FROM contacts WHERE id = ?", [contactId]);
215
+ try {
216
+ getGatewayDb()
217
+ .delete(gwContacts)
218
+ .where(eq(gwContacts.id, contactId))
219
+ .run();
220
+ } catch (gwErr) {
221
+ log.warn({ err: gwErr }, "contact-prompt-submit: gateway DB contact rollback DELETE dual-write failed");
222
+ }
223
+ }
113
224
 
114
225
  // Notify daemon of failure so the CLI doesn't hang.
115
226
  await ipcCallAssistant("resolve_contact_prompt", {
@@ -122,8 +233,8 @@ export async function handleContactPromptSubmit(req: Request): Promise<Response>
122
233
  }
123
234
 
124
235
  log.info(
125
- { channelType, address: normalizedAddress, contactId, channelId, role: effectiveRole },
126
- "contact-prompt-submit: created new contact + channel",
236
+ { channelType, address: normalizedAddress, contactId, channelId },
237
+ "contact-prompt-submit: created new channel",
127
238
  );
128
239
  }
129
240
  } catch (err) {
@@ -6,10 +6,18 @@
6
6
  */
7
7
 
8
8
  import { proxyForward } from "@vellumai/assistant-client";
9
+ import { eq } from "drizzle-orm";
9
10
 
10
11
  import { mintServiceToken } from "../../auth/token-exchange.js";
11
12
  import type { GatewayConfig } from "../../config.js";
13
+ import {
14
+ assistantDbQuery,
15
+ assistantDbRun,
16
+ } from "../../db/assistant-db-proxy.js";
17
+ import { getGatewayDb } from "../../db/connection.js";
18
+ import { contacts } from "../../db/schema.js";
12
19
  import { fetchImpl } from "../../fetch.js";
20
+ import { ipcCallAssistant } from "../../ipc/assistant-client.js";
13
21
  import { getLogger } from "../../logger.js";
14
22
 
15
23
  const log = getLogger("contacts-control-plane-proxy");
@@ -72,11 +80,32 @@ export function createContactsControlPlaneProxyHandler(config: GatewayConfig) {
72
80
  return forward(req, `/v1/contacts/${contactId}`);
73
81
  },
74
82
 
75
- async handleDeleteContact(
76
- req: Request,
77
- contactId: string,
78
- ): Promise<Response> {
79
- return forward(req, `/v1/contacts/${contactId}`);
83
+ async handleDeleteContact(contactId: string): Promise<Response> {
84
+ const rows = await assistantDbQuery<{ role: string }>(
85
+ "SELECT role FROM contacts WHERE id = ?",
86
+ [contactId],
87
+ );
88
+ if (rows.length === 0) {
89
+ log.warn({ contactId }, "delete_contact: not found");
90
+ return Response.json(
91
+ { error: { code: "NOT_FOUND", message: `Contact "${contactId}" not found` } },
92
+ { status: 404 },
93
+ );
94
+ }
95
+ if (rows[0].role === "guardian") {
96
+ log.warn({ contactId }, "delete_contact: attempted to delete guardian");
97
+ return Response.json(
98
+ { error: { code: "FORBIDDEN", message: "Cannot delete a guardian contact" } },
99
+ { status: 403 },
100
+ );
101
+ }
102
+ await assistantDbRun("DELETE FROM contacts WHERE id = ?", [contactId]);
103
+ getGatewayDb().delete(contacts).where(eq(contacts.id, contactId)).run();
104
+ void ipcCallAssistant("emit_event", {
105
+ body: { kind: "contacts_changed" },
106
+ } as unknown as Record<string, unknown>);
107
+ log.info({ contactId }, "delete_contact: deleted");
108
+ return new Response(null, { status: 204 });
80
109
  },
81
110
 
82
111
  async handleMergeContacts(req: Request): Promise<Response> {
@@ -118,6 +118,24 @@ export async function tryIpcProxy(
118
118
  }
119
119
  });
120
120
 
121
+ // Override caller-supplied identity headers with values derived from the
122
+ // verified JWT claims. The daemon's IPC adapter (`injectLocalActorHeader`)
123
+ // preserves any inbound `x-vellum-actor-principal-id`, so without this
124
+ // step a malicious client could spoof another user's principal id by
125
+ // setting the header explicitly. Mirrors the HTTP adapter's behavior in
126
+ // `assistant/src/runtime/routes/http-adapter.ts`.
127
+ delete headers["x-vellum-actor-principal-id"];
128
+ delete headers["x-vellum-principal-type"];
129
+ if (claims) {
130
+ const sub = parseSub(claims.sub);
131
+ if (sub.ok) {
132
+ headers["x-vellum-principal-type"] = sub.principalType;
133
+ if (sub.actorPrincipalId) {
134
+ headers["x-vellum-actor-principal-id"] = sub.actorPrincipalId;
135
+ }
136
+ }
137
+ }
138
+
121
139
  let body: Record<string, unknown> | undefined;
122
140
  if (req.method !== "GET" && req.method !== "HEAD") {
123
141
  const contentType = req.headers.get("content-type") ?? "";
@@ -314,7 +314,28 @@ describe("resolvePublicBaseWssUrl", () => {
314
314
  );
315
315
  });
316
316
 
317
- test("falls back to configFile publicBaseUrl when velayBaseUrl is set but platformAssistantId is missing", () => {
317
+ test("uses configFile publicBaseUrl before velayBaseUrl fallback", () => {
318
+ const config = {
319
+ ...baseConfig,
320
+ velayBaseUrl: "http://host.docker.internal:8501",
321
+ };
322
+ const mockConfigFile = {
323
+ getString: (section: string, key: string) =>
324
+ section === "ingress" && key === "publicBaseUrl"
325
+ ? "https://velay-public.example.test/abc12345-0000-0000-0000-000000000000"
326
+ : undefined,
327
+ } as Parameters<typeof resolvePublicBaseWssUrl>[1];
328
+ const result = resolvePublicBaseWssUrl(
329
+ config,
330
+ mockConfigFile,
331
+ "abc12345-0000-0000-0000-000000000000",
332
+ );
333
+ expect(result).toBe(
334
+ "wss://velay-public.example.test/abc12345-0000-0000-0000-000000000000",
335
+ );
336
+ });
337
+
338
+ test("falls back to configFile publicBaseUrl when platformAssistantId is missing", () => {
318
339
  const config = {
319
340
  ...baseConfig,
320
341
  velayBaseUrl: "https://velay-dev.vellum.ai",