@vellumai/vellum-gateway 0.10.4-staging.1 → 0.10.4
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/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +18 -0
- package/package.json +1 -1
- package/src/__tests__/contact-handlers.test.ts +32 -0
- package/src/__tests__/contact-prompt-submit.test.ts +105 -25
- package/src/__tests__/contacts-control-plane-proxy.test.ts +465 -0
- package/src/__tests__/contacts-info-joiner.test.ts +47 -0
- package/src/__tests__/guardian-bootstrap-binding.test.ts +28 -0
- package/src/auth/guardian-bootstrap.ts +7 -0
- package/src/db/connection.ts +6 -0
- package/src/db/contact-store.ts +89 -0
- package/src/feature-flag-registry.json +8 -16
- package/src/http/routes/contact-prompt.ts +22 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +161 -4
- package/src/ipc/contact-handlers.ts +16 -0
- package/src/ipc/ipc-health.test.ts +109 -0
- package/src/ipc/ipc-health.ts +82 -0
- package/src/risk/command-registry/commands/assistant.ts +8 -0
- package/src/verification/outbound-voice-verification-sync.ts +9 -0
- package/src/verification/voice-approval-sync.ts +8 -0
|
@@ -291,3 +291,21 @@ export const GetContactIpcResponseSchema = z.object({
|
|
|
291
291
|
export type GetContactIpcResponse = z.infer<
|
|
292
292
|
typeof GetContactIpcResponseSchema
|
|
293
293
|
>;
|
|
294
|
+
|
|
295
|
+
export const GetGuardianContactIpcParamsSchema = z
|
|
296
|
+
.object({})
|
|
297
|
+
.strict()
|
|
298
|
+
.default({});
|
|
299
|
+
|
|
300
|
+
export type GetGuardianContactIpcParams = z.infer<
|
|
301
|
+
typeof GetGuardianContactIpcParamsSchema
|
|
302
|
+
>;
|
|
303
|
+
|
|
304
|
+
export const GetGuardianContactIpcResponseSchema = z.object({
|
|
305
|
+
ok: z.boolean(),
|
|
306
|
+
guardianIds: z.array(z.string()),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
export type GetGuardianContactIpcResponse = z.infer<
|
|
310
|
+
typeof GetGuardianContactIpcResponseSchema
|
|
311
|
+
>;
|
package/package.json
CHANGED
|
@@ -94,6 +94,10 @@ const upsertVerifiedChannelHandler = contactRoutes.find(
|
|
|
94
94
|
(r) => r.method === "upsert_verified_channel",
|
|
95
95
|
)!.handler;
|
|
96
96
|
|
|
97
|
+
const getGuardianContactHandler = contactRoutes.find(
|
|
98
|
+
(r) => r.method === "get_guardian_contact",
|
|
99
|
+
)!.handler;
|
|
100
|
+
|
|
97
101
|
beforeAll(async () => {
|
|
98
102
|
await initGatewayDb();
|
|
99
103
|
});
|
|
@@ -349,6 +353,34 @@ describe("mark_channel_revoked IPC handler", () => {
|
|
|
349
353
|
});
|
|
350
354
|
});
|
|
351
355
|
|
|
356
|
+
describe("get_guardian_contact IPC handler", () => {
|
|
357
|
+
test("returns the guardian contact id(s) from the gateway DB", async () => {
|
|
358
|
+
seedContact("g1", "guardian");
|
|
359
|
+
seedContact("c1", "contact");
|
|
360
|
+
|
|
361
|
+
const res = (await getGuardianContactHandler({})) as {
|
|
362
|
+
ok: boolean;
|
|
363
|
+
guardianIds: string[];
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
expect(res.ok).toBe(true);
|
|
367
|
+
expect(res.guardianIds).toEqual(["g1"]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("excludes non-guardian contacts", async () => {
|
|
371
|
+
seedContact("c1", "contact");
|
|
372
|
+
seedContact("c2", "contact");
|
|
373
|
+
|
|
374
|
+
const res = (await getGuardianContactHandler({})) as {
|
|
375
|
+
ok: boolean;
|
|
376
|
+
guardianIds: string[];
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
expect(res.ok).toBe(true);
|
|
380
|
+
expect(res.guardianIds).toEqual([]);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
352
384
|
describe("upsert_verified_channel IPC handler", () => {
|
|
353
385
|
test("creates + verifies a new gateway channel and returns it", async () => {
|
|
354
386
|
const before = Date.now();
|
|
@@ -132,6 +132,40 @@ function makeRequest(body: Record<string, unknown>): Request {
|
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// IPC call inspectors
|
|
137
|
+
//
|
|
138
|
+
// The handler fires two IPC operations on a successful mutating submit: an
|
|
139
|
+
// `emit_event` { kind: "contacts_changed" } cache-invalidation broadcast plus
|
|
140
|
+
// the `resolve_contact_prompt` that unblocks the CLI. These helpers pick the
|
|
141
|
+
// right call regardless of order.
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function callsFor(
|
|
145
|
+
ipc: typeof ipcMock,
|
|
146
|
+
op: string,
|
|
147
|
+
): { body: Record<string, unknown> }[] {
|
|
148
|
+
return (ipc.mock.calls as any[][])
|
|
149
|
+
.filter((c) => c[0] === op)
|
|
150
|
+
.map((c) => c[1] as { body: Record<string, unknown> });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveCall(ipc: typeof ipcMock): { body: Record<string, unknown> } {
|
|
154
|
+
const calls = callsFor(ipc, "resolve_contact_prompt");
|
|
155
|
+
expect(calls).toHaveLength(1);
|
|
156
|
+
return calls[0];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function expectEmittedContactsChanged(ipc: typeof ipcMock): void {
|
|
160
|
+
const calls = callsFor(ipc, "emit_event");
|
|
161
|
+
expect(calls.length).toBeGreaterThanOrEqual(1);
|
|
162
|
+
expect(calls.some((c) => c.body.kind === "contacts_changed")).toBe(true);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function expectNoEmit(ipc: typeof ipcMock): void {
|
|
166
|
+
expect(callsFor(ipc, "emit_event")).toHaveLength(0);
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
// ---------------------------------------------------------------------------
|
|
136
170
|
// Suite setup
|
|
137
171
|
// ---------------------------------------------------------------------------
|
|
@@ -209,11 +243,12 @@ describe("handleContactPromptSubmit", () => {
|
|
|
209
243
|
expect(gwGuardian[0].role).toBe("guardian");
|
|
210
244
|
|
|
211
245
|
// IPC should have been called with the guardian contactId + gateway channel id.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
|
|
246
|
+
const ipcCall = resolveCall(ipcMock);
|
|
215
247
|
expect(ipcCall.body.contactId).toBe("guardian-1");
|
|
216
248
|
expect(ipcCall.body.channelId).toBe(gwChannels[0].id);
|
|
249
|
+
|
|
250
|
+
// A successful guardian bind invalidates the daemon guardian-id cache.
|
|
251
|
+
expectEmittedContactsChanged(ipcMock);
|
|
217
252
|
});
|
|
218
253
|
|
|
219
254
|
test("guardian prompt — reuses channel already bound to guardian", async () => {
|
|
@@ -252,9 +287,9 @@ describe("handleContactPromptSubmit", () => {
|
|
|
252
287
|
expect(gwChannels).toHaveLength(1);
|
|
253
288
|
expect(gwChannels[0].id).toBe("chan-1");
|
|
254
289
|
|
|
255
|
-
|
|
256
|
-
const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
|
|
290
|
+
const ipcCall = resolveCall(ipcMock);
|
|
257
291
|
expect(ipcCall.body.channelId).toBe("chan-1");
|
|
292
|
+
expectEmittedContactsChanged(ipcMock);
|
|
258
293
|
});
|
|
259
294
|
|
|
260
295
|
test("guardian prompt — 409 when channel already belongs to another contact", async () => {
|
|
@@ -310,8 +345,11 @@ describe("handleContactPromptSubmit", () => {
|
|
|
310
345
|
// IPC should have been called with an error so the CLI doesn't hang.
|
|
311
346
|
expect(ipcMock).toHaveBeenCalledTimes(1);
|
|
312
347
|
|
|
313
|
-
const ipcCall = (ipcMock
|
|
348
|
+
const ipcCall = resolveCall(ipcMock);
|
|
314
349
|
expect(typeof ipcCall.body.error).toBe("string");
|
|
350
|
+
|
|
351
|
+
// A 409 conflict mutated nothing — no cache-invalidation broadcast.
|
|
352
|
+
expectNoEmit(ipcMock);
|
|
315
353
|
});
|
|
316
354
|
|
|
317
355
|
test("guardian prompt — accepted even when assistant-DB mirror throws (gateway-first)", async () => {
|
|
@@ -377,9 +415,9 @@ describe("handleContactPromptSubmit", () => {
|
|
|
377
415
|
expect(gwChannels).toHaveLength(1);
|
|
378
416
|
expect(gwChannels[0].contactId).toBe(gwGuardians[0].id);
|
|
379
417
|
|
|
380
|
-
|
|
381
|
-
const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
|
|
418
|
+
const ipcCall = resolveCall(ipcMock);
|
|
382
419
|
expect(ipcCall.body.contactId).toBe(gwGuardians[0].id);
|
|
420
|
+
expectEmittedContactsChanged(ipcMock);
|
|
383
421
|
});
|
|
384
422
|
|
|
385
423
|
test("non-guardian prompt — creates new contact and channel (gateway-first)", async () => {
|
|
@@ -417,10 +455,12 @@ describe("handleContactPromptSubmit", () => {
|
|
|
417
455
|
expect(gwChannelRows[0].isPrimary).toBe(true);
|
|
418
456
|
|
|
419
457
|
// The channel id handed to resolve_contact_prompt matches the gateway row.
|
|
420
|
-
|
|
421
|
-
const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
|
|
458
|
+
const ipcCall = resolveCall(ipcMock);
|
|
422
459
|
expect(ipcCall.body.channelId).toBe(gwChannelRows[0].id);
|
|
423
460
|
expect(ipcCall.body.contactId).toBe(gwContactRows[0].id);
|
|
461
|
+
|
|
462
|
+
// A successful non-guardian upsert invalidates the daemon contact caches.
|
|
463
|
+
expectEmittedContactsChanged(ipcMock);
|
|
424
464
|
});
|
|
425
465
|
|
|
426
466
|
test("non-guardian prompt — accepted even when assistant-DB mirror throws (gateway-first)", async () => {
|
|
@@ -505,9 +545,9 @@ describe("handleContactPromptSubmit", () => {
|
|
|
505
545
|
// display_name not clobbered when displayName omitted from the body.
|
|
506
546
|
expect(gwContactRows[0].displayName).toBe("Alice");
|
|
507
547
|
|
|
508
|
-
|
|
509
|
-
const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
|
|
548
|
+
const ipcCall = resolveCall(ipcMock);
|
|
510
549
|
expect(ipcCall.body.contactId).toBe("contact-1");
|
|
550
|
+
expectEmittedContactsChanged(ipcMock);
|
|
511
551
|
});
|
|
512
552
|
|
|
513
553
|
test("non-guardian prompt — explicit null displayName is treated as omitted (preserves name, no 500)", async () => {
|
|
@@ -633,10 +673,9 @@ describe("handleContactPromptSubmit", () => {
|
|
|
633
673
|
expect(asChannels).toHaveLength(1);
|
|
634
674
|
expect(asChannels[0].contact_id).toBe("guardian-1");
|
|
635
675
|
|
|
636
|
-
const ipcCall = (ipcMock
|
|
637
|
-
body: Record<string, unknown>;
|
|
638
|
-
};
|
|
676
|
+
const ipcCall = resolveCall(ipcMock);
|
|
639
677
|
expect(ipcCall.body.channelId).toBe("chan-reuse");
|
|
678
|
+
expectEmittedContactsChanged(ipcMock);
|
|
640
679
|
});
|
|
641
680
|
|
|
642
681
|
test("guardian reuse — mirror-heal upsert throwing is non-fatal (still accepted, reuses channel)", async () => {
|
|
@@ -697,11 +736,10 @@ describe("handleContactPromptSubmit", () => {
|
|
|
697
736
|
expect(gwChannels[0].contactId).toBe("guardian-1");
|
|
698
737
|
|
|
699
738
|
// Daemon resolved with the existing channel id (success, not error).
|
|
700
|
-
const ipcCall = (ipcMock
|
|
701
|
-
body: Record<string, unknown>;
|
|
702
|
-
};
|
|
739
|
+
const ipcCall = resolveCall(ipcMock);
|
|
703
740
|
expect(ipcCall.body.channelId).toBe("chan-reuse-fail");
|
|
704
741
|
expect(ipcCall.body.error).toBeUndefined();
|
|
742
|
+
expectEmittedContactsChanged(ipcMock);
|
|
705
743
|
});
|
|
706
744
|
|
|
707
745
|
test("guardian bootstrap-create — gateway is authoritative; assistant mirror row exists without ACL role", async () => {
|
|
@@ -762,12 +800,14 @@ describe("handleContactPromptSubmit", () => {
|
|
|
762
800
|
expect(body.accepted).toBe(false);
|
|
763
801
|
|
|
764
802
|
// Daemon was notified with an error (not a success resolve with empty id).
|
|
765
|
-
|
|
766
|
-
const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as {
|
|
767
|
-
body: Record<string, unknown>;
|
|
768
|
-
};
|
|
803
|
+
const ipcCall = resolveCall(ipcMock);
|
|
769
804
|
expect(typeof ipcCall.body.error).toBe("string");
|
|
770
805
|
expect(ipcCall.body.channelId).toBeUndefined();
|
|
806
|
+
|
|
807
|
+
// The non-guardian upsert already committed (no rollback on this path)
|
|
808
|
+
// before the read-back miss, so the caches are still invalidated — the
|
|
809
|
+
// emit fires before the channel-id guard.
|
|
810
|
+
expectEmittedContactsChanged(ipcMock);
|
|
771
811
|
});
|
|
772
812
|
|
|
773
813
|
test("guardian bind — rolls back freshly-created guardian + 500 when channel can't be resolved", async () => {
|
|
@@ -807,9 +847,49 @@ describe("handleContactPromptSubmit", () => {
|
|
|
807
847
|
expect(gwGuardians).toHaveLength(0);
|
|
808
848
|
|
|
809
849
|
// Daemon notified with an error.
|
|
810
|
-
const ipcCall = (ipcMock
|
|
811
|
-
body: Record<string, unknown>;
|
|
812
|
-
};
|
|
850
|
+
const ipcCall = resolveCall(ipcMock);
|
|
813
851
|
expect(typeof ipcCall.body.error).toBe("string");
|
|
852
|
+
|
|
853
|
+
// The rolled-back bind mutated nothing net — no cache-invalidation broadcast.
|
|
854
|
+
expectNoEmit(ipcMock);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("guardian bind — existing guardian, read-back miss still emits (committed bind, no rollback)", async () => {
|
|
858
|
+
// An existing guardian is bound to a NEW channel; the post-bind resolve
|
|
859
|
+
// misses. rollbackCreatedContact is a no-op (the guardian wasn't created
|
|
860
|
+
// here), so the committed channel bind persists and the caches MUST be
|
|
861
|
+
// invalidated despite the 500.
|
|
862
|
+
seedGuardian();
|
|
863
|
+
const spy = spyOn(
|
|
864
|
+
ContactStore.prototype,
|
|
865
|
+
"getChannelsForContact",
|
|
866
|
+
).mockReturnValue([]);
|
|
867
|
+
|
|
868
|
+
let res: Response;
|
|
869
|
+
try {
|
|
870
|
+
res = await handleContactPromptSubmit(
|
|
871
|
+
makeRequest({
|
|
872
|
+
requestId: "req-existing-noresolve",
|
|
873
|
+
address: "+15557778888",
|
|
874
|
+
channelType: "phone",
|
|
875
|
+
role: "guardian",
|
|
876
|
+
}),
|
|
877
|
+
);
|
|
878
|
+
} finally {
|
|
879
|
+
spy.mockRestore();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
expect(res.status).toBe(500);
|
|
883
|
+
|
|
884
|
+
// The existing guardian was NOT rolled back.
|
|
885
|
+
const gwGuardians = getGatewayDb()
|
|
886
|
+
.select()
|
|
887
|
+
.from(gwContacts)
|
|
888
|
+
.where(eq(gwContacts.role, "guardian"))
|
|
889
|
+
.all();
|
|
890
|
+
expect(gwGuardians).toHaveLength(1);
|
|
891
|
+
|
|
892
|
+
// Committed bind on an existing guardian — caches invalidated despite 500.
|
|
893
|
+
expectEmittedContactsChanged(ipcMock);
|
|
814
894
|
});
|
|
815
895
|
});
|