@vellumai/vellum-gateway 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -0
- package/Dockerfile +4 -2
- package/bun.lock +8 -1
- package/knip.json +1 -0
- package/package.json +2 -1
- package/src/__tests__/contact-prompt-submit.test.ts +5 -5
- package/src/__tests__/contact-store-mark-channel-verified.test.ts +177 -0
- package/src/__tests__/contacts-control-plane-proxy.test.ts +297 -6
- package/src/__tests__/contacts-control-plane-route-match.test.ts +16 -0
- package/src/__tests__/edge-auth.test.ts +253 -0
- package/src/__tests__/edge-guardian-auth.test.ts +198 -0
- package/src/__tests__/ipc-route-policy-coverage.test.ts +297 -0
- package/src/__tests__/ipc-route-policy.test.ts +43 -0
- package/src/__tests__/ipc-server-watchdog.test.ts +189 -0
- package/src/__tests__/live-voice-websocket.test.ts +1 -1
- package/src/__tests__/slack-normalize.test.ts +132 -0
- package/src/auth/guardian-bootstrap.ts +3 -1
- package/src/auth/ipc-route-policy.ts +34 -0
- package/src/db/assistant-db-proxy.ts +76 -7
- package/src/db/contact-store.ts +767 -1
- package/src/db/schema.ts +29 -0
- package/src/feature-flag-registry.json +17 -17
- package/src/handlers/handle-inbound.ts +9 -23
- package/src/http/middleware/auth.ts +193 -40
- package/src/http/router.ts +26 -4
- package/src/http/routes/channel-verification-session-proxy.ts +53 -6
- package/src/http/routes/contact-prompt.ts +44 -15
- package/src/http/routes/contacts-control-plane-proxy.ts +329 -2
- package/src/http/routes/contacts-control-plane-route-match.ts +12 -0
- package/src/http/routes/ipc-runtime-proxy.test.ts +38 -43
- package/src/http/routes/ipc-runtime-proxy.ts +2 -2
- package/src/http/routes/log-export.test.ts +1 -0
- package/src/http/routes/log-export.ts +9 -2
- package/src/http/routes/log-tail.test.ts +10 -9
- package/src/http/routes/log-tail.ts +5 -2
- package/src/http/routes/pair.ts +8 -0
- package/src/http/routes/twilio-voice-webhook.ts +11 -2
- package/src/index.ts +98 -13
- package/src/ipc/assistant-client.test.ts +67 -15
- package/src/ipc/assistant-client.ts +12 -118
- package/src/ipc/risk-classification-handlers.test.ts +76 -0
- package/src/ipc/risk-classification-handlers.ts +20 -9
- package/src/ipc/server.ts +113 -46
- package/src/logger.ts +71 -17
- package/src/post-assistant-ready.ts +9 -3
- package/src/risk/bash-risk-classifier.test.ts +106 -0
- package/src/risk/bash-risk-classifier.ts +19 -15
- package/src/risk/command-registry/commands/assistant.ts +75 -26
- package/src/risk/command-registry.test.ts +3 -1
- package/src/risk/shell-parser.test.ts +159 -0
- package/src/risk/shell-parser.ts +150 -19
- package/src/runtime/client.ts +0 -11
- package/src/schema.ts +32 -0
- package/src/slack/normalize.test.ts +5 -0
- package/src/slack/normalize.ts +7 -0
- package/src/velay/bridge-utils.ts +10 -0
- package/src/velay/client.test.ts +156 -0
- package/src/velay/client.ts +83 -0
- package/src/velay/http-bridge.test.ts +29 -0
- package/src/velay/http-bridge.ts +7 -0
- package/src/velay/protocol.ts +12 -1
- package/src/verification/outbound-voice-verification-sync.ts +171 -0
- package/src/verification/voice-approval-sync.ts +107 -0
|
@@ -168,9 +168,10 @@ export async function handleContactPromptSubmit(req: Request): Promise<Response>
|
|
|
168
168
|
{ channelType, address: normalizedAddress, contactId, existingContactId: existingChannel[0].contactId },
|
|
169
169
|
"contact-prompt-submit: channel already assigned to another contact",
|
|
170
170
|
);
|
|
171
|
-
await
|
|
172
|
-
|
|
173
|
-
|
|
171
|
+
await notifyDaemonResolveError(
|
|
172
|
+
requestId,
|
|
173
|
+
"Channel already assigned to another contact",
|
|
174
|
+
);
|
|
174
175
|
return Response.json(
|
|
175
176
|
{ accepted: false, error: "Channel already assigned to another contact" },
|
|
176
177
|
{ status: 409 },
|
|
@@ -223,9 +224,10 @@ export async function handleContactPromptSubmit(req: Request): Promise<Response>
|
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
// Notify daemon of failure so the CLI doesn't hang.
|
|
226
|
-
await
|
|
227
|
-
|
|
228
|
-
|
|
227
|
+
await notifyDaemonResolveError(
|
|
228
|
+
requestId,
|
|
229
|
+
"Failed to create contact channel",
|
|
230
|
+
);
|
|
229
231
|
return Response.json(
|
|
230
232
|
{ accepted: false, error: "Failed to create contact channel" },
|
|
231
233
|
{ status: 500 },
|
|
@@ -239,22 +241,49 @@ export async function handleContactPromptSubmit(req: Request): Promise<Response>
|
|
|
239
241
|
}
|
|
240
242
|
} catch (err) {
|
|
241
243
|
log.error({ err, requestId }, "contact-prompt-submit: DB error");
|
|
242
|
-
await
|
|
243
|
-
body: { requestId, error: "Database error" },
|
|
244
|
-
});
|
|
244
|
+
await notifyDaemonResolveError(requestId, "Database error");
|
|
245
245
|
return Response.json({ accepted: false, error: "Database error" }, { status: 500 });
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
// Notify daemon to unblock the waiting contacts/prompt IPC call.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
try {
|
|
250
|
+
const ipcResult = await ipcCallAssistant("resolve_contact_prompt", {
|
|
251
|
+
body: { requestId, contactId, channelId, channelType, address: normalizedAddress },
|
|
252
|
+
});
|
|
253
|
+
if ((ipcResult as { resolved?: boolean }).resolved === false) {
|
|
254
|
+
log.warn(
|
|
255
|
+
{ requestId, contactId },
|
|
256
|
+
"contact-prompt-submit: resolve_contact_prompt IPC did not find a pending prompt — CLI may time out",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
253
260
|
log.warn(
|
|
254
|
-
{ requestId, contactId },
|
|
255
|
-
"contact-prompt-submit: resolve_contact_prompt IPC
|
|
261
|
+
{ err, requestId, contactId },
|
|
262
|
+
"contact-prompt-submit: resolve_contact_prompt IPC failed — CLI may time out",
|
|
256
263
|
);
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
return Response.json({ accepted: true });
|
|
260
267
|
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Best-effort notification to the daemon that a pending contact prompt has
|
|
271
|
+
* resolved with an error. Failures here must not block the HTTP response —
|
|
272
|
+
* the caller has already decided the request failed; we just want to wake
|
|
273
|
+
* the CLI up.
|
|
274
|
+
*/
|
|
275
|
+
async function notifyDaemonResolveError(
|
|
276
|
+
requestId: string,
|
|
277
|
+
error: string,
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
try {
|
|
280
|
+
await ipcCallAssistant("resolve_contact_prompt", {
|
|
281
|
+
body: { requestId, error },
|
|
282
|
+
});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
log.warn(
|
|
285
|
+
{ err, requestId },
|
|
286
|
+
"contact-prompt-submit: resolve_contact_prompt error notification failed",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
assistantDbRun,
|
|
16
16
|
} from "../../db/assistant-db-proxy.js";
|
|
17
17
|
import { getGatewayDb } from "../../db/connection.js";
|
|
18
|
+
import { ContactStore } from "../../db/contact-store.js";
|
|
18
19
|
import { contacts } from "../../db/schema.js";
|
|
19
20
|
import { fetchImpl } from "../../fetch.js";
|
|
20
21
|
import { ipcCallAssistant } from "../../ipc/assistant-client.js";
|
|
@@ -22,6 +23,61 @@ import { getLogger } from "../../logger.js";
|
|
|
22
23
|
|
|
23
24
|
const log = getLogger("contacts-control-plane-proxy");
|
|
24
25
|
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Validation constants (mirrored from assistant/src/runtime/routes/contact-routes.ts)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const VALID_CONTACT_TYPES = ["human", "assistant"] as const;
|
|
31
|
+
const VALID_ASSISTANT_SPECIES = ["vellum"] as const;
|
|
32
|
+
const VALID_CHANNEL_STATUSES = [
|
|
33
|
+
"active",
|
|
34
|
+
"pending",
|
|
35
|
+
"revoked",
|
|
36
|
+
"blocked",
|
|
37
|
+
"unverified",
|
|
38
|
+
] as const;
|
|
39
|
+
const VALID_CHANNEL_POLICIES = ["allow", "deny", "escalate"] as const;
|
|
40
|
+
|
|
41
|
+
type ContactType = (typeof VALID_CONTACT_TYPES)[number];
|
|
42
|
+
type AssistantSpecies = (typeof VALID_ASSISTANT_SPECIES)[number];
|
|
43
|
+
type ChannelStatus = (typeof VALID_CHANNEL_STATUSES)[number];
|
|
44
|
+
type ChannelPolicy = (typeof VALID_CHANNEL_POLICIES)[number];
|
|
45
|
+
|
|
46
|
+
function isContactType(v: unknown): v is ContactType {
|
|
47
|
+
return VALID_CONTACT_TYPES.includes(v as ContactType);
|
|
48
|
+
}
|
|
49
|
+
function isAssistantSpecies(v: unknown): v is AssistantSpecies {
|
|
50
|
+
return VALID_ASSISTANT_SPECIES.includes(v as AssistantSpecies);
|
|
51
|
+
}
|
|
52
|
+
function isChannelStatus(v: unknown): v is ChannelStatus {
|
|
53
|
+
return VALID_CHANNEL_STATUSES.includes(v as ChannelStatus);
|
|
54
|
+
}
|
|
55
|
+
function isChannelPolicy(v: unknown): v is ChannelPolicy {
|
|
56
|
+
return VALID_CHANNEL_POLICIES.includes(v as ChannelPolicy);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate that metadata matches the expected shape for the given species.
|
|
61
|
+
* Mirrors `validateSpeciesMetadata` in `assistant/src/contacts/contact-store.ts`.
|
|
62
|
+
*/
|
|
63
|
+
function validateSpeciesMetadata(
|
|
64
|
+
species: AssistantSpecies,
|
|
65
|
+
metadata: Record<string, unknown> | null | undefined,
|
|
66
|
+
): string | null {
|
|
67
|
+
if (metadata == null) return null;
|
|
68
|
+
|
|
69
|
+
if (species === "vellum") {
|
|
70
|
+
if (typeof metadata.assistantId !== "string" || !metadata.assistantId) {
|
|
71
|
+
return 'Vellum assistant metadata requires a non-empty "assistantId" string';
|
|
72
|
+
}
|
|
73
|
+
if (typeof metadata.gatewayUrl !== "string" || !metadata.gatewayUrl) {
|
|
74
|
+
return 'Vellum assistant metadata requires a non-empty "gatewayUrl" string';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
25
81
|
export function createContactsControlPlaneProxyHandler(config: GatewayConfig) {
|
|
26
82
|
async function forward(
|
|
27
83
|
req: Request,
|
|
@@ -72,8 +128,240 @@ export function createContactsControlPlaneProxyHandler(config: GatewayConfig) {
|
|
|
72
128
|
return forward(req, "/v1/contacts", url.search);
|
|
73
129
|
},
|
|
74
130
|
|
|
131
|
+
/**
|
|
132
|
+
* POST /v1/contacts — gateway-native contact upsert.
|
|
133
|
+
*
|
|
134
|
+
* Writes to BOTH the gateway DB (auth/authz fields: id, displayName, role,
|
|
135
|
+
* principalId + channels) and the assistant DB (all fields including notes,
|
|
136
|
+
* userFile, contactType) so the daemon stays in sync during the migration
|
|
137
|
+
* transition period.
|
|
138
|
+
*
|
|
139
|
+
* Resolution order mirrors the assistant's upsertContact:
|
|
140
|
+
* 1. Match by `body.id` if provided.
|
|
141
|
+
* 2. Match by (type, address) on any provided channel.
|
|
142
|
+
* 3. Create a new contact with a generated id.
|
|
143
|
+
*/
|
|
75
144
|
async handleUpsertContact(req: Request): Promise<Response> {
|
|
76
|
-
|
|
145
|
+
// ── Parse body ──────────────────────────────────────────────────
|
|
146
|
+
let body: Record<string, unknown>;
|
|
147
|
+
try {
|
|
148
|
+
body = (await req.json()) as Record<string, unknown>;
|
|
149
|
+
} catch {
|
|
150
|
+
return Response.json(
|
|
151
|
+
{ error: { code: "BAD_REQUEST", message: "Invalid JSON body" } },
|
|
152
|
+
{ status: 400 },
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Validate ────────────────────────────────────────────────────
|
|
157
|
+
if (
|
|
158
|
+
!body.displayName ||
|
|
159
|
+
typeof body.displayName !== "string" ||
|
|
160
|
+
!body.displayName.trim()
|
|
161
|
+
) {
|
|
162
|
+
return Response.json(
|
|
163
|
+
{
|
|
164
|
+
error: {
|
|
165
|
+
code: "BAD_REQUEST",
|
|
166
|
+
message:
|
|
167
|
+
"displayName is required and must be a non-empty string",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{ status: 400 },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const displayName = (body.displayName as string).trim();
|
|
174
|
+
|
|
175
|
+
if (body.contactType !== undefined && !isContactType(body.contactType)) {
|
|
176
|
+
return Response.json(
|
|
177
|
+
{
|
|
178
|
+
error: {
|
|
179
|
+
code: "BAD_REQUEST",
|
|
180
|
+
message: `Invalid contactType "${body.contactType}". Must be one of: ${VALID_CONTACT_TYPES.join(", ")}`,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{ status: 400 },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const assistantMeta = body.assistantMetadata as
|
|
188
|
+
| { species?: unknown; metadata?: unknown }
|
|
189
|
+
| undefined;
|
|
190
|
+
|
|
191
|
+
if (body.contactType === "assistant") {
|
|
192
|
+
if (!assistantMeta) {
|
|
193
|
+
return Response.json(
|
|
194
|
+
{
|
|
195
|
+
error: {
|
|
196
|
+
code: "BAD_REQUEST",
|
|
197
|
+
message:
|
|
198
|
+
'assistantMetadata is required when contactType is "assistant"',
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{ status: 400 },
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (!isAssistantSpecies(assistantMeta.species)) {
|
|
205
|
+
return Response.json(
|
|
206
|
+
{
|
|
207
|
+
error: {
|
|
208
|
+
code: "BAD_REQUEST",
|
|
209
|
+
message: `Invalid species "${assistantMeta.species}". Must be one of: ${VALID_ASSISTANT_SPECIES.join(", ")}`,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{ status: 400 },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const speciesError = validateSpeciesMetadata(
|
|
216
|
+
assistantMeta.species,
|
|
217
|
+
assistantMeta.metadata as
|
|
218
|
+
| Record<string, unknown>
|
|
219
|
+
| null
|
|
220
|
+
| undefined,
|
|
221
|
+
);
|
|
222
|
+
if (speciesError) {
|
|
223
|
+
return Response.json(
|
|
224
|
+
{ error: { code: "BAD_REQUEST", message: speciesError } },
|
|
225
|
+
{ status: 400 },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (body.contactType === "human" && assistantMeta) {
|
|
230
|
+
return Response.json(
|
|
231
|
+
{
|
|
232
|
+
error: {
|
|
233
|
+
code: "BAD_REQUEST",
|
|
234
|
+
message:
|
|
235
|
+
'assistantMetadata must not be provided when contactType is "human"',
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{ status: 400 },
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (assistantMeta && !body.contactType) {
|
|
242
|
+
return Response.json(
|
|
243
|
+
{
|
|
244
|
+
error: {
|
|
245
|
+
code: "BAD_REQUEST",
|
|
246
|
+
message:
|
|
247
|
+
'contactType must be "assistant" when assistantMetadata is provided',
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{ status: 400 },
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
type ChannelInput = {
|
|
255
|
+
type: string;
|
|
256
|
+
address: string;
|
|
257
|
+
isPrimary?: boolean;
|
|
258
|
+
externalUserId?: string | null;
|
|
259
|
+
externalChatId?: string | null;
|
|
260
|
+
status?: string;
|
|
261
|
+
policy?: string;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const channelInputs = body.channels as ChannelInput[] | undefined;
|
|
265
|
+
if (channelInputs !== undefined) {
|
|
266
|
+
if (!Array.isArray(channelInputs)) {
|
|
267
|
+
return Response.json(
|
|
268
|
+
{
|
|
269
|
+
error: {
|
|
270
|
+
code: "BAD_REQUEST",
|
|
271
|
+
message: "channels must be an array",
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{ status: 400 },
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
for (const ch of channelInputs) {
|
|
278
|
+
if (typeof ch?.type !== "string" || !ch.type.trim()) {
|
|
279
|
+
return Response.json(
|
|
280
|
+
{
|
|
281
|
+
error: {
|
|
282
|
+
code: "BAD_REQUEST",
|
|
283
|
+
message:
|
|
284
|
+
"channel.type is required and must be a non-empty string",
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
{ status: 400 },
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (typeof ch?.address !== "string" || !ch.address.trim()) {
|
|
291
|
+
return Response.json(
|
|
292
|
+
{
|
|
293
|
+
error: {
|
|
294
|
+
code: "BAD_REQUEST",
|
|
295
|
+
message:
|
|
296
|
+
"channel.address is required and must be a non-empty string",
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
{ status: 400 },
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (ch.status !== undefined && !isChannelStatus(ch.status)) {
|
|
303
|
+
return Response.json(
|
|
304
|
+
{
|
|
305
|
+
error: {
|
|
306
|
+
code: "BAD_REQUEST",
|
|
307
|
+
message: `Invalid channel status "${ch.status}". Must be one of: ${VALID_CHANNEL_STATUSES.join(", ")}`,
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{ status: 400 },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (ch.policy !== undefined && !isChannelPolicy(ch.policy)) {
|
|
314
|
+
return Response.json(
|
|
315
|
+
{
|
|
316
|
+
error: {
|
|
317
|
+
code: "BAD_REQUEST",
|
|
318
|
+
message: `Invalid channel policy "${ch.policy}". Must be one of: ${VALID_CHANNEL_POLICIES.join(", ")}`,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{ status: 400 },
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Service-layer write (gateway DB + assistant DB dual-write) ───
|
|
328
|
+
const store = new ContactStore();
|
|
329
|
+
const { contact, created } = await store.upsertContact({
|
|
330
|
+
id: body.id as string | undefined,
|
|
331
|
+
displayName,
|
|
332
|
+
role: body.role as string | undefined,
|
|
333
|
+
principalId: body.principalId as string | null | undefined,
|
|
334
|
+
notes: body.notes as string | null | undefined,
|
|
335
|
+
contactType: body.contactType as string | undefined,
|
|
336
|
+
assistantMetadata:
|
|
337
|
+
body.contactType === "assistant" && assistantMeta
|
|
338
|
+
? {
|
|
339
|
+
species: assistantMeta.species as string,
|
|
340
|
+
metadata:
|
|
341
|
+
(assistantMeta.metadata as
|
|
342
|
+
| Record<string, unknown>
|
|
343
|
+
| null
|
|
344
|
+
| undefined) ?? null,
|
|
345
|
+
}
|
|
346
|
+
: undefined,
|
|
347
|
+
channels: channelInputs?.map((ch) => ({
|
|
348
|
+
type: ch.type,
|
|
349
|
+
address: ch.address,
|
|
350
|
+
isPrimary: ch.isPrimary,
|
|
351
|
+
externalUserId: ch.externalUserId ?? null,
|
|
352
|
+
externalChatId: ch.externalChatId ?? null,
|
|
353
|
+
status: ch.status,
|
|
354
|
+
policy: ch.policy,
|
|
355
|
+
})),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ── Emit contacts_changed ────────────────────────────────────────
|
|
359
|
+
void ipcCallAssistant("emit_event", {
|
|
360
|
+
body: { kind: "contacts_changed" },
|
|
361
|
+
} as unknown as Record<string, unknown>).catch(() => {});
|
|
362
|
+
|
|
363
|
+
log.info({ contactId: contact.id, created }, "upsert_contact: handled natively");
|
|
364
|
+
return Response.json({ ok: true, contact });
|
|
77
365
|
},
|
|
78
366
|
|
|
79
367
|
async handleGetContact(req: Request, contactId: string): Promise<Response> {
|
|
@@ -103,7 +391,7 @@ export function createContactsControlPlaneProxyHandler(config: GatewayConfig) {
|
|
|
103
391
|
getGatewayDb().delete(contacts).where(eq(contacts.id, contactId)).run();
|
|
104
392
|
void ipcCallAssistant("emit_event", {
|
|
105
393
|
body: { kind: "contacts_changed" },
|
|
106
|
-
} as unknown as Record<string, unknown>);
|
|
394
|
+
} as unknown as Record<string, unknown>).catch(() => {});
|
|
107
395
|
log.info({ contactId }, "delete_contact: deleted");
|
|
108
396
|
return new Response(null, { status: 204 });
|
|
109
397
|
},
|
|
@@ -119,6 +407,45 @@ export function createContactsControlPlaneProxyHandler(config: GatewayConfig) {
|
|
|
119
407
|
return forward(req, `/v1/contact-channels/${contactChannelId}`);
|
|
120
408
|
},
|
|
121
409
|
|
|
410
|
+
/**
|
|
411
|
+
* POST /v1/contact-channels/:id/verify — guardian-only manual verify.
|
|
412
|
+
*
|
|
413
|
+
* Gateway-native + dual-write: the channel mutation happens in the
|
|
414
|
+
* gateway DB first (source of truth); a best-effort mirror is written
|
|
415
|
+
* to the assistant DB so the daemon stays in sync during the
|
|
416
|
+
* gateway-security-migration transition period.
|
|
417
|
+
*
|
|
418
|
+
* Idempotent: a row that's already active+verifiedVia=manual returns
|
|
419
|
+
* the same shape (200 with channel) but no second write occurs.
|
|
420
|
+
*/
|
|
421
|
+
async handleVerifyContactChannel(
|
|
422
|
+
_req: Request,
|
|
423
|
+
contactChannelId: string,
|
|
424
|
+
): Promise<Response> {
|
|
425
|
+
const result =
|
|
426
|
+
await new ContactStore().markChannelVerified(contactChannelId);
|
|
427
|
+
if (!result) {
|
|
428
|
+
return Response.json(
|
|
429
|
+
{
|
|
430
|
+
error: {
|
|
431
|
+
code: "NOT_FOUND",
|
|
432
|
+
message: `Channel "${contactChannelId}" not found`,
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
{ status: 404 },
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
log.info(
|
|
439
|
+
{
|
|
440
|
+
contactChannelId,
|
|
441
|
+
didWrite: result.didWrite,
|
|
442
|
+
status: result.channel.status,
|
|
443
|
+
},
|
|
444
|
+
"manual_verify: channel attested verified by guardian",
|
|
445
|
+
);
|
|
446
|
+
return Response.json({ ok: true, channel: result.channel });
|
|
447
|
+
},
|
|
448
|
+
|
|
122
449
|
// ── Invite routes ──
|
|
123
450
|
async handleListInvites(req: Request): Promise<Response> {
|
|
124
451
|
const url = new URL(req.url);
|
|
@@ -5,6 +5,7 @@ export type ContactsControlPlaneRoute =
|
|
|
5
5
|
| { kind: "deleteContact"; contactId: string }
|
|
6
6
|
| { kind: "mergeContacts" }
|
|
7
7
|
| { kind: "updateContactChannel"; contactChannelId: string }
|
|
8
|
+
| { kind: "verifyContactChannel"; contactChannelId: string }
|
|
8
9
|
| { kind: "listInvites" }
|
|
9
10
|
| { kind: "createInvite" }
|
|
10
11
|
| { kind: "redeemInvite" }
|
|
@@ -26,6 +27,17 @@ export function matchContactsControlPlaneRoute(
|
|
|
26
27
|
return { kind: "mergeContacts" };
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// Channel verify (must come before the bare channel match)
|
|
31
|
+
const channelVerifyMatch = pathname.match(
|
|
32
|
+
/^\/v1\/contact-channels\/([^/]+)\/verify$/,
|
|
33
|
+
);
|
|
34
|
+
if (channelVerifyMatch && method === "POST") {
|
|
35
|
+
return {
|
|
36
|
+
kind: "verifyContactChannel",
|
|
37
|
+
contactChannelId: channelVerifyMatch[1],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
// Channel status/policy updates
|
|
30
42
|
const channelMatch = pathname.match(/^\/v1\/contact-channels\/([^/]+)$/);
|
|
31
43
|
if (channelMatch && method === "PATCH") {
|
|
@@ -31,39 +31,38 @@ class MockIpcTransportError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
// ipcCallAssistant — used by refreshRouteSchema
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
34
|
+
// Single mock for `ipcCallAssistant` — used by both `refreshRouteSchema`
|
|
35
|
+
// (to prime the cache) and `tryIpcProxy` (per-request IPC calls).
|
|
36
|
+
const ROUTE_SCHEMA = [
|
|
37
|
+
{ operationId: "health", endpoint: "health", method: "GET" },
|
|
38
|
+
{ operationId: "acp_steer", endpoint: "acp/:id/steer", method: "POST" },
|
|
39
|
+
{
|
|
40
|
+
operationId: "acp_list_sessions",
|
|
41
|
+
endpoint: "acp/sessions",
|
|
42
|
+
method: "GET",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
operationId: "apps_dist_file",
|
|
46
|
+
endpoint: "apps/:appId/dist/:filename",
|
|
47
|
+
method: "GET",
|
|
48
|
+
},
|
|
49
|
+
// Policy-enforced routes (policies resolved gateway-side)
|
|
50
|
+
{ operationId: "settings_get", endpoint: "settings", method: "GET" },
|
|
51
|
+
{ operationId: "calls_start", endpoint: "calls/start", method: "POST" },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const defaultIpcImpl = (
|
|
55
|
+
method: string,
|
|
56
|
+
_params?: Record<string, unknown>,
|
|
57
|
+
): Promise<unknown> => {
|
|
58
|
+
if (method === "get_route_schema") return Promise.resolve(ROUTE_SCHEMA);
|
|
59
|
+
return Promise.resolve({ ok: true });
|
|
60
|
+
};
|
|
54
61
|
|
|
55
|
-
|
|
56
|
-
const ipcCallAssistantStrictMock = mock(
|
|
57
|
-
(_method: string, _params?: Record<string, unknown>) =>
|
|
58
|
-
Promise.resolve({ ok: true }),
|
|
59
|
-
);
|
|
62
|
+
const ipcCallAssistantMock = mock(defaultIpcImpl);
|
|
60
63
|
|
|
61
|
-
// Single mock.module for assistant-client — must include ALL exports
|
|
62
|
-
// that any transitive import needs (route-schema-cache uses ipcCallAssistant,
|
|
63
|
-
// ipc-runtime-proxy uses ipcCallAssistantStrict + error classes).
|
|
64
64
|
mock.module("../../ipc/assistant-client.js", () => ({
|
|
65
65
|
ipcCallAssistant: ipcCallAssistantMock,
|
|
66
|
-
ipcCallAssistantStrict: ipcCallAssistantStrictMock,
|
|
67
66
|
IpcHandlerError: MockIpcHandlerError,
|
|
68
67
|
IpcTransportError: MockIpcTransportError,
|
|
69
68
|
}));
|
|
@@ -196,10 +195,8 @@ describe("matchRoute", () => {
|
|
|
196
195
|
|
|
197
196
|
describe("tryIpcProxy", () => {
|
|
198
197
|
beforeEach(() => {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
Promise.resolve({ ok: true }),
|
|
202
|
-
);
|
|
198
|
+
ipcCallAssistantMock.mockReset();
|
|
199
|
+
ipcCallAssistantMock.mockImplementation(defaultIpcImpl);
|
|
203
200
|
validateEdgeTokenMock.mockReset();
|
|
204
201
|
validateEdgeTokenMock.mockImplementation(() => ({
|
|
205
202
|
ok: true,
|
|
@@ -241,8 +238,8 @@ describe("tryIpcProxy", () => {
|
|
|
241
238
|
expect(result).not.toBeNull();
|
|
242
239
|
expect(result!.status).toBe(200);
|
|
243
240
|
|
|
244
|
-
expect(
|
|
245
|
-
const [opId, params] =
|
|
241
|
+
expect(ipcCallAssistantMock).toHaveBeenCalledTimes(1);
|
|
242
|
+
const [opId, params] = ipcCallAssistantMock.mock.calls[0] as [
|
|
246
243
|
string,
|
|
247
244
|
Record<string, unknown>,
|
|
248
245
|
];
|
|
@@ -263,7 +260,7 @@ describe("tryIpcProxy", () => {
|
|
|
263
260
|
|
|
264
261
|
await tryIpcProxy(req, makeConfig());
|
|
265
262
|
|
|
266
|
-
const [, params] =
|
|
263
|
+
const [, params] = ipcCallAssistantMock.mock.calls[0] as [
|
|
267
264
|
string,
|
|
268
265
|
Record<string, unknown>,
|
|
269
266
|
];
|
|
@@ -306,7 +303,7 @@ describe("tryIpcProxy", () => {
|
|
|
306
303
|
});
|
|
307
304
|
|
|
308
305
|
test("returns handler error status code from IpcHandlerError", async () => {
|
|
309
|
-
|
|
306
|
+
ipcCallAssistantMock.mockImplementation(() => {
|
|
310
307
|
throw new MockIpcHandlerError("Not found", 404, "NOT_FOUND");
|
|
311
308
|
});
|
|
312
309
|
|
|
@@ -320,7 +317,7 @@ describe("tryIpcProxy", () => {
|
|
|
320
317
|
});
|
|
321
318
|
|
|
322
319
|
test("returns 502 on transport error", async () => {
|
|
323
|
-
|
|
320
|
+
ipcCallAssistantMock.mockImplementation(() => {
|
|
324
321
|
throw new MockIpcTransportError("Socket closed");
|
|
325
322
|
});
|
|
326
323
|
|
|
@@ -333,7 +330,7 @@ describe("tryIpcProxy", () => {
|
|
|
333
330
|
const req = makeRequest("/v1/acp/sessions?limit=10&offset=5");
|
|
334
331
|
await tryIpcProxy(req, makeConfig());
|
|
335
332
|
|
|
336
|
-
const [, params] =
|
|
333
|
+
const [, params] = ipcCallAssistantMock.mock.calls[0] as [
|
|
337
334
|
string,
|
|
338
335
|
Record<string, unknown>,
|
|
339
336
|
];
|
|
@@ -347,10 +344,8 @@ describe("tryIpcProxy", () => {
|
|
|
347
344
|
|
|
348
345
|
describe("policy enforcement", () => {
|
|
349
346
|
beforeEach(() => {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
Promise.resolve({ ok: true }),
|
|
353
|
-
);
|
|
347
|
+
ipcCallAssistantMock.mockReset();
|
|
348
|
+
ipcCallAssistantMock.mockImplementation(defaultIpcImpl);
|
|
354
349
|
validateEdgeTokenMock.mockReset();
|
|
355
350
|
});
|
|
356
351
|
|
|
@@ -22,9 +22,9 @@ import { validateEdgeToken } from "../../auth/token-exchange.js";
|
|
|
22
22
|
import type { TokenClaims } from "../../auth/types.js";
|
|
23
23
|
import type { GatewayConfig } from "../../config.js";
|
|
24
24
|
import {
|
|
25
|
-
ipcCallAssistantStrict,
|
|
26
25
|
IpcHandlerError,
|
|
27
26
|
IpcTransportError,
|
|
27
|
+
ipcCallAssistant,
|
|
28
28
|
} from "../../ipc/assistant-client.js";
|
|
29
29
|
import { matchRoute } from "../../ipc/route-schema-cache.js";
|
|
30
30
|
import { getLogger } from "../../logger.js";
|
|
@@ -160,7 +160,7 @@ export async function tryIpcProxy(
|
|
|
160
160
|
|
|
161
161
|
// --- Call daemon via IPC ------------------------------------------------
|
|
162
162
|
try {
|
|
163
|
-
const result = await
|
|
163
|
+
const result = await ipcCallAssistant(match.operationId, params);
|
|
164
164
|
|
|
165
165
|
const duration = Math.round(performance.now() - start);
|
|
166
166
|
log.info(
|
|
@@ -30,7 +30,11 @@ import { fetchCesLogExport } from "@vellumai/ces-client/http-log-export";
|
|
|
30
30
|
import { mintServiceToken } from "../../auth/token-exchange.js";
|
|
31
31
|
import type { GatewayConfig } from "../../config.js";
|
|
32
32
|
import { fetchImpl } from "../../fetch.js";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
getLogger,
|
|
35
|
+
LOG_FILE_JSON_PATTERN,
|
|
36
|
+
LOG_FILE_PATTERN,
|
|
37
|
+
} from "../../logger.js";
|
|
34
38
|
|
|
35
39
|
const log = getLogger("log-export");
|
|
36
40
|
|
|
@@ -257,7 +261,10 @@ async function collectGatewayLogs(
|
|
|
257
261
|
let totalBytes = 0;
|
|
258
262
|
|
|
259
263
|
for (const name of entries) {
|
|
260
|
-
|
|
264
|
+
// Include both the pretty .log and the JSONL sidecar so support bundles
|
|
265
|
+
// give humans something readable and tooling something parseable.
|
|
266
|
+
const match =
|
|
267
|
+
LOG_FILE_PATTERN.exec(name) ?? LOG_FILE_JSON_PATTERN.exec(name);
|
|
261
268
|
if (!match) continue;
|
|
262
269
|
|
|
263
270
|
const fileDateStr = match[1];
|