@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.
- package/ARCHITECTURE.md +20 -21
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/__tests__/config-file-watcher.test.ts +1 -1
- package/src/__tests__/contact-prompt-submit.test.ts +349 -0
- package/src/__tests__/ipc-route-policy.test.ts +24 -0
- package/src/__tests__/nonbash-trust-rule-overrides.test.ts +50 -0
- package/src/__tests__/remote-feature-flag-sync.test.ts +16 -14
- package/src/__tests__/slack-display-name.test.ts +6 -2
- package/src/__tests__/slack-normalize.test.ts +36 -56
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +4 -2
- package/src/__tests__/telegram-webhook-manager.test.ts +8 -25
- package/src/__tests__/twilio-webhooks.test.ts +2 -6
- package/src/__tests__/upsert-verified-contact-channel.test.ts +173 -0
- package/src/auth/guardian-bootstrap.ts +49 -0
- package/src/auth/ipc-route-policy.ts +5 -0
- package/src/db/contact-store.ts +27 -1
- package/src/email/register-callback.test.ts +4 -4
- package/src/email/register-callback.ts +12 -16
- package/src/feature-flag-registry.json +27 -3
- package/src/handlers/handle-inbound.ts +12 -0
- package/src/http/routes/contact-prompt.ts +134 -23
- package/src/http/routes/contacts-control-plane-proxy.ts +34 -5
- package/src/http/routes/ipc-runtime-proxy.ts +18 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +22 -1
- package/src/http/routes/twilio-voice-webhook.ts +53 -0
- package/src/index.ts +4 -2
- package/src/ipc/velay-handlers.ts +31 -0
- package/src/remote-feature-flag-sync.ts +10 -8
- package/src/risk/command-registry/commands/assistant.ts +1 -0
- package/src/risk/skill-risk-classifier.ts +12 -3
- package/src/runtime/client.ts +25 -12
- package/src/slack/normalize.test.ts +3 -3
- package/src/slack/normalize.ts +6 -69
- package/src/slack/socket-mode.ts +1 -5
- package/src/telegram/webhook-manager.ts +9 -13
- package/src/velay/client.ts +27 -16
- 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"]],
|
package/src/db/contact-store.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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("
|
|
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`.
|
|
26
|
-
* API key, assistant ID)
|
|
27
|
-
*
|
|
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
|
|
53
|
-
// the daemon's resolvePlatformCallbackRegistrationContext()
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 || !
|
|
66
|
+
if (!platformBaseUrl || !assistantCredential || !assistantId) {
|
|
71
67
|
log.debug(
|
|
72
68
|
{
|
|
73
69
|
hasPlatformBaseUrl: !!platformBaseUrl,
|
|
74
|
-
hasApiKey: !!
|
|
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:
|
|
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
|
|
250
|
-
"defaultEnabled":
|
|
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":
|
|
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
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 (
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
126
|
-
"contact-prompt-submit: created new
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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("
|
|
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",
|