@vellumai/assistant 0.4.6 → 0.4.7

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 (42) hide show
  1. package/ARCHITECTURE.md +23 -6
  2. package/bun.lock +51 -0
  3. package/docs/trusted-contact-access.md +8 -0
  4. package/package.json +2 -1
  5. package/src/__tests__/actor-token-service.test.ts +4 -4
  6. package/src/__tests__/call-controller.test.ts +37 -0
  7. package/src/__tests__/channel-delivery-store.test.ts +2 -2
  8. package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
  9. package/src/__tests__/guardian-dispatch.test.ts +39 -1
  10. package/src/__tests__/guardian-routing-state.test.ts +8 -30
  11. package/src/__tests__/non-member-access-request.test.ts +7 -0
  12. package/src/__tests__/notification-decision-fallback.test.ts +232 -0
  13. package/src/__tests__/notification-decision-strategy.test.ts +304 -8
  14. package/src/__tests__/notification-guardian-path.test.ts +38 -1
  15. package/src/__tests__/relay-server.test.ts +65 -5
  16. package/src/__tests__/send-endpoint-busy.test.ts +29 -1
  17. package/src/__tests__/tool-grant-request-escalation.test.ts +1 -0
  18. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
  19. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -2
  20. package/src/__tests__/trusted-contact-multichannel.test.ts +1 -1
  21. package/src/calls/call-controller.ts +15 -0
  22. package/src/calls/relay-server.ts +45 -11
  23. package/src/calls/types.ts +1 -0
  24. package/src/daemon/providers-setup.ts +0 -8
  25. package/src/daemon/session-slash.ts +35 -2
  26. package/src/memory/db-init.ts +4 -0
  27. package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
  28. package/src/memory/migrations/index.ts +1 -0
  29. package/src/memory/migrations/registry.ts +1 -1
  30. package/src/memory/schema.ts +19 -0
  31. package/src/notifications/README.md +8 -1
  32. package/src/notifications/copy-composer.ts +160 -30
  33. package/src/notifications/decision-engine.ts +98 -1
  34. package/src/runtime/actor-refresh-token-service.ts +309 -0
  35. package/src/runtime/actor-refresh-token-store.ts +157 -0
  36. package/src/runtime/actor-token-service.ts +3 -3
  37. package/src/runtime/gateway-client.ts +239 -0
  38. package/src/runtime/http-server.ts +2 -0
  39. package/src/runtime/routes/guardian-bootstrap-routes.ts +10 -24
  40. package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
  41. package/src/runtime/routes/pairing-routes.ts +60 -50
  42. package/src/types/qrcode.d.ts +10 -0
package/ARCHITECTURE.md CHANGED
@@ -22,7 +22,7 @@ This document owns assistant-runtime architecture details. The repo-level archit
22
22
  - Voice calls mirror the same prompt contract: `CallController` receives guardian context on setup and refreshes it immediately after successful voice challenge verification, so the first post-verification turn is grounded as `actor_role: guardian`.
23
23
  - Voice-specific behavior (DTMF/speech verification flow, relay state machine) remains voice-local; only actor-role resolution is shared.
24
24
 
25
- ### Vellum Guardian Identity Model (Actor Tokens + Bootstrap)
25
+ ### Vellum Guardian Identity Model (Actor Tokens + Refresh Tokens)
26
26
 
27
27
  The vellum channel (macOS desktop, iOS, CLI) uses an identity-bound actor token system to authenticate guardian identity on HTTP routes. This replaces the previous implicit trust model where all local connections were assumed to be the guardian.
28
28
 
@@ -30,16 +30,30 @@ The vellum channel (macOS desktop, iOS, CLI) uses an identity-bound actor token
30
30
 
31
31
  1. **Startup migration** — On daemon start, `ensureVellumGuardianBinding()` (in `guardian-vellum-migration.ts`) backfills a `channel='vellum'` guardian binding with a stable `guardianPrincipalId` (format: `vellum-principal-<uuid>`). Existing installations get a binding with `verifiedVia: 'startup-migration'`; new installs get one via bootstrap. This migration is idempotent and preserves bindings for other channels (Telegram, SMS, etc.).
32
32
 
33
- 2. **Hatch bootstrap (loopback-only, macOS)** — On every launch, the macOS client calls `POST /v1/integrations/guardian/vellum/bootstrap` with `{ platform: 'macos', deviceId }`. The endpoint is loopback-only: it rejects requests with `X-Forwarded-For` and verifies the peer IP is a loopback address (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`). The endpoint is idempotent: it ensures a vellum guardian principal exists, revokes any prior token for the same device binding, mints a new HMAC-SHA256 signed actor token with a 90-day TTL, stores only the SHA-256 hash, and returns `{ guardianPrincipalId, actorToken, isNew }`. The raw token is returned once and never persisted on the server. macOS re-bootstraps on each startup to refresh its token.
33
+ 2. **Bootstrap (loopback-only, macOS) — initial issuance only** — On first launch (no existing actor token), the macOS client calls `POST /v1/integrations/guardian/vellum/bootstrap` with `{ platform: 'macos', deviceId }`. The endpoint is loopback-only: it rejects requests with `X-Forwarded-For` and verifies the peer IP is a loopback address (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`). The endpoint ensures a vellum guardian principal exists, revokes any prior token for the same device binding, mints a new HMAC-SHA256 signed actor token with a 30-day TTL and a rotating refresh token, stores only the SHA-256 hashes, and returns `{ guardianPrincipalId, actorToken, actorTokenExpiresAt, refreshToken, refreshTokenExpiresAt, refreshAfter, isNew }`. Bootstrap is only used for initial credential issuance ongoing renewal is handled exclusively by the refresh endpoint.
34
34
 
35
- 3. **iOS pairing** — iOS devices obtain actor tokens exclusively through the QR pairing flow (re-pairs on credential loss). When an iOS device completes pairing, the pairing response includes an `actorToken` minted against the same vellum guardian principal. The pairing handler in `pairing-routes.ts` calls `mintPairingActorToken()` which looks up the vellum binding and mints a device-specific token. iOS does not call the bootstrap endpoint.
35
+ 3. **iOS pairing — initial issuance only** — iOS devices obtain actor tokens exclusively through the QR pairing flow. When an iOS device completes pairing, the pairing response includes an `actorToken` and `refreshToken` minted against the same vellum guardian principal. The pairing handler in `pairing-routes.ts` calls `mintPairingActorToken()` which looks up the vellum binding and mints a device-specific token pair. iOS does not call the bootstrap endpoint. Re-pairing is only needed if both the actor token and refresh token expire.
36
36
 
37
37
  4. **IPC identity** — Local IPC connections (Unix domain socket from the macOS native app) do not send actor tokens. Instead, the daemon assigns a deterministic local actor identity via `resolveLocalIpcGuardianContext()` in `local-actor-identity.ts`. This looks up the vellum guardian binding and routes through the same `resolveGuardianContext` trust pipeline used by HTTP channel ingress. When no vellum binding exists yet (pre-bootstrap), a fallback guardian context is returned since the local macOS user is inherently the guardian of their own machine.
38
38
 
39
- **Actor token format:** `base64url(JSON claims) + '.' + base64url(HMAC-SHA256 signature)`. Claims include `assistantId`, `platform`, `deviceId`, `guardianPrincipalId`, `iat`, `exp` (default: 90 days from issuance), and `jti`.
39
+ **Actor token format:** `base64url(JSON claims) + '.' + base64url(HMAC-SHA256 signature)`. Claims include `assistantId`, `platform`, `deviceId`, `guardianPrincipalId`, `iat`, `exp` (30 days from issuance), and `jti`.
40
40
 
41
41
  **Hash-only storage:** Only the SHA-256 hex digest of the raw token is persisted in the `actor_token_records` table. Token verification recomputes the hash and looks it up in the store to check revocation status. Tokens are scoped to `(assistantId, guardianPrincipalId, hashedDeviceId)` with a one-active-per-device invariant.
42
42
 
43
+ **Refresh token lifecycle:**
44
+
45
+ Refresh tokens provide a rotating credential renewal mechanism that avoids re-bootstrap or re-pairing for ongoing sessions.
46
+
47
+ - **Issuance:** A refresh token is minted alongside every actor token (during bootstrap or pairing). The response includes `refreshToken`, `refreshTokenExpiresAt`, and `refreshAfter` (the timestamp at which clients should proactively refresh, set to 80% of the actor token TTL).
48
+ - **Dual expiry:** Each refresh token has a 365-day absolute expiry (from issuance) and a 90-day inactivity expiry (from last use). The effective expiry is the earlier of the two. Using a refresh token resets the inactivity window.
49
+ - **Single-use rotation:** Each call to `POST /v1/integrations/guardian/vellum/refresh` consumes the presented refresh token and returns a new actor token + new refresh token pair. The old refresh token is marked as rotated and cannot be reused.
50
+ - **Token family tracking:** Refresh tokens are grouped into families (one family per initial issuance chain). All tokens in a family share a `familyId`.
51
+ - **Replay detection:** If a client presents a refresh token that has already been rotated (i.e., it was used once and a successor was issued), the server treats this as a potential token theft. The entire token family for that device is revoked, forcing re-bootstrap or re-pairing.
52
+ - **Device binding:** Refresh tokens are bound to `(assistantId, guardianPrincipalId, hashedDeviceId)`. A refresh request from a different device binding is rejected.
53
+ - **Hash-only storage:** Only the SHA-256 hex digest of the refresh token is stored in the `actor_refresh_token_records` table. The raw token is returned once and never persisted on the server.
54
+
55
+ **Refresh endpoint:** `POST /v1/integrations/guardian/vellum/refresh` accepts `{ refreshToken }` in the request body. It validates the token hash, checks expiry and device binding, performs replay detection, rotates the token, mints a new actor token, and returns `{ actorToken, actorTokenExpiresAt, refreshToken, refreshTokenExpiresAt, refreshAfter }`. This endpoint is only reachable through the gateway (bearer-authenticated).
56
+
43
57
  **Signing key management:** A 32-byte random signing key is generated on first startup and persisted at `~/.vellum/protected/actor-token-signing-key` with `chmod 0o600`. The key is loaded on subsequent startups via `loadOrCreateSigningKey()`.
44
58
 
45
59
  **Strict HTTP enforcement:** Vellum-channel HTTP routes (POST /v1/messages, POST /v1/confirm, POST /v1/guardian-actions/decision, etc.) require a valid actor token via the `X-Actor-Token` header. The middleware in `middleware/actor-token.ts` verifies the HMAC signature, checks the token is active in the store, and resolves a guardian context through the standard trust pipeline. For backward compatibility with the CLI, requests without an actor token that originate from a loopback address (no `X-Forwarded-For` header) fall back to `resolveLocalIpcGuardianContext()`. Gateway-proxied requests (which carry `X-Forwarded-For`) without an actor token are rejected.
@@ -52,11 +66,14 @@ The vellum channel (macOS desktop, iOS, CLI) uses an identity-bound actor token
52
66
  |------|---------|
53
67
  | `src/runtime/actor-token-service.ts` | HMAC-SHA256 mint/verify, signing key management, `hashToken` |
54
68
  | `src/runtime/actor-token-store.ts` | Hash-only persistence: create, find by hash/device binding, revoke |
69
+ | `src/runtime/actor-refresh-token-service.ts` | Refresh token rotation, replay detection, family revocation |
70
+ | `src/runtime/actor-refresh-token-store.ts` | Refresh token hash-only persistence: create, find, rotate, revoke by family |
55
71
  | `src/runtime/middleware/actor-token.ts` | HTTP middleware: `verifyHttpActorToken`, `verifyHttpActorTokenWithLocalFallback`, `isActorBoundGuardian` |
56
72
  | `src/runtime/local-actor-identity.ts` | `resolveLocalIpcGuardianContext` — deterministic IPC identity |
57
73
  | `src/runtime/guardian-vellum-migration.ts` | `ensureVellumGuardianBinding` — startup binding backfill |
58
- | `src/runtime/routes/guardian-bootstrap-routes.ts` | `POST /v1/integrations/guardian/vellum/bootstrap` handler |
59
- | `src/runtime/routes/pairing-routes.ts` | `mintPairingActorToken` actor token in pairing response |
74
+ | `src/runtime/routes/guardian-bootstrap-routes.ts` | `POST /v1/integrations/guardian/vellum/bootstrap` handler (initial issuance only) |
75
+ | `src/runtime/routes/guardian-refresh-routes.ts` | `POST /v1/integrations/guardian/vellum/refresh` handler (token rotation) |
76
+ | `src/runtime/routes/pairing-routes.ts` | `mintPairingActorToken` — actor token + refresh token in pairing response |
60
77
  | `src/memory/guardian-bindings.ts` | Guardian binding persistence (shared across all channels) |
61
78
 
62
79
  ### Channel-Agnostic Scoped Approval Grants
package/bun.lock CHANGED
@@ -25,6 +25,7 @@
25
25
  "pino-pretty": "^13.1.3",
26
26
  "playwright": "^1.58.2",
27
27
  "postgres": "^3.4.8",
28
+ "qrcode": "^1.5.4",
28
29
  "react": "^19.2.4",
29
30
  "rrule": "^2.8.1",
30
31
  "tldts": "^7.0.23",
@@ -594,6 +595,8 @@
594
595
 
595
596
  "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
596
597
 
598
+ "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
599
+
597
600
  "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
598
601
 
599
602
  "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="],
@@ -658,6 +661,8 @@
658
661
 
659
662
  "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
660
663
 
664
+ "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
665
+
661
666
  "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
662
667
 
663
668
  "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
@@ -666,6 +671,8 @@
666
671
 
667
672
  "diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="],
668
673
 
674
+ "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
675
+
669
676
  "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
670
677
 
671
678
  "drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
@@ -1014,6 +1021,8 @@
1014
1021
 
1015
1022
  "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
1016
1023
 
1024
+ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
1025
+
1017
1026
  "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
1018
1027
 
1019
1028
  "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
@@ -1060,6 +1069,8 @@
1060
1069
 
1061
1070
  "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
1062
1071
 
1072
+ "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
1073
+
1063
1074
  "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
1064
1075
 
1065
1076
  "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
@@ -1090,6 +1101,8 @@
1090
1101
 
1091
1102
  "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
1092
1103
 
1104
+ "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
1105
+
1093
1106
  "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
1094
1107
 
1095
1108
  "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -1120,6 +1133,8 @@
1120
1133
 
1121
1134
  "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
1122
1135
 
1136
+ "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
1137
+
1123
1138
  "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
1124
1139
 
1125
1140
  "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
@@ -1152,6 +1167,8 @@
1152
1167
 
1153
1168
  "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
1154
1169
 
1170
+ "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
1171
+
1155
1172
  "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
1156
1173
 
1157
1174
  "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
@@ -1282,6 +1299,8 @@
1282
1299
 
1283
1300
  "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
1284
1301
 
1302
+ "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
1303
+
1285
1304
  "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="],
1286
1305
 
1287
1306
  "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
@@ -1596,6 +1615,8 @@
1596
1615
 
1597
1616
  "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
1598
1617
 
1618
+ "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
1619
+
1599
1620
  "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
1600
1621
 
1601
1622
  "readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
@@ -1746,6 +1767,16 @@
1746
1767
 
1747
1768
  "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
1748
1769
 
1770
+ "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
1771
+
1772
+ "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
1773
+
1774
+ "qrcode/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
1775
+
1776
+ "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
1777
+
1778
+ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
1779
+
1749
1780
  "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
1750
1781
 
1751
1782
  "rimraf/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1774,6 +1805,16 @@
1774
1805
 
1775
1806
  "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
1776
1807
 
1808
+ "qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
1809
+
1810
+ "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
1811
+
1812
+ "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
1813
+
1814
+ "qrcode/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
1815
+
1816
+ "qrcode/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
1817
+
1777
1818
  "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
1778
1819
 
1779
1820
  "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
@@ -1782,6 +1823,16 @@
1782
1823
 
1783
1824
  "archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
1784
1825
 
1826
+ "qrcode/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
1827
+
1828
+ "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
1829
+
1830
+ "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
1831
+
1832
+ "qrcode/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
1833
+
1785
1834
  "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
1835
+
1836
+ "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
1786
1837
  }
1787
1838
  }
@@ -16,6 +16,14 @@ Design doc defining how unknown users gain access to a Vellum assistant via chan
16
16
  2. **Assistant rejects the message** via the ingress ACL in `inbound-message-handler.ts`. The user has no `assistant_ingress_members` record, so the handler replies: *"Hmm looks like you don't have access to talk to me. I'll let them know you tried talking to me and get back to you."* and returns `{ denied: true, reason: 'not_a_member' }`.
17
17
  3. **Notification pipeline alerts the guardian.** The rejection triggers `notifyGuardianOfAccessRequest()` which creates a canonical access request and calls `emitNotificationSignal()` with `sourceEventName: 'ingress.access_request'`. The notification routes through the decision engine to all connected channels (vellum macOS app, Telegram, etc.). The guardian sees who is requesting access, including a request code for approve/reject and an `open invite flow` option to start the Trusted Contacts invite flow.
18
18
 
19
+ **Access-request copy contract:** Every guardian-facing access-request notification must contain:
20
+ 1. **Requester context** — best-available identity (display name, username, external ID, source channel), sanitized to prevent control-character injection.
21
+ 2. **Request-code decision directive** — e.g., `Reply "A1B2C3 approve" to grant access or "A1B2C3 reject" to deny.`
22
+ 3. **Invite directive** — the exact phrase `Reply "open invite flow" to start Trusted Contacts invite flow.`
23
+ 4. **Revoked-member warning** (when applicable) — `Note: this user was previously revoked.`
24
+
25
+ Model-generated phrasing is permitted for the surrounding copy, but a post-generation enforcement step in the decision engine validates that all required directive elements are present. If any are missing, the full deterministic contract text is appended. This ensures the guardian can always parse and act on the notification regardless of LLM output quality.
26
+
19
27
  **Guardian binding resolution for access requests** uses a fallback strategy:
20
28
  1. Source-channel active binding first (e.g., Telegram binding for a Telegram access request).
21
29
  2. Any active binding for the assistant on another channel (deterministic: most recently verified first, then alphabetical by channel).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -46,6 +46,7 @@
46
46
  "pino-pretty": "^13.1.3",
47
47
  "playwright": "^1.58.2",
48
48
  "postgres": "^3.4.8",
49
+ "qrcode": "^1.5.4",
49
50
  "react": "^19.2.4",
50
51
  "rrule": "^2.8.1",
51
52
  "tldts": "^7.0.23",
@@ -100,7 +100,7 @@ afterAll(() => {
100
100
  // ---------------------------------------------------------------------------
101
101
 
102
102
  describe('actor-token mint/verify', () => {
103
- test('mint returns token, hash, and claims with default 90-day TTL', () => {
103
+ test('mint returns token, hash, and claims with default 30-day TTL', () => {
104
104
  const result = mintActorToken({
105
105
  assistantId: 'self',
106
106
  platform: 'macos',
@@ -115,10 +115,10 @@ describe('actor-token mint/verify', () => {
115
115
  expect(result.claims.deviceId).toBe('device-123');
116
116
  expect(result.claims.guardianPrincipalId).toBe('principal-abc');
117
117
  expect(result.claims.iat).toBeGreaterThan(0);
118
- // Default TTL is 90 days
118
+ // Default TTL is 30 days
119
119
  expect(result.claims.exp).not.toBeNull();
120
- const ninetyDaysMs = 90 * 24 * 60 * 60 * 1000;
121
- expect(result.claims.exp! - result.claims.iat).toBe(ninetyDaysMs);
120
+ const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
121
+ expect(result.claims.exp! - result.claims.iat).toBe(thirtyDaysMs);
122
122
  expect(result.claims.jti).toBeTruthy();
123
123
  });
124
124
 
@@ -400,6 +400,43 @@ describe('call-controller', () => {
400
400
  controller.destroy();
401
401
  });
402
402
 
403
+ test('markNextCallerTurnAsOpeningAck: tags the next caller turn with CALL_OPENING_ACK without requiring a prior CALL_OPENING', async () => {
404
+ let turnCount = 0;
405
+ mockStartVoiceTurn.mockImplementation(async (opts: { content: string; onTextDelta: (t: string) => void; onComplete: () => void }) => {
406
+ turnCount++;
407
+
408
+ if (turnCount === 1) {
409
+ // First caller utterance after markNextCallerTurnAsOpeningAck
410
+ expect(opts.content).toContain('[CALL_OPENING_ACK]');
411
+ expect(opts.content).toContain('I want to check my balance');
412
+ for (const token of ['Sure, let me check your balance.']) {
413
+ opts.onTextDelta(token);
414
+ }
415
+ } else {
416
+ // Subsequent utterance should NOT have the marker
417
+ expect(opts.content).not.toContain('[CALL_OPENING_ACK]');
418
+ for (const token of ['Your balance is $42.']) {
419
+ opts.onTextDelta(token);
420
+ }
421
+ }
422
+ opts.onComplete();
423
+ return { turnId: `run-${turnCount}`, abort: () => {} };
424
+ });
425
+
426
+ const { controller } = setupController();
427
+
428
+ // Simulate post-approval: call markNextCallerTurnAsOpeningAck directly
429
+ // without any prior startInitialGreeting / CALL_OPENING
430
+ controller.markNextCallerTurnAsOpeningAck();
431
+
432
+ await controller.handleCallerUtterance('I want to check my balance');
433
+ await controller.handleCallerUtterance('How much exactly?');
434
+
435
+ expect(turnCount).toBe(2);
436
+
437
+ controller.destroy();
438
+ });
439
+
403
440
  // ── ASK_GUARDIAN pattern ──────────────────────────────────────────
404
441
 
405
442
  test('ASK_GUARDIAN pattern: detects pattern, creates pending question, sets session to waiting_on_user', async () => {
@@ -508,7 +508,7 @@ describe('channel-delivery-store', () => {
508
508
  headers: { 'Content-Type': 'application/json' },
509
509
  body: JSON.stringify({
510
510
  sourceChannel: 'telegram',
511
- externalChatId: 'chat-del',
511
+ conversationExternalId: 'chat-del',
512
512
  // Note: no assistantId in the body — it comes from the route param
513
513
  }),
514
514
  });
@@ -558,7 +558,7 @@ describe('channel-delivery-store', () => {
558
558
  headers: { 'Content-Type': 'application/json' },
559
559
  body: JSON.stringify({
560
560
  sourceChannel: 'telegram',
561
- externalChatId: 'chat-def',
561
+ conversationExternalId: 'chat-def',
562
562
  }),
563
563
  });
564
564
 
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+
3
+ import { deliverChannelReply } from '../runtime/gateway-client.js';
4
+
5
+ type FetchCall = {
6
+ url: string;
7
+ init: RequestInit;
8
+ };
9
+
10
+ describe('gateway-client managed outbound lane', () => {
11
+ const originalFetch = globalThis.fetch;
12
+ const calls: FetchCall[] = [];
13
+
14
+ beforeEach(() => {
15
+ calls.length = 0;
16
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
17
+ const url = typeof input === 'string'
18
+ ? input
19
+ : input instanceof URL
20
+ ? input.toString()
21
+ : input.url;
22
+ calls.push({ url, init: init ?? {} });
23
+ return new Response(JSON.stringify({ status: 'accepted' }), { status: 202 });
24
+ }) as unknown as typeof globalThis.fetch;
25
+ });
26
+
27
+ afterEach(() => {
28
+ globalThis.fetch = originalFetch;
29
+ });
30
+
31
+ test('translates managed callback URL into managed outbound-send request', async () => {
32
+ await deliverChannelReply(
33
+ 'https://platform.test/v1/internal/managed-gateway/outbound-send/?route_id=route-123&assistant_id=assistant-123&source_channel=sms&source_update_id=SM-inbound-123&callback_token=runtime-token',
34
+ {
35
+ chatId: '+15550001111',
36
+ text: 'hello from runtime',
37
+ },
38
+ );
39
+
40
+ expect(calls).toHaveLength(1);
41
+ const call = calls[0];
42
+ expect(call.url).toBe('https://platform.test/v1/internal/managed-gateway/outbound-send/');
43
+
44
+ const headers = call.init.headers as Record<string, string>;
45
+ expect(headers['Content-Type']).toBe('application/json');
46
+ expect(headers['X-Managed-Gateway-Callback-Token']).toBe('runtime-token');
47
+ expect(headers['X-Idempotency-Key']).toStartWith('mgw-send-');
48
+ expect(headers.Authorization).toBeUndefined();
49
+
50
+ const body = JSON.parse(String(call.init.body)) as {
51
+ route_id: string;
52
+ assistant_id: string;
53
+ normalized_send: {
54
+ sourceChannel: string;
55
+ message: {
56
+ to: string;
57
+ content: string;
58
+ externalMessageId: string;
59
+ };
60
+ source: {
61
+ requestId: string;
62
+ };
63
+ raw: {
64
+ sourceUpdateId: string;
65
+ };
66
+ };
67
+ };
68
+ expect(body.route_id).toBe('route-123');
69
+ expect(body.assistant_id).toBe('assistant-123');
70
+ expect(body.normalized_send.sourceChannel).toBe('sms');
71
+ expect(body.normalized_send.message.to).toBe('+15550001111');
72
+ expect(body.normalized_send.message.content).toBe('hello from runtime');
73
+ expect(body.normalized_send.message.externalMessageId).toStartWith('mgw-send-');
74
+ expect(body.normalized_send.source.requestId).toBe(
75
+ body.normalized_send.message.externalMessageId,
76
+ );
77
+ expect(body.normalized_send.raw.sourceUpdateId).toBe('SM-inbound-123');
78
+ });
79
+
80
+ test('retries managed outbound send on retriable upstream responses with stable idempotency key', async () => {
81
+ calls.length = 0;
82
+ let attempt = 0;
83
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
84
+ const url = typeof input === 'string'
85
+ ? input
86
+ : input instanceof URL
87
+ ? input.toString()
88
+ : input.url;
89
+ calls.push({ url, init: init ?? {} });
90
+ attempt += 1;
91
+ if (attempt === 1) {
92
+ return new Response('temporary upstream error', { status: 502 });
93
+ }
94
+ return new Response(JSON.stringify({ status: 'accepted' }), { status: 202 });
95
+ }) as unknown as typeof globalThis.fetch;
96
+
97
+ await deliverChannelReply(
98
+ 'https://platform.test/v1/internal/managed-gateway/outbound-send/?route_id=route-retry&assistant_id=assistant-retry&source_channel=sms&source_update_id=SM-retry',
99
+ {
100
+ chatId: '+15550002222',
101
+ text: 'retry this outbound send',
102
+ },
103
+ );
104
+
105
+ expect(calls).toHaveLength(2);
106
+
107
+ const firstHeaders = calls[0].init.headers as Record<string, string>;
108
+ const secondHeaders = calls[1].init.headers as Record<string, string>;
109
+ expect(firstHeaders['X-Idempotency-Key']).toBeDefined();
110
+ expect(secondHeaders['X-Idempotency-Key']).toBe(firstHeaders['X-Idempotency-Key']);
111
+
112
+ const firstBody = JSON.parse(String(calls[0].init.body)) as {
113
+ normalized_send: { source: { requestId: string } };
114
+ };
115
+ const secondBody = JSON.parse(String(calls[1].init.body)) as {
116
+ normalized_send: { source: { requestId: string } };
117
+ };
118
+ expect(secondBody.normalized_send.source.requestId).toBe(
119
+ firstBody.normalized_send.source.requestId,
120
+ );
121
+ });
122
+
123
+ test('falls back to standard callback delivery for non-managed callback URL', async () => {
124
+ await deliverChannelReply(
125
+ 'https://gateway.test/deliver/sms',
126
+ {
127
+ chatId: '+15550001111',
128
+ text: 'standard gateway callback',
129
+ },
130
+ 'runtime-bearer',
131
+ );
132
+
133
+ expect(calls).toHaveLength(1);
134
+ const call = calls[0];
135
+ expect(call.url).toBe('https://gateway.test/deliver/sms');
136
+
137
+ const headers = call.init.headers as Record<string, string>;
138
+ expect(headers['Content-Type']).toBe('application/json');
139
+ expect(headers.Authorization).toBe('Bearer runtime-bearer');
140
+
141
+ const body = JSON.parse(String(call.init.body)) as { chatId: string; text: string };
142
+ expect(body).toEqual({
143
+ chatId: '+15550001111',
144
+ text: 'standard gateway callback',
145
+ });
146
+ });
147
+ });
@@ -31,25 +31,45 @@ mock.module('../util/logger.js', () => ({
31
31
 
32
32
  let mockTelegramBinding: unknown = null;
33
33
  let mockSmsBinding: unknown = null;
34
+ let mockVellumBinding: unknown = null;
34
35
 
35
36
  mock.module('../memory/channel-guardian-store.js', () => ({
36
37
  getActiveBinding: (_assistantId: string, channel: string) => {
37
38
  if (channel === 'telegram') return mockTelegramBinding;
38
39
  if (channel === 'sms') return mockSmsBinding;
40
+ if (channel === 'vellum') return mockVellumBinding;
39
41
  return null;
40
42
  },
43
+ createBinding: (params: Record<string, unknown>) => ({
44
+ id: `binding-${Date.now()}`,
45
+ ...params,
46
+ status: 'active',
47
+ verifiedAt: Date.now(),
48
+ verifiedVia: 'test',
49
+ metadataJson: null,
50
+ createdAt: Date.now(),
51
+ updatedAt: Date.now(),
52
+ }),
53
+ listActiveBindingsByAssistant: () => mockVellumBinding ? [mockVellumBinding] : [],
41
54
  }));
42
55
 
43
56
  mock.module('../config/loader.js', () => ({
44
57
  getConfig: () => ({
45
58
  ui: {},
46
-
59
+
47
60
  calls: {
48
61
  userConsultTimeoutSeconds: 120,
49
62
  },
50
63
  }),
51
64
  }));
52
65
 
66
+ // Mock guardian-vellum-migration to use a stable principal, avoiding UNIQUE
67
+ // constraint errors when ensureVellumGuardianBinding is called across tests.
68
+ // Returns a known principal so guardian-dispatch can attribute requests.
69
+ mock.module('../runtime/guardian-vellum-migration.js', () => ({
70
+ ensureVellumGuardianBinding: () => 'test-principal-id',
71
+ }));
72
+
53
73
  const emitCalls: unknown[] = [];
54
74
  let threadCreatedFromMock: ThreadCreatedInfo | null = null;
55
75
  let mockEmitResult: {
@@ -109,12 +129,30 @@ function resetTables(): void {
109
129
  db.run('DELETE FROM canonical_guardian_requests');
110
130
  db.run('DELETE FROM guardian_action_deliveries');
111
131
  db.run('DELETE FROM guardian_action_requests');
132
+ db.run('DELETE FROM channel_guardian_bindings');
112
133
  db.run('DELETE FROM call_pending_questions');
113
134
  db.run('DELETE FROM call_events');
114
135
  db.run('DELETE FROM call_sessions');
115
136
  db.run('DELETE FROM conversations');
137
+
116
138
  mockTelegramBinding = null;
117
139
  mockSmsBinding = null;
140
+ // Pre-seed vellum binding so the self-healing path in dispatchGuardianQuestion
141
+ // never triggers (avoids UNIQUE constraint violations on repeated dispatches).
142
+ mockVellumBinding = {
143
+ id: 'binding-vellum-test',
144
+ assistantId: 'self',
145
+ channel: 'vellum',
146
+ guardianExternalUserId: 'vellum-guardian',
147
+ guardianDeliveryChatId: 'local',
148
+ guardianPrincipalId: 'test-principal-id',
149
+ status: 'active',
150
+ verifiedAt: Date.now(),
151
+ verifiedVia: 'test',
152
+ metadataJson: null,
153
+ createdAt: Date.now(),
154
+ updatedAt: Date.now(),
155
+ };
118
156
  emitCalls.length = 0;
119
157
  threadCreatedFromMock = null;
120
158
  mockEmitResult = {
@@ -348,44 +348,22 @@ describe('inbound-message-handler trusted-contact interactivity', () => {
348
348
  expect(processCalls[0].options?.isInteractive).toBe(true);
349
349
  });
350
350
 
351
- test('unknown actors remain non-interactive', async () => {
352
- // No guardian binding, no member record => unknown trust class
351
+ test('unknown actors remain non-interactive (denied at gate)', async () => {
352
+ // No member record => non-member denied at the ACL gate,
353
+ // which is the strongest form of "not interactive".
353
354
  mockFindMember = () => null;
354
355
 
355
- const processCalls: Array<{ options?: Record<string, unknown> }> = [];
356
- const processMessage = mock(async (
357
- conversationId: string,
358
- _content: string,
359
- _attachmentIds?: string[],
360
- options?: Record<string, unknown>,
361
- ) => {
362
- processCalls.push({ options });
363
- const messageId = `msg-unknown-${Date.now()}`;
364
- const db = getDb();
365
- db.insert(messages).values({
366
- id: messageId,
367
- conversationId,
368
- role: 'user',
369
- content: JSON.stringify([{ type: 'text', text: 'hello' }]),
370
- createdAt: Date.now(),
371
- }).run();
372
- return { messageId };
373
- });
374
-
375
356
  const req = makeInboundRequest({
376
357
  externalMessageId: `msg-unknown-${Date.now()}`,
377
- // No actorExternalId => unknown trust class
378
- actorExternalId: undefined,
358
+ actorExternalId: 'unknown-user-no-member',
379
359
  });
380
360
 
381
- const res = await handleChannelInbound(req, processMessage as any, 'test-token');
361
+ const res = await handleChannelInbound(req, undefined, 'test-token');
382
362
  const body = await res.json() as Record<string, unknown>;
363
+ // Unknown actors are ACL-denied: accepted but denied with reason
383
364
  expect(body.accepted).toBe(true);
384
-
385
- await new Promise((resolve) => setTimeout(resolve, 300));
386
-
387
- expect(processCalls.length).toBeGreaterThan(0);
388
- expect(processCalls[0].options?.isInteractive).toBe(false);
365
+ expect(body.denied).toBe(true);
366
+ expect(body.reason).toBe('not_a_member');
389
367
  });
390
368
  });
391
369