@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.
- package/ARCHITECTURE.md +21 -24
- package/README.md +6 -9
- package/docs/trusted-contact-access.md +4 -4
- package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +233 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +27 -0
- package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +216 -0
- package/openapi.yaml +41 -16
- package/package.json +1 -1
- package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +0 -1
- package/src/__tests__/channel-readiness-service.test.ts +0 -6
- package/src/__tests__/channel-readiness-slack-remote.test.ts +0 -4
- package/src/__tests__/helpers/seed-contact-channel.ts +0 -2
- package/src/__tests__/inbound-invite-redemption.test.ts +72 -341
- package/src/__tests__/invite-redeemed-ipc.test.ts +97 -0
- package/src/__tests__/invite-routes-http.test.ts +246 -420
- package/src/__tests__/phone.test.ts +1 -15
- package/src/__tests__/plugin-bootstrap.test.ts +6 -191
- package/src/__tests__/plugin-types.test.ts +0 -1
- package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +0 -1
- package/src/__tests__/relay-server.test.ts +338 -66
- package/src/calls/__tests__/gateway-invite-reader.test.ts +170 -0
- package/src/calls/__tests__/relay-setup-router.test.ts +126 -97
- package/src/calls/gateway-invite-reader.ts +98 -0
- package/src/calls/media-stream-server.ts +12 -1
- package/src/calls/relay-server.ts +8 -9
- package/src/calls/relay-setup-router.ts +18 -39
- package/src/calls/relay-verification.ts +13 -20
- package/src/calls/twilio-routes.ts +1 -1
- package/src/channels/config.ts +0 -42
- package/src/contacts/__tests__/member-write-relay.test.ts +0 -2
- package/src/contacts/contact-store.ts +0 -5
- package/src/contacts/contacts-write.ts +0 -2
- package/src/contacts/member-write-relay.ts +0 -2
- package/src/contacts/types.ts +0 -1
- package/src/daemon/external-plugins-bootstrap.ts +10 -37
- package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +1 -1
- package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +26 -14
- package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +27 -14
- package/src/daemon/handlers/__tests__/config-channels.test.ts +0 -1
- package/src/daemon/handlers/config-a2a.ts +7 -10
- package/src/ipc/assistant-server.ts +2 -2
- package/src/ipc/routes/__tests__/invite-ipc-routes.test.ts +60 -4
- package/src/ipc/routes/invite-ipc-routes.ts +53 -39
- package/src/persistence/a2a-invite-store.ts +205 -0
- package/src/persistence/migrations/315-create-a2a-invites.ts +58 -0
- package/src/persistence/migrations/316-drop-contact-channels-invite-id.ts +28 -0
- package/src/persistence/migrations/__tests__/315-create-a2a-invites.test.ts +161 -0
- package/src/persistence/migrations/__tests__/316-drop-contact-channels-invite-id.test.ts +156 -0
- package/src/persistence/schema/a2a.ts +14 -0
- package/src/persistence/schema/contacts.ts +0 -31
- package/src/persistence/steps.ts +4 -0
- package/src/plugins/types.ts +0 -7
- package/src/runtime/__tests__/trust-verdict-consumer.test.ts +0 -1
- package/src/runtime/auth/__tests__/route-policy.test.ts +31 -0
- package/src/runtime/channel-invite-transport.ts +2 -2
- package/src/runtime/channel-invite-transports/voice.ts +1 -1
- package/src/runtime/channel-readiness-service.ts +3 -5
- package/src/runtime/invite-service.ts +104 -361
- package/src/runtime/routes/__tests__/contact-routes.test.ts +0 -1
- package/src/runtime/routes/__tests__/invite-relay-routes.test.ts +119 -30
- package/src/runtime/routes/contact-routes.ts +155 -91
- package/src/runtime/routes/inbound-message-handler.ts +3 -3
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +23 -59
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +9 -495
- package/src/runtime/trust-verdict-consumer.ts +0 -1
- package/src/util/phone.ts +1 -8
- package/src/__tests__/invite-redemption-service.test.ts +0 -1180
- package/src/__tests__/invite-service-ipc.test.ts +0 -350
- package/src/__tests__/voice-invite-redemption.test.ts +0 -702
- package/src/persistence/invite-store.ts +0 -481
- package/src/runtime/invite-redemption-service.ts +0 -828
- package/src/runtime/invite-redemption-templates.ts +0 -46
- 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
|
|
433
|
-
| `src/runtime/
|
|
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 (
|
|
459
|
-
│
|
|
460
|
-
│
|
|
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
|
-
│
|
|
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
|
|
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
|
|
474
|
-
7. The
|
|
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
|
|
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 (
|
|
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. `
|
|
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
|
|
504
|
-
3. If active, non-expired
|
|
505
|
-
4. `
|
|
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/
|
|
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` |
|
|
526
|
-
| `src/runtime/routes/
|
|
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/
|
|
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?
|
|
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
|
|
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
|
|
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/
|
|
428
|
-
| `src/runtime/routes/
|
|
429
|
-
| `src/
|
|
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` |
|
|
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
|
-
|
|
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
|
|
120
|
-
|
|
|
121
|
-
| `
|
|
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,
|