@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.10.4-staging.1",
3
+ "version": "0.10.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- expect(ipcMock).toHaveBeenCalledTimes(1);
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.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
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.mock.calls as any[][])[0][1] as {
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.mock.calls as any[][])[0][1] as {
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
- expect(ipcMock).toHaveBeenCalledTimes(1);
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.mock.calls as any[][])[0][1] as {
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
  });