@vellumai/assistant 0.10.4-dev.202607021247.94bb87f → 0.10.4-dev.202607021328.aaa3f0c

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 (73) hide show
  1. package/ARCHITECTURE.md +21 -24
  2. package/README.md +6 -9
  3. package/docs/trusted-contact-access.md +4 -4
  4. package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +233 -0
  5. package/node_modules/@vellumai/gateway-client/src/index.ts +27 -0
  6. package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +216 -0
  7. package/openapi.yaml +41 -16
  8. package/package.json +1 -1
  9. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +0 -1
  10. package/src/__tests__/channel-readiness-service.test.ts +0 -6
  11. package/src/__tests__/channel-readiness-slack-remote.test.ts +0 -4
  12. package/src/__tests__/helpers/seed-contact-channel.ts +0 -2
  13. package/src/__tests__/inbound-invite-redemption.test.ts +72 -341
  14. package/src/__tests__/invite-redeemed-ipc.test.ts +97 -0
  15. package/src/__tests__/invite-routes-http.test.ts +246 -420
  16. package/src/__tests__/phone.test.ts +1 -15
  17. package/src/__tests__/plugin-bootstrap.test.ts +6 -191
  18. package/src/__tests__/plugin-types.test.ts +0 -1
  19. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +0 -1
  20. package/src/__tests__/relay-server.test.ts +338 -66
  21. package/src/calls/__tests__/gateway-invite-reader.test.ts +170 -0
  22. package/src/calls/__tests__/relay-setup-router.test.ts +126 -97
  23. package/src/calls/gateway-invite-reader.ts +98 -0
  24. package/src/calls/media-stream-server.ts +12 -1
  25. package/src/calls/relay-server.ts +8 -9
  26. package/src/calls/relay-setup-router.ts +18 -39
  27. package/src/calls/relay-verification.ts +13 -20
  28. package/src/calls/twilio-routes.ts +1 -1
  29. package/src/channels/config.ts +0 -42
  30. package/src/contacts/__tests__/member-write-relay.test.ts +0 -2
  31. package/src/contacts/contact-store.ts +0 -5
  32. package/src/contacts/contacts-write.ts +0 -2
  33. package/src/contacts/member-write-relay.ts +0 -2
  34. package/src/contacts/types.ts +0 -1
  35. package/src/daemon/external-plugins-bootstrap.ts +10 -37
  36. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +1 -1
  37. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +26 -14
  38. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +27 -14
  39. package/src/daemon/handlers/__tests__/config-channels.test.ts +0 -1
  40. package/src/daemon/handlers/config-a2a.ts +7 -10
  41. package/src/ipc/assistant-server.ts +2 -2
  42. package/src/ipc/routes/__tests__/invite-ipc-routes.test.ts +60 -4
  43. package/src/ipc/routes/invite-ipc-routes.ts +53 -39
  44. package/src/persistence/a2a-invite-store.ts +205 -0
  45. package/src/persistence/migrations/315-create-a2a-invites.ts +58 -0
  46. package/src/persistence/migrations/316-drop-contact-channels-invite-id.ts +28 -0
  47. package/src/persistence/migrations/__tests__/315-create-a2a-invites.test.ts +161 -0
  48. package/src/persistence/migrations/__tests__/316-drop-contact-channels-invite-id.test.ts +156 -0
  49. package/src/persistence/schema/a2a.ts +14 -0
  50. package/src/persistence/schema/contacts.ts +0 -31
  51. package/src/persistence/steps.ts +4 -0
  52. package/src/plugins/types.ts +0 -7
  53. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +0 -1
  54. package/src/runtime/auth/__tests__/route-policy.test.ts +31 -0
  55. package/src/runtime/channel-invite-transport.ts +2 -2
  56. package/src/runtime/channel-invite-transports/voice.ts +1 -1
  57. package/src/runtime/channel-readiness-service.ts +3 -5
  58. package/src/runtime/invite-service.ts +104 -361
  59. package/src/runtime/routes/__tests__/contact-routes.test.ts +0 -1
  60. package/src/runtime/routes/__tests__/invite-relay-routes.test.ts +119 -30
  61. package/src/runtime/routes/contact-routes.ts +155 -91
  62. package/src/runtime/routes/inbound-message-handler.ts +3 -3
  63. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +23 -59
  64. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +9 -495
  65. package/src/runtime/trust-verdict-consumer.ts +0 -1
  66. package/src/util/phone.ts +1 -8
  67. package/src/__tests__/invite-redemption-service.test.ts +0 -1180
  68. package/src/__tests__/invite-service-ipc.test.ts +0 -350
  69. package/src/__tests__/voice-invite-redemption.test.ts +0 -702
  70. package/src/persistence/invite-store.ts +0 -481
  71. package/src/runtime/invite-redemption-service.ts +0 -828
  72. package/src/runtime/invite-redemption-templates.ts +0 -46
  73. package/src/util/voice-code.ts +0 -31
package/ARCHITECTURE.md CHANGED
@@ -429,9 +429,8 @@ External users who are not the guardian can gain access to the assistant through
429
429
  | `src/runtime/routes/access-request-decision.ts` | Guardian decision → verification session creation |
430
430
  | `src/runtime/routes/guardian-approval-interception.ts` | Routes guardian decisions (button + conversational) to access request handler |
431
431
  | `src/runtime/channel-verification-service.ts` | Verification session lifecycle, identity binding, rate limiting |
432
- | `src/runtime/routes/contact-routes.ts` | HTTP API handlers for contact and channel management |
433
- | `src/runtime/routes/invite-routes.ts` | HTTP API handlers for invite management |
434
- | `src/runtime/invite-service.ts` | Business logic for invite operations |
432
+ | `src/runtime/routes/contact-routes.ts` | HTTP/IPC handlers for contacts, channels, and invites (invite ops relay to gateway IPC) |
433
+ | `src/runtime/invite-service.ts` | Daemon-owned invite presentation (share link, guardian instruction) over gateway-minted invites |
435
434
  | `src/contacts/contact-store.ts` | Contact read queries — lookup, search, list, and channel operations |
436
435
  | `src/memory/guardian-approvals.ts` | Approval request persistence |
437
436
  | `src/channels/channel-verification-sessions.ts` | Verification challenge persistence |
@@ -455,11 +454,12 @@ A complementary access-granting flow where the guardian proactively creates a sh
455
454
  │ • extractInboundToken(payload) → token | undefined │
456
455
  │ Registered: Telegram │ Deferred: Slack, Voice │
457
456
  ├─────────────────────────────────────────────────────────────┤
458
- │ Core Redemption Engine (invite-redemption-service.ts)
459
- Channel-agnostic token validation, expiry, use-count,
460
- channel-match enforcement, contact activation/reactivation
457
+ │ Core Redemption Engine (gateway-native)
458
+ gateway/src/verification/invite-redemption.ts
459
+ Token/code validation, expiry, use-count, channel-match
460
+ │ enforcement, atomic claim, gateway ACL activation │
461
461
  │ Returns: InviteRedemptionOutcome (discriminated union) │
462
- Reply templates: invite-redemption-templates.ts
462
+ Daemon mirrors contact info via the invite_redeemed event
463
463
  └─────────────────────────────────────────────────────────────┘
464
464
  ```
465
465
 
@@ -467,16 +467,16 @@ A complementary access-granting flow where the guardian proactively creates a sh
467
467
 
468
468
  1. Guardian asks the assistant to create an invite via desktop chat.
469
469
  2. `guardian-invite-intent.ts` detects the intent and rewrites the message to force-load the `contacts` skill.
470
- 3. The skill calls the ingress HTTP API to create an invite token, then calls the Telegram transport adapter to build a deep link: `https://t.me/<bot>?start=iv_<token>`.
470
+ 3. The skill calls the invites HTTP API; the daemon relays the mint to the gateway (`invites_create` IPC), which writes the canonical `ingress_invites` row, then the daemon's Telegram transport adapter builds a deep link: `https://t.me/<bot>?start=iv_<token>`.
471
471
  4. Guardian shares the link with the invitee out-of-band.
472
472
  5. Invitee clicks the link, opening Telegram which sends `/start iv_<token>` to the bot.
473
- 6. The gateway forwards the message to `/channels/inbound`. The inbound handler calls `getInviteAdapterRegistry().get('telegram').extractInboundToken()` to parse the `iv_` token.
474
- 7. The token is redeemed via `invite-redemption-service.ts`, which validates, activates the contact, and returns a `redeemed` outcome.
473
+ 6. The gateway intercepts the `/start iv_<token>` message at ingress (before any daemon forwarding) and redeems the token against its canonical `ingress_invites` row.
474
+ 7. The gateway's redemption engine validates, activates the channel in the gateway ACL, and notifies the daemon (`invite_redeemed`) to mirror the contact info locally.
475
475
  8. A deterministic welcome message is delivered to the invitee (bypasses the LLM pipeline).
476
476
 
477
477
  **Token prefix convention:** The `iv_` prefix distinguishes invite tokens from `gv_` (guardian verification) tokens. Both use the same Telegram `/start` deep-link mechanism but are routed to different handlers.
478
478
 
479
- **Inbound intercept points:** Invite token extraction runs early in the inbound handler, before ACL denial, so valid invites short-circuit the contact check. Two intercept branches handle: (a) unknown contacts — the invite creates their first contact record; (b) inactive contacts (revoked/pending) — the invite reactivates them.
479
+ **Inbound intercept points:** Invite token/code extraction runs at gateway ingress for non-member senders, before ACL denial, so valid invites short-circuit the contact check. The engine handles: (a) unknown contacts — the invite creates their first channel record; (b) inactive contacts (revoked) — the invite reactivates them; blocked channels are never reactivated.
480
480
 
481
481
  **Channel adapter status:**
482
482
 
@@ -488,21 +488,21 @@ A complementary access-granting flow where the guardian proactively creates a sh
488
488
 
489
489
  ### Voice Invite Flow (invite_redemption_pending)
490
490
 
491
- Voice invites use a short numeric code (4-10 digits, default 6) instead of a URL token. The guardian creates an invite bound to the invitee's E.164 phone number; the invitee redeems it by entering the code during an inbound voice call.
491
+ Voice invites use a short numeric code (6 digits) instead of a URL token. The guardian creates an invite bound to the invitee's E.164 phone number; the invitee redeems it by entering the code during an inbound voice call.
492
492
 
493
493
  **Creation flow:**
494
494
 
495
495
  1. Guardian creates a voice invite via `POST /v1/contacts/invites` with `sourceChannel: "phone"` and `expectedExternalUserId` (E.164 phone).
496
- 2. `invite-service.ts` generates a cryptographically random numeric code (`generateVoiceCode`), hashes it with SHA-256 (`hashVoiceCode`), and stores only the hash.
496
+ 2. The daemon relays the mint to the gateway (`invites_create` IPC), which generates a cryptographically random numeric code and stores only its SHA-256 hash on the canonical `ingress_invites` row.
497
497
  3. The one-time plaintext `voiceCode` is returned in the creation response. The raw token is NOT returned for voice invites — redemption uses the identity-bound code flow exclusively.
498
498
  4. Guardian communicates the code to the invitee out-of-band.
499
499
 
500
500
  **Call-time redemption subflow (`invite_redemption_pending`):**
501
501
 
502
502
  1. Unknown caller dials in. `relay-server.ts` resolves trust via `resolveActorTrust`. Caller is `unknown`, no pending guardian challenge.
503
- 2. The relay checks `findActiveVoiceInvites` for invites bound to the caller's phone number.
504
- 3. If active, non-expired invites exist, the relay enters the `invite_redemption_pending` state (reuses the `verification_pending` connection state) and prompts the caller with personalized copy: `Welcome <friend-name>. Please enter the 6-digit code that <guardian-name> provided you to verify your identity.`
505
- 4. `redeemVoiceInviteCode` validates: identity match, code hash match, expiry, use count. On success, the contact is activated and the call transitions to the normal call flow.
503
+ 2. The relay asks the gateway for the active voice invite bound to the caller's phone number (`get_active_voice_invite` IPC via `calls/gateway-invite-reader.ts`; fail-soft — any gateway failure falls through to the unverified path).
504
+ 3. If an active, non-expired invite exists, the relay enters the `invite_redemption_pending` state (reuses the `verification_pending` connection state) and prompts the caller with personalized copy: `Welcome <friend-name>. Please enter the 6-digit code that <guardian-name> provided you to verify your identity.`
505
+ 4. The gateway's `redeem_voice_invite` engine validates: identity match, code hash match, expiry, use count. On success, the phone channel is activated in the gateway ACL and the call transitions to the normal call flow.
506
506
  5. On invalid/expired code, the caller hears deterministic failure copy: `Sorry, the code you provided is incorrect or has since expired. Please ask <guardian-name> for a new code. Goodbye.` and the call ends immediately.
507
507
 
508
508
  **Security invariants:**
@@ -516,18 +516,15 @@ Voice invites use a short numeric code (4-10 digits, default 6) instead of a URL
516
516
 
517
517
  | File | Purpose |
518
518
  | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
519
- | `src/runtime/invite-redemption-service.ts` | Core redemption engine token validation, voice code redemption, contact activation, discriminated-union outcomes |
520
- | `src/runtime/invite-redemption-templates.ts` | Deterministic reply templates for each redemption outcome |
519
+ | `gateway/src/verification/invite-redemption.ts` (gateway) | Core redemption engine + gateway-ingress token/code intercept — validation, atomic claim, ACL activation, discriminated-union outcomes |
521
520
  | `src/runtime/channel-invite-transport.ts` | Transport adapter registry with `buildShareableInvite` / `extractInboundToken` interface |
522
521
  | `src/runtime/channel-invite-transports/telegram.ts` | Telegram adapter — `t.me/<bot>?start=iv_<token>` deep links, `/start iv_<token>` extraction |
523
522
  | `src/runtime/channel-invite-transports/voice.ts` | Voice transport adapter — code-based redemption metadata |
524
523
  | `src/daemon/guardian-invite-intent.ts` | Intent detection — routes create/list/revoke requests into the contacts skill |
525
- | `src/runtime/invite-service.ts` | Shared business logic for invite operations (used by HTTP routes) |
526
- | `src/runtime/routes/invite-routes.ts` | HTTP API handlers for invite management including voice invite creation and redemption |
527
- | `src/runtime/routes/inbound-message-handler.ts` | Invite token intercept in the inbound flow (unknown-contact and inactive-contact branches) |
524
+ | `src/runtime/invite-service.ts` | Daemon-owned invite presentation (share link, guardian instruction, channel handle) over gateway-minted invites |
525
+ | `src/runtime/routes/contact-routes.ts` | HTTP/IPC invite handlers relay mint/list/revoke/redeem to the gateway's invite IPC routes |
528
526
  | `src/calls/relay-server.ts` | Voice relay state machine — `invite_redemption_pending` subflow (always-on canonical behavior) |
529
- | `src/util/voice-code.ts` | Cryptographic voice code generation and SHA-256 hashing |
530
- | `src/persistence/invite-store.ts` | Invite persistence including `findActiveVoiceInvites` for identity-bound lookup |
527
+ | `src/calls/gateway-invite-reader.ts` | Gateway IPC read of the active voice invite for a caller identity |
531
528
 
532
529
  ### Voice Inbound Security Model (Canonical)
533
530
 
@@ -550,7 +547,7 @@ resolveActorTrust() → trustClass
550
547
  |
551
548
  ┌────────────────────┼──────────────────────┐
552
549
  | | |
553
- pendingChallenge? activeVoiceInvites? no invite, no challenge
550
+ pendingChallenge? activeVoiceInvite? no invite, no challenge
554
551
  | | |
555
552
  v v v
556
553
  Guardian verification Invite redemption Name capture +
package/README.md CHANGED
@@ -309,7 +309,6 @@ Guardian verification and ingress contact management are complementary but indep
309
309
  | `src/runtime/trust-context-resolver.ts` | Actor role classification: guardian / non-guardian / unverified_channel |
310
310
  | `src/runtime/routes/inbound-message-handler.ts` | Ingress ACL enforcement, verification-code intercept, escalation creation |
311
311
  | `src/contacts/contact-store.ts` | Contact + channel CRUD: `findContactChannel`, `upsertContact`, `updateChannelStatus`, `searchContacts` |
312
- | `src/memory/invite-store.ts` | Invite lifecycle: `createInvite`, `redeemInvite` (atomically creates member record) |
313
312
  | `src/memory/channel-verification-sessions.ts` | Guardian binding types and verification challenge persistence |
314
313
  | `src/memory/guardian-approvals.ts` | Approval request persistence |
315
314
  | `src/runtime/verification-outbound-actions.ts` | Shared business logic for outbound verification (start/resend/cancel) |
@@ -383,7 +382,7 @@ External users join through **invite tokens**. There are two invite flows:
383
382
  1. **Guardian requests invite** — The guardian asks the assistant (via desktop chat) to create a Telegram invite link. The `guardian-invite-intent.ts` module detects the intent and routes the request into the `contacts` skill.
384
383
  2. **Invite creation** — The skill creates an invite token via the ingress HTTP API, looks up the Telegram bot username from the integration config endpoint, and constructs a shareable deep link: `https://t.me/<bot>?start=iv_<token>`.
385
384
  3. **Guardian shares link** — The guardian copies the deep link and shares it with the invitee through any messaging channel.
386
- 4. **Invitee redeems** — The invitee clicks the link, which opens Telegram and sends `/start iv_<token>` to the bot. The inbound message handler extracts the token via the transport adapter, redeems it through the invite redemption service, and auto-creates an active member record.
385
+ 4. **Invitee redeems** — The invitee clicks the link, which opens Telegram and sends `/start iv_<token>` to the bot. The gateway intercepts the token at ingress, redeems it against its canonical invite row, activates the member channel in the gateway ACL, and notifies the daemon to mirror the contact info.
387
386
  5. **Access granted** — The invitee receives a welcome message and all subsequent messages pass the ingress ACL.
388
387
 
389
388
  The `iv_` prefix distinguishes invite tokens from `gv_` (guardian verification) tokens, which use the same Telegram `/start` deep-link mechanism.
@@ -392,7 +391,7 @@ The `iv_` prefix distinguishes invite tokens from `gv_` (guardian verification)
392
391
 
393
392
  The invite redemption system uses a three-layer architecture:
394
393
 
395
- - **Core redemption engine** (`invite-redemption-service.ts`) — Channel-agnostic business logic that validates tokens, enforces expiry/use-count/channel-match constraints, handles member reactivation, and returns a discriminated-union `InviteRedemptionOutcome`. Deterministic reply templates (`invite-redemption-templates.ts`) map each outcome to a user-facing message without passing through the LLM.
394
+ - **Core redemption engine** (gateway-native, `gateway/src/verification/invite-redemption.ts`) — Channel-agnostic business logic that validates tokens and codes, enforces expiry/use-count/channel-match constraints, handles member reactivation, and returns a discriminated-union `InviteRedemptionOutcome`. Deterministic reply templates map each outcome to a user-facing message without passing through the LLM.
396
395
  - **Channel transport adapters** (`channel-invite-transport.ts` + `channel-invite-transports/`) — A registry of per-channel adapters that know how to build shareable links (`buildShareLink`) and extract inbound tokens (`extractInboundToken`). Adapters are implemented for Telegram, Voice, Email, WhatsApp, and Slack.
397
396
  - **Conversational orchestration** (`guardian-invite-intent.ts`) — Pattern-based intent detection that intercepts guardian invite management requests (create, list, revoke) in the session pipeline and forces immediate entry into the `contacts` skill, bypassing the normal agent loop.
398
397
 
@@ -422,16 +421,14 @@ If no guardian binding exists, escalation fails closed — the message is denied
422
421
 
423
422
  | File | Purpose |
424
423
  | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
425
- | `src/memory/invite-store.ts` | CRUD for invite tokens with SHA-256 hashing and expiry |
426
424
  | `src/contacts/contact-store.ts` | Contact + channel CRUD with policy enforcement |
427
- | `src/daemon/handlers/config-inbox.ts` | HTTP handlers for invite operations |
428
- | `src/runtime/routes/channel-routes.ts` | ACL enforcement point — member lookup, policy check, escalation creation |
429
- | `src/runtime/invite-redemption-service.ts` | Core redemption engine — token validation, member creation, discriminated-union outcomes |
430
- | `src/runtime/invite-redemption-templates.ts` | Deterministic reply templates for each redemption outcome |
425
+ | `src/runtime/routes/contact-routes.ts` | HTTP/IPC invite handlers relay mint/list/revoke/redeem to the gateway's invite IPC routes |
426
+ | `src/runtime/routes/inbound-message-handler.ts` | ACL enforcement point — member lookup, policy check, escalation creation |
427
+ | `gateway/src/verification/invite-redemption.ts` (gateway) | Core redemption engine — token/code validation, atomic claim, ACL activation, discriminated-union outcomes |
431
428
  | `src/runtime/channel-invite-transport.ts` | Transport adapter registry — `buildShareableInvite` / `extractInboundToken` per channel |
432
429
  | `src/runtime/channel-invite-transports/telegram.ts` | Telegram adapter — builds `t.me/<bot>?start=iv_<token>` deep links, extracts `iv_` tokens from `/start` commands |
433
430
  | `src/daemon/guardian-invite-intent.ts` | Intent detection — routes guardian invite management requests into the `contacts` skill |
434
- | `src/runtime/invite-service.ts` | Shared business logic for invite and contact operations |
431
+ | `src/runtime/invite-service.ts` | Daemon-owned invite presentation (share link, guardian instruction) over gateway-minted invites |
435
432
 
436
433
  ## Database
437
434
 
@@ -114,11 +114,11 @@ No member record is created. No verification session is created.
114
114
 
115
115
  ### Invites (alternative path)
116
116
 
117
- The `assistant_ingress_invites` table supports a parallel invite-based onboarding path. An invite carries a SHA-256 hashed token and can be redeemed via `redeemInvite()`, which atomically creates an active contact channel record. This path is distinct from the trusted contact flow but serves the same end state: an active `contact_channels` entry with `status: 'active'` and `policy: 'allow'`.
117
+ Invites are gateway-native: the gateway's `ingress_invites` table is the sole invite store, and its redemption engine (`gateway/src/verification/invite-redemption.ts`) validates the SHA-256 hashed token, atomically claims the row, and activates the channel in the gateway ACL. The daemon only mirrors the contact/channel identity locally via the `invite_redeemed` event. This path is distinct from the trusted contact flow but serves the same end state: an active member channel.
118
118
 
119
- | Table | Purpose in trusted contact flow |
120
- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
121
- | `assistant_ingress_invites` | Not used in the guardian-mediated flow. Available as an alternative for direct invite links (e.g., guardian shares a URL instead of going through the approval + verification flow). |
119
+ | Table | Purpose in trusted contact flow |
120
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
121
+ | `ingress_invites` (gateway DB) | Not used in the guardian-mediated flow. Available as an alternative for direct invite links (e.g., guardian shares a URL instead of going through the approval + verification flow). |
122
122
 
123
123
  ### Voice In-Call Guardian Approval (friend-initiated)
124
124
 
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Tests for the shared invite contract.
3
+ *
4
+ * The pinned hash vectors below define the hash compatibility contract:
5
+ * `hashInviteToken` and `hashInviteCode` must produce exactly these outputs
6
+ * because invite hashes already stored in the assistant and gateway DBs were
7
+ * produced with them. Do not update the vectors to "fix" a failure — a
8
+ * mismatch means the hash scheme broke.
9
+ */
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+
13
+ import {
14
+ ActiveVoiceInviteSchema,
15
+ generateInviteCode,
16
+ generateInviteToken,
17
+ GetActiveVoiceInviteRequestSchema,
18
+ hashInviteCode,
19
+ hashInviteToken,
20
+ INVITE_CODE_REDEMPTION_CHANNELS,
21
+ InviteRedemptionOutcomeSchema,
22
+ isInviteCodeRedemptionEnabled,
23
+ isValidE164,
24
+ RedeemInviteByCodeRequestSchema,
25
+ RedeemInviteByTokenRequestSchema,
26
+ RedeemVoiceInviteRequestSchema,
27
+ type InviteRedemptionOutcome,
28
+ } from "../invite-contract.js";
29
+
30
+ describe("hash helpers — pinned compatibility vectors", () => {
31
+ test("hashInviteToken matches invite-store.ts hashToken output", () => {
32
+ expect(hashInviteToken("test-raw-token")).toBe(
33
+ "7ef58e99b3406554f5ce0c0055b8ed3ea4db2c8a36613092d644c57c6a033330",
34
+ );
35
+ expect(hashInviteToken("AbC123_-xyz")).toBe(
36
+ "860c47c13bb8d13decd6815b20be7de8cf11597e4b704af58b00048e90a5a337",
37
+ );
38
+ });
39
+
40
+ test("hashInviteCode matches voice-code.ts hashVoiceCode output", () => {
41
+ expect(hashInviteCode("123456")).toBe(
42
+ "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92",
43
+ );
44
+ expect(hashInviteCode("0000")).toBe(
45
+ "9af15b336e6a9619928537df30b2e6a2376569fcf9d7e773eccede65606529a0",
46
+ );
47
+ expect(hashInviteCode("987654321")).toBe(
48
+ "8a9bcf1e51e812d0af8465a8dbcc9f741064bf0af3b3d08e6b0246437c19f7fb",
49
+ );
50
+ });
51
+ });
52
+
53
+ describe("generateInviteToken", () => {
54
+ test("produces a 43-character base64url token", () => {
55
+ const token = generateInviteToken();
56
+ expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
57
+ });
58
+
59
+ test("produces unique tokens", () => {
60
+ expect(generateInviteToken()).not.toBe(generateInviteToken());
61
+ });
62
+ });
63
+
64
+ describe("generateInviteCode", () => {
65
+ test("defaults to 6 digits with no leading zero", () => {
66
+ const code = generateInviteCode();
67
+ expect(code).toMatch(/^[1-9]\d{5}$/);
68
+ });
69
+
70
+ test("honors the digit count at both bounds", () => {
71
+ expect(generateInviteCode(4)).toMatch(/^[1-9]\d{3}$/);
72
+ expect(generateInviteCode(10)).toMatch(/^[1-9]\d{9}$/);
73
+ });
74
+
75
+ test("rejects out-of-range digit counts", () => {
76
+ expect(() => generateInviteCode(3)).toThrow();
77
+ expect(() => generateInviteCode(11)).toThrow();
78
+ });
79
+ });
80
+
81
+ describe("isValidE164", () => {
82
+ test("valid E.164 number", () => {
83
+ expect(isValidE164("+15551234567")).toBe(true);
84
+ });
85
+
86
+ test("missing + prefix", () => {
87
+ expect(isValidE164("5551234567")).toBe(false);
88
+ });
89
+
90
+ test("too short", () => {
91
+ expect(isValidE164("+123")).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe("invite-code channel gating", () => {
96
+ test("allows exactly the redemption-enabled channels", () => {
97
+ expect([...INVITE_CODE_REDEMPTION_CHANNELS].sort()).toEqual([
98
+ "email",
99
+ "slack",
100
+ "telegram",
101
+ "whatsapp",
102
+ ]);
103
+ });
104
+
105
+ test("isInviteCodeRedemptionEnabled matches the allowlist", () => {
106
+ for (const channel of ["telegram", "whatsapp", "slack", "email"]) {
107
+ expect(isInviteCodeRedemptionEnabled(channel)).toBe(true);
108
+ }
109
+ for (const channel of ["vellum", "platform", "phone", "a2a", "nope"]) {
110
+ expect(isInviteCodeRedemptionEnabled(channel)).toBe(false);
111
+ }
112
+ });
113
+ });
114
+
115
+ const fullOutcome: InviteRedemptionOutcome = {
116
+ inviteId: "inv-1",
117
+ contactId: "c-1",
118
+ sourceChannel: "telegram",
119
+ memberExternalUserId: "tg-user-1",
120
+ memberExternalChatId: "tg-chat-1",
121
+ displayName: "Member Name",
122
+ username: "member",
123
+ sourceConversationId: "conv-1",
124
+ result: "redeemed",
125
+ };
126
+
127
+ describe("InviteRedemptionOutcomeSchema", () => {
128
+ test("round-trips a fully-populated outcome", () => {
129
+ expect(InviteRedemptionOutcomeSchema.parse(fullOutcome)).toEqual(
130
+ fullOutcome,
131
+ );
132
+ });
133
+
134
+ test("parses a minimal outcome", () => {
135
+ const minimal = {
136
+ inviteId: "inv-1",
137
+ contactId: "c-1",
138
+ sourceChannel: "slack",
139
+ memberExternalUserId: "U123",
140
+ result: "already_member",
141
+ } satisfies InviteRedemptionOutcome;
142
+ expect(InviteRedemptionOutcomeSchema.parse(minimal)).toEqual(minimal);
143
+ });
144
+
145
+ test("rejects an invalid result", () => {
146
+ expect(() =>
147
+ InviteRedemptionOutcomeSchema.parse({
148
+ ...fullOutcome,
149
+ result: "definitely_not_a_result",
150
+ }),
151
+ ).toThrow();
152
+ });
153
+ });
154
+
155
+ describe("invite IPC request schemas", () => {
156
+ test("RedeemInviteByCodeRequestSchema round-trips full and minimal requests", () => {
157
+ const full = {
158
+ code: "123456",
159
+ sourceChannel: "telegram",
160
+ externalUserId: "tg-user-1",
161
+ externalChatId: "tg-chat-1",
162
+ displayName: "Member Name",
163
+ username: "member",
164
+ };
165
+ expect(RedeemInviteByCodeRequestSchema.parse(full)).toEqual(full);
166
+ const minimal = { code: "123456", sourceChannel: "email" };
167
+ expect(RedeemInviteByCodeRequestSchema.parse(minimal)).toEqual(minimal);
168
+ expect(() =>
169
+ RedeemInviteByCodeRequestSchema.parse({ code: "", sourceChannel: "" }),
170
+ ).toThrow();
171
+ });
172
+
173
+ test("RedeemInviteByTokenRequestSchema round-trips full and minimal requests", () => {
174
+ const full = {
175
+ token: "raw-token",
176
+ sourceChannel: "slack",
177
+ externalUserId: "U123",
178
+ externalChatId: "D456",
179
+ displayName: "Member Name",
180
+ username: "member",
181
+ };
182
+ expect(RedeemInviteByTokenRequestSchema.parse(full)).toEqual(full);
183
+ const minimal = { token: "raw-token", sourceChannel: "slack" };
184
+ expect(RedeemInviteByTokenRequestSchema.parse(minimal)).toEqual(minimal);
185
+ expect(() =>
186
+ RedeemInviteByTokenRequestSchema.parse({ sourceChannel: "slack" }),
187
+ ).toThrow();
188
+ });
189
+
190
+ test("RedeemVoiceInviteRequestSchema requires caller and code", () => {
191
+ const request = { callerExternalUserId: "+15555550100", code: "123456" };
192
+ expect(RedeemVoiceInviteRequestSchema.parse(request)).toEqual(request);
193
+ expect(() =>
194
+ RedeemVoiceInviteRequestSchema.parse({ callerExternalUserId: "+1555" }),
195
+ ).toThrow();
196
+ });
197
+
198
+ test("GetActiveVoiceInviteRequestSchema requires the caller", () => {
199
+ const request = { callerExternalUserId: "+15555550100" };
200
+ expect(GetActiveVoiceInviteRequestSchema.parse(request)).toEqual(request);
201
+ expect(() => GetActiveVoiceInviteRequestSchema.parse({})).toThrow();
202
+ });
203
+ });
204
+
205
+ describe("ActiveVoiceInviteSchema", () => {
206
+ test("round-trips populated and null display fields", () => {
207
+ const populated = {
208
+ inviteId: "inv-1",
209
+ inviteeName: "Friend",
210
+ guardianName: "Guardian",
211
+ codeDigits: 6,
212
+ };
213
+ expect(ActiveVoiceInviteSchema.parse(populated)).toEqual(populated);
214
+ const anonymous = {
215
+ inviteId: "inv-2",
216
+ inviteeName: null,
217
+ guardianName: null,
218
+ codeDigits: 6,
219
+ };
220
+ expect(ActiveVoiceInviteSchema.parse(anonymous)).toEqual(anonymous);
221
+ });
222
+
223
+ test("rejects a non-integer codeDigits", () => {
224
+ expect(() =>
225
+ ActiveVoiceInviteSchema.parse({
226
+ inviteId: "inv-1",
227
+ inviteeName: null,
228
+ guardianName: null,
229
+ codeDigits: 6.5,
230
+ }),
231
+ ).toThrow();
232
+ });
233
+ });
@@ -92,6 +92,33 @@ export type {
92
92
  TrustVerdict,
93
93
  } from "./trust-verdict-contract.js";
94
94
 
95
+ // Invite contract (shared gateway ↔ daemon) — hash/generate helpers,
96
+ // channel gating, redemption outcome + invite IPC schemas
97
+ export {
98
+ ActiveVoiceInviteSchema,
99
+ generateInviteCode,
100
+ generateInviteToken,
101
+ GetActiveVoiceInviteRequestSchema,
102
+ hashInviteCode,
103
+ hashInviteToken,
104
+ InviteRedemptionOutcomeSchema,
105
+ isInviteCodeRedemptionEnabled,
106
+ isValidE164,
107
+ RedeemInviteByCodeRequestSchema,
108
+ RedeemInviteByTokenRequestSchema,
109
+ RedeemVoiceInviteRequestSchema,
110
+ } from "./invite-contract.js";
111
+
112
+ export type {
113
+ ActiveVoiceInvite,
114
+ GetActiveVoiceInviteRequest,
115
+ InviteRedemptionOutcome,
116
+ InviteRedemptionResult,
117
+ RedeemInviteByCodeRequest,
118
+ RedeemInviteByTokenRequest,
119
+ RedeemVoiceInviteRequest,
120
+ } from "./invite-contract.js";
121
+
95
122
  // Guardian delivery contract (daemon → gateway pull) — Zod schemas + derived types
96
123
  export {
97
124
  GuardianDeliverySchema,