@vellumai/assistant 0.4.32 → 0.4.34

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 (186) hide show
  1. package/docs/architecture/memory.md +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/access-request-decision.test.ts +85 -4
  4. package/src/__tests__/actor-token-service.test.ts +4 -12
  5. package/src/__tests__/approval-primitive.test.ts +0 -45
  6. package/src/__tests__/approval-routes-http.test.ts +0 -1
  7. package/src/__tests__/assistant-id-boundary-guard.test.ts +150 -0
  8. package/src/__tests__/call-controller.test.ts +0 -1
  9. package/src/__tests__/call-routes-http.test.ts +0 -1
  10. package/src/__tests__/callback-handoff-copy.test.ts +0 -1
  11. package/src/__tests__/channel-approval-routes.test.ts +5 -45
  12. package/src/__tests__/channel-guardian.test.ts +122 -346
  13. package/src/__tests__/channel-invite-transport.test.ts +52 -40
  14. package/src/__tests__/commit-message-enrichment-service.test.ts +4 -38
  15. package/src/__tests__/computer-use-session-working-dir.test.ts +0 -1
  16. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +4 -3
  17. package/src/__tests__/contacts-tools.test.ts +4 -5
  18. package/src/__tests__/conversation-attention-store.test.ts +2 -65
  19. package/src/__tests__/conversation-attention-telegram.test.ts +0 -2
  20. package/src/__tests__/conversation-pairing.test.ts +0 -1
  21. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  22. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -3
  23. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -7
  24. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -1
  25. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -74
  26. package/src/__tests__/guardian-action-late-reply.test.ts +1 -8
  27. package/src/__tests__/guardian-dispatch.test.ts +0 -1
  28. package/src/__tests__/guardian-grant-minting.test.ts +0 -1
  29. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  30. package/src/__tests__/guardian-routing-state.test.ts +0 -3
  31. package/src/__tests__/handlers-telegram-config.test.ts +0 -1
  32. package/src/__tests__/inbound-invite-redemption.test.ts +1 -7
  33. package/src/__tests__/ingress-reconcile.test.ts +3 -36
  34. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1
  35. package/src/__tests__/migration-export-http.test.ts +0 -1
  36. package/src/__tests__/migration-import-commit-http.test.ts +0 -1
  37. package/src/__tests__/migration-import-preflight-http.test.ts +0 -1
  38. package/src/__tests__/migration-validate-http.test.ts +0 -1
  39. package/src/__tests__/non-member-access-request.test.ts +0 -8
  40. package/src/__tests__/notification-broadcaster.test.ts +1 -2
  41. package/src/__tests__/notification-decision-fallback.test.ts +0 -2
  42. package/src/__tests__/notification-decision-strategy.test.ts +0 -1
  43. package/src/__tests__/notification-guardian-path.test.ts +0 -1
  44. package/src/__tests__/notification-telegram-adapter.test.ts +0 -4
  45. package/src/__tests__/relay-server.test.ts +151 -80
  46. package/src/__tests__/sandbox-host-parity.test.ts +5 -2
  47. package/src/__tests__/scoped-approval-grants.test.ts +9 -40
  48. package/src/__tests__/scoped-grant-security-matrix.test.ts +0 -36
  49. package/src/__tests__/send-endpoint-busy.test.ts +0 -1
  50. package/src/__tests__/send-notification-tool.test.ts +0 -1
  51. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  52. package/src/__tests__/slack-channel-config.test.ts +0 -1
  53. package/src/__tests__/slack-inbound-verification.test.ts +2 -5
  54. package/src/__tests__/sms-messaging-provider.test.ts +0 -4
  55. package/src/__tests__/terminal-tools.test.ts +5 -2
  56. package/src/__tests__/thread-seed-composer.test.ts +0 -1
  57. package/src/__tests__/tool-approval-handler.test.ts +0 -1
  58. package/src/__tests__/tool-grant-request-escalation.test.ts +0 -4
  59. package/src/__tests__/trusted-contact-approval-notifier.test.ts +65 -77
  60. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
  61. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +1 -18
  62. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -14
  63. package/src/__tests__/trusted-contact-verification.test.ts +3 -16
  64. package/src/__tests__/twilio-routes.test.ts +2 -3
  65. package/src/__tests__/update-bulletin.test.ts +0 -2
  66. package/src/__tests__/user-reference.test.ts +47 -1
  67. package/src/__tests__/voice-invite-redemption.test.ts +0 -1
  68. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -38
  69. package/src/__tests__/workspace-git-service.test.ts +2 -2
  70. package/src/approvals/approval-primitive.ts +0 -15
  71. package/src/approvals/guardian-decision-primitive.ts +0 -3
  72. package/src/approvals/guardian-request-resolvers.ts +0 -5
  73. package/src/calls/call-domain.ts +0 -3
  74. package/src/calls/call-store.ts +0 -3
  75. package/src/calls/guardian-action-sweep.ts +2 -1
  76. package/src/calls/guardian-dispatch.ts +1 -2
  77. package/src/calls/relay-access-wait.ts +0 -4
  78. package/src/calls/relay-server.ts +8 -66
  79. package/src/calls/relay-setup-router.ts +1 -2
  80. package/src/calls/relay-verification.ts +0 -1
  81. package/src/calls/twilio-routes.ts +0 -3
  82. package/src/calls/types.ts +0 -1
  83. package/src/calls/voice-session-bridge.ts +0 -1
  84. package/src/channels/config.ts +41 -2
  85. package/src/config/bundled-skills/notifications/tools/send-notification.ts +0 -1
  86. package/src/config/bundled-skills/slack/SKILL.md +2 -0
  87. package/src/config/bundled-skills/slack-digest-setup/SKILL.md +164 -0
  88. package/src/config/env.ts +0 -4
  89. package/src/config/feature-flag-registry.json +4 -4
  90. package/src/config/user-reference.ts +47 -9
  91. package/src/contacts/contact-store.ts +13 -88
  92. package/src/contacts/contacts-write.ts +3 -11
  93. package/src/contacts/types.ts +0 -1
  94. package/src/daemon/handlers/config-channels.ts +19 -44
  95. package/src/daemon/handlers/config-inbox.ts +6 -6
  96. package/src/daemon/handlers/contacts.ts +8 -12
  97. package/src/daemon/handlers/index.ts +0 -2
  98. package/src/daemon/lifecycle.ts +18 -26
  99. package/src/daemon/session-process.ts +0 -4
  100. package/src/memory/channel-delivery-store.ts +1 -0
  101. package/src/memory/conversation-attention-store.ts +4 -19
  102. package/src/memory/conversation-crud.ts +0 -2
  103. package/src/memory/db-init.ts +8 -0
  104. package/src/memory/delivery-crud.ts +13 -0
  105. package/src/memory/guardian-action-store.ts +0 -12
  106. package/src/memory/guardian-approvals.ts +35 -80
  107. package/src/memory/guardian-rate-limits.ts +1 -14
  108. package/src/memory/guardian-verification.ts +6 -34
  109. package/src/memory/invite-store.ts +76 -15
  110. package/src/memory/migrations/040-invite-code-hash-column.ts +16 -0
  111. package/src/memory/migrations/134-contacts-notes-column.ts +64 -45
  112. package/src/memory/migrations/136-drop-assistant-id-columns.ts +263 -0
  113. package/src/memory/migrations/index.ts +2 -0
  114. package/src/memory/migrations/registry.ts +14 -1
  115. package/src/memory/schema/calls.ts +0 -7
  116. package/src/memory/schema/contacts.ts +2 -8
  117. package/src/memory/schema/guardian.ts +0 -5
  118. package/src/memory/schema/infrastructure.ts +0 -2
  119. package/src/memory/schema/notifications.ts +3 -17
  120. package/src/memory/scoped-approval-grants.ts +2 -24
  121. package/src/notifications/adapters/sms.ts +2 -1
  122. package/src/notifications/broadcaster.ts +1 -6
  123. package/src/notifications/decision-engine.ts +3 -4
  124. package/src/notifications/deliveries-store.ts +0 -4
  125. package/src/notifications/destination-resolver.ts +4 -6
  126. package/src/notifications/deterministic-checks.ts +1 -6
  127. package/src/notifications/emit-signal.ts +4 -11
  128. package/src/notifications/events-store.ts +7 -17
  129. package/src/notifications/preference-summary.ts +2 -2
  130. package/src/notifications/preferences-store.ts +2 -9
  131. package/src/notifications/signal.ts +0 -1
  132. package/src/notifications/thread-candidates.ts +1 -11
  133. package/src/notifications/types.ts +0 -3
  134. package/src/runtime/access-request-helper.ts +3 -10
  135. package/src/runtime/actor-refresh-token-store.ts +0 -6
  136. package/src/runtime/actor-token-store.ts +3 -16
  137. package/src/runtime/actor-trust-resolver.ts +1 -4
  138. package/src/runtime/auth/__tests__/credential-service.test.ts +0 -9
  139. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -3
  140. package/src/runtime/auth/credential-service.ts +1 -15
  141. package/src/runtime/auth/require-bound-guardian.ts +1 -4
  142. package/src/runtime/auth/token-service.ts +50 -0
  143. package/src/runtime/channel-guardian-service.ts +16 -49
  144. package/src/runtime/channel-invite-transport.ts +129 -34
  145. package/src/runtime/channel-invite-transports/email.ts +54 -0
  146. package/src/runtime/channel-invite-transports/slack.ts +87 -0
  147. package/src/runtime/channel-invite-transports/sms.ts +74 -0
  148. package/src/runtime/channel-invite-transports/telegram.ts +35 -11
  149. package/src/runtime/channel-invite-transports/voice.ts +12 -12
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +0 -1
  151. package/src/runtime/guardian-action-followup-executor.ts +3 -2
  152. package/src/runtime/guardian-action-grant-minter.ts +0 -1
  153. package/src/runtime/guardian-outbound-actions.ts +2 -12
  154. package/src/runtime/guardian-vellum-migration.ts +2 -3
  155. package/src/runtime/http-server.ts +0 -1
  156. package/src/runtime/invite-redemption-service.ts +191 -11
  157. package/src/runtime/invite-redemption-templates.ts +6 -6
  158. package/src/runtime/invite-service.ts +81 -11
  159. package/src/runtime/local-actor-identity.ts +2 -5
  160. package/src/runtime/routes/access-request-decision.ts +52 -7
  161. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +6 -9
  162. package/src/runtime/routes/channel-readiness-routes.ts +29 -18
  163. package/src/runtime/routes/contact-routes.ts +48 -46
  164. package/src/runtime/routes/conversation-attention-routes.ts +0 -2
  165. package/src/runtime/routes/global-search-routes.ts +0 -2
  166. package/src/runtime/routes/guardian-bootstrap-routes.ts +6 -12
  167. package/src/runtime/routes/guardian-expiry-sweep.ts +3 -2
  168. package/src/runtime/routes/inbound-message-handler.ts +1 -6
  169. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +296 -47
  170. package/src/runtime/routes/inbound-stages/background-dispatch.ts +6 -42
  171. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +1 -6
  172. package/src/runtime/routes/inbound-stages/edit-intercept.ts +10 -0
  173. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +0 -1
  174. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +0 -1
  175. package/src/runtime/routes/inbound-stages/verification-intercept.ts +3 -7
  176. package/src/runtime/routes/invite-routes.ts +1 -0
  177. package/src/runtime/routes/pairing-routes.ts +4 -4
  178. package/src/runtime/tool-grant-request-helper.ts +0 -1
  179. package/src/tools/browser/browser-manager.ts +22 -12
  180. package/src/tools/browser/runtime-check.ts +110 -3
  181. package/src/tools/calls/call-start.ts +1 -3
  182. package/src/tools/followups/followup_create.ts +1 -2
  183. package/src/tools/shared/shell-output.ts +7 -2
  184. package/src/tools/tool-approval-handler.ts +0 -2
  185. package/src/util/platform.ts +0 -4
  186. package/src/workspace/git-service.ts +10 -4
@@ -13,6 +13,7 @@ import { upsertMember } from "../contacts/contacts-write.js";
13
13
  import { getSqlite } from "../memory/db.js";
14
14
  import {
15
15
  findActiveVoiceInvites,
16
+ findByInviteCodeHash,
16
17
  findByTokenHash,
17
18
  hashToken,
18
19
  markInviteExpired,
@@ -20,7 +21,6 @@ import {
20
21
  } from "../memory/invite-store.js";
21
22
  import { canonicalizeInboundIdentity } from "../util/canonicalize-identity.js";
22
23
  import { hashVoiceCode } from "../util/voice-code.js";
23
- import { DAEMON_INTERNAL_ASSISTANT_ID } from "./assistant-scope.js";
24
24
 
25
25
  // ---------------------------------------------------------------------------
26
26
  // Outcome type
@@ -83,7 +83,6 @@ export function redeemInvite(params: {
83
83
  externalChatId,
84
84
  displayName,
85
85
  username,
86
- assistantId,
87
86
  } = params;
88
87
 
89
88
  if (!externalUserId && !externalChatId) {
@@ -179,7 +178,6 @@ export function redeemInvite(params: {
179
178
  getSqlite()
180
179
  .transaction(() => {
181
180
  reactivated = upsertMember({
182
- assistantId: assistantId ?? invite.assistantId,
183
181
  sourceChannel,
184
182
  externalUserId,
185
183
  externalChatId,
@@ -226,7 +224,6 @@ export function redeemInvite(params: {
226
224
  getSqlite()
227
225
  .transaction(() => {
228
226
  freshResult = upsertMember({
229
- assistantId: assistantId ?? invite.assistantId,
230
227
  sourceChannel,
231
228
  externalUserId,
232
229
  externalChatId,
@@ -287,11 +284,7 @@ export function redeemVoiceInviteCode(params: {
287
284
  sourceChannel: "voice";
288
285
  code: string;
289
286
  }): VoiceRedemptionOutcome {
290
- const {
291
- assistantId = DAEMON_INTERNAL_ASSISTANT_ID,
292
- callerExternalUserId,
293
- code,
294
- } = params;
287
+ const { callerExternalUserId, code } = params;
295
288
 
296
289
  if (!callerExternalUserId) {
297
290
  return { ok: false, reason: "invalid_or_expired" };
@@ -299,7 +292,6 @@ export function redeemVoiceInviteCode(params: {
299
292
 
300
293
  // Find all active voice invites bound to the caller's phone number
301
294
  const candidates = findActiveVoiceInvites({
302
- assistantId,
303
295
  expectedExternalUserId: callerExternalUserId,
304
296
  });
305
297
 
@@ -371,7 +363,6 @@ export function redeemVoiceInviteCode(params: {
371
363
  getSqlite()
372
364
  .transaction(() => {
373
365
  const writeResult = upsertMember({
374
- assistantId: invite.assistantId,
375
366
  sourceChannel: "voice",
376
367
  externalUserId: callerExternalUserId,
377
368
  externalChatId: callerExternalUserId,
@@ -404,3 +395,192 @@ export function redeemVoiceInviteCode(params: {
404
395
  inviteId: invite.id,
405
396
  };
406
397
  }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // redeemInviteByCode
401
+ // ---------------------------------------------------------------------------
402
+
403
+ /**
404
+ * Redeem an invite using a 6-digit invite code (channel-agnostic).
405
+ *
406
+ * Unlike token-based redemption which uses deep links, code redemption works
407
+ * by intercepting bare 6-digit messages on channels with codeRedemptionEnabled.
408
+ * The code is hashed and looked up via `findByInviteCodeHash`.
409
+ *
410
+ * Validation: active status, not expired, uses remaining, channel match.
411
+ * On success: upserts/reactivates a member with status 'active', policy 'allow'.
412
+ */
413
+ export function redeemInviteByCode(params: {
414
+ code: string;
415
+ sourceChannel: string;
416
+ externalUserId?: string;
417
+ externalChatId?: string;
418
+ displayName?: string;
419
+ username?: string;
420
+ assistantId?: string;
421
+ }): InviteRedemptionOutcome {
422
+ const {
423
+ code,
424
+ sourceChannel,
425
+ externalUserId,
426
+ externalChatId,
427
+ displayName,
428
+ username,
429
+ } = params;
430
+
431
+ if (!externalUserId && !externalChatId) {
432
+ return { ok: false, reason: "missing_identity" };
433
+ }
434
+
435
+ const codeHash = hashVoiceCode(code);
436
+ const invite = findByInviteCodeHash(codeHash, sourceChannel);
437
+
438
+ if (!invite) {
439
+ return { ok: false, reason: "invalid_token" };
440
+ }
441
+
442
+ if (invite.status !== "active") {
443
+ const mapped = STORE_ERROR_TO_REASON[`invite_${invite.status}`];
444
+ if (mapped) return mapped;
445
+ return { ok: false, reason: "invalid_token" };
446
+ }
447
+
448
+ if (invite.expiresAt <= Date.now()) {
449
+ markInviteExpired(invite.id);
450
+ return { ok: false, reason: "expired" };
451
+ }
452
+
453
+ if (invite.useCount >= invite.maxUses) {
454
+ return { ok: false, reason: "max_uses_reached" };
455
+ }
456
+
457
+ // Code is valid — now safe to check existing membership without leaking
458
+ // membership status to callers with bogus codes.
459
+ const canonicalUserId = externalUserId
460
+ ? (canonicalizeInboundIdentity(
461
+ sourceChannel as ChannelId,
462
+ externalUserId,
463
+ ) ?? externalUserId)
464
+ : undefined;
465
+ const contactResult = findContactChannel({
466
+ channelType: sourceChannel,
467
+ externalUserId: canonicalUserId,
468
+ externalChatId: externalChatId,
469
+ });
470
+ const existingChannel = contactResult?.channel ?? null;
471
+ const existingContact = contactResult?.contact ?? null;
472
+
473
+ if (existingChannel && existingChannel.status === "active") {
474
+ return { ok: true, type: "already_member", memberId: existingChannel.id };
475
+ }
476
+
477
+ // Blocked members cannot bypass the guardian's explicit block via invite
478
+ // codes. Return the same generic failure as an invalid token to avoid
479
+ // leaking membership status to the caller.
480
+ if (existingChannel && existingChannel.status === "blocked") {
481
+ return { ok: false, reason: "invalid_token" };
482
+ }
483
+
484
+ // Inactive member reactivation: reactivate via upsertMember and consume
485
+ // an invite use atomically.
486
+ if (existingChannel) {
487
+ const STALE_INVITE_REACTIVATE = Symbol("stale_invite_reactivate");
488
+ const canonicalMemberId = existingChannel.externalUserId
489
+ ? canonicalizeInboundIdentity(
490
+ sourceChannel as ChannelId,
491
+ existingChannel.externalUserId,
492
+ )
493
+ : null;
494
+ const canonicalCallerId = externalUserId
495
+ ? canonicalizeInboundIdentity(sourceChannel as ChannelId, externalUserId)
496
+ : null;
497
+ const memberMatchesSender = !!(
498
+ canonicalMemberId &&
499
+ canonicalCallerId &&
500
+ canonicalMemberId === canonicalCallerId
501
+ );
502
+ const preservedDisplayName =
503
+ memberMatchesSender && existingContact?.displayName?.trim().length
504
+ ? existingContact.displayName
505
+ : displayName;
506
+
507
+ let reactivated: ReturnType<typeof upsertMember> | undefined;
508
+ try {
509
+ getSqlite()
510
+ .transaction(() => {
511
+ reactivated = upsertMember({
512
+ sourceChannel,
513
+ externalUserId,
514
+ externalChatId,
515
+ displayName: preservedDisplayName,
516
+ username,
517
+ status: "active",
518
+ policy: "allow",
519
+ inviteId: invite.id,
520
+ });
521
+
522
+ const recorded = recordInviteUse({
523
+ inviteId: invite.id,
524
+ externalUserId,
525
+ externalChatId,
526
+ });
527
+
528
+ if (!recorded) throw STALE_INVITE_REACTIVATE;
529
+ })
530
+ .immediate();
531
+ } catch (err) {
532
+ if (err === STALE_INVITE_REACTIVATE) {
533
+ return { ok: false, reason: "invalid_token" };
534
+ }
535
+ throw err;
536
+ }
537
+
538
+ return {
539
+ ok: true,
540
+ type: "redeemed",
541
+ memberId: reactivated!.channel.id,
542
+ inviteId: invite.id,
543
+ };
544
+ }
545
+
546
+ // Fresh member creation: upsert into contacts tables and consume an invite
547
+ // use atomically.
548
+ const STALE_INVITE_FRESH = Symbol("stale_invite_fresh");
549
+ let freshResult: ReturnType<typeof upsertMember> | undefined;
550
+ try {
551
+ getSqlite()
552
+ .transaction(() => {
553
+ freshResult = upsertMember({
554
+ sourceChannel,
555
+ externalUserId,
556
+ externalChatId,
557
+ displayName,
558
+ username,
559
+ status: "active",
560
+ policy: "allow",
561
+ inviteId: invite.id,
562
+ });
563
+
564
+ const recorded = recordInviteUse({
565
+ inviteId: invite.id,
566
+ externalUserId,
567
+ externalChatId,
568
+ });
569
+
570
+ if (!recorded) throw STALE_INVITE_FRESH;
571
+ })
572
+ .immediate();
573
+ } catch (err) {
574
+ if (err === STALE_INVITE_FRESH) {
575
+ return { ok: false, reason: "invalid_token" };
576
+ }
577
+ throw err;
578
+ }
579
+
580
+ return {
581
+ ok: true,
582
+ type: "redeemed",
583
+ memberId: freshResult!.channel.id,
584
+ inviteId: invite.id,
585
+ };
586
+ }
@@ -13,13 +13,13 @@ import type { InviteRedemptionOutcome } from "./invite-redemption-service.js";
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
15
  export const INVITE_REPLY_TEMPLATES = {
16
- redeemed: "Welcome! You've been granted access via invite link.",
16
+ redeemed: "Welcome! You've been granted access.",
17
17
  already_member: "You already have access.",
18
- invalid_token: "This invite link is no longer valid.",
19
- expired: "This invite link is no longer valid.",
20
- revoked: "This invite link is no longer valid.",
21
- max_uses_reached: "This invite link is no longer valid.",
22
- channel_mismatch: "This invite link is not valid for this channel.",
18
+ invalid_token: "This invite is no longer valid.",
19
+ expired: "This invite is no longer valid.",
20
+ revoked: "This invite is no longer valid.",
21
+ max_uses_reached: "This invite is no longer valid.",
22
+ channel_mismatch: "This invite is not valid for this channel.",
23
23
  missing_identity:
24
24
  "Unable to process this invite. Please contact the person who shared it.",
25
25
  generic_failure:
@@ -9,6 +9,11 @@
9
9
  */
10
10
 
11
11
  import { isChannelId } from "../channels/types.js";
12
+ import {
13
+ DECLINED_BY_USER_SENTINEL,
14
+ DEFAULT_USER_REFERENCE,
15
+ resolveGuardianName,
16
+ } from "../config/user-reference.js";
12
17
  import {
13
18
  createInvite,
14
19
  findByTokenHash,
@@ -20,7 +25,7 @@ import {
20
25
  } from "../memory/invite-store.js";
21
26
  import { isValidE164 } from "../util/phone.js";
22
27
  import { generateVoiceCode, hashVoiceCode } from "../util/voice-code.js";
23
- import { getTransport } from "./channel-invite-transport.js";
28
+ import { getInviteAdapterRegistry } from "./channel-invite-transport.js";
24
29
  import {
25
30
  type InviteRedemptionOutcome,
26
31
  redeemInvite as redeemInviteTyped,
@@ -28,8 +33,6 @@ import {
28
33
  type VoiceRedemptionOutcome,
29
34
  } from "./invite-redemption-service.js";
30
35
 
31
- import "./channel-invite-transports/telegram.js";
32
-
33
36
  // ---------------------------------------------------------------------------
34
37
  // Response shapes — used by both HTTP routes and IPC handlers
35
38
  // ---------------------------------------------------------------------------
@@ -54,6 +57,10 @@ export interface InviteResponseData {
54
57
  voiceCodeDigits?: number;
55
58
  friendName?: string;
56
59
  guardianName?: string;
60
+ // Non-voice invite fields (present only for non-voice invites)
61
+ inviteCode?: string;
62
+ guardianInstruction?: string;
63
+ channelHandle?: string;
57
64
  createdAt: number;
58
65
  }
59
66
 
@@ -66,11 +73,11 @@ function buildSharePayload(
66
73
  rawToken?: string,
67
74
  ): InviteResponseData["share"] | undefined {
68
75
  if (!rawToken || !isChannelId(sourceChannel)) return undefined;
69
- const transport = getTransport(sourceChannel);
70
- if (!transport?.buildShareableInvite) return undefined;
76
+ const adapter = getInviteAdapterRegistry().get(sourceChannel);
77
+ if (!adapter?.buildShareLink) return undefined;
71
78
 
72
79
  try {
73
- return transport.buildShareableInvite({
80
+ return adapter.buildShareLink({
74
81
  rawToken,
75
82
  sourceChannel,
76
83
  });
@@ -83,7 +90,13 @@ function buildSharePayload(
83
90
 
84
91
  function inviteToResponse(
85
92
  inv: IngressInvite,
86
- opts?: { rawToken?: string; voiceCode?: string },
93
+ opts?: {
94
+ rawToken?: string;
95
+ voiceCode?: string;
96
+ inviteCode?: string;
97
+ guardianInstruction?: string;
98
+ channelHandle?: string;
99
+ },
87
100
  ): InviteResponseData {
88
101
  const share = buildSharePayload(inv.sourceChannel, opts?.rawToken);
89
102
  return {
@@ -106,6 +119,11 @@ function inviteToResponse(
106
119
  : {}),
107
120
  ...(inv.friendName ? { friendName: inv.friendName } : {}),
108
121
  ...(inv.guardianName ? { guardianName: inv.guardianName } : {}),
122
+ ...(opts?.inviteCode ? { inviteCode: opts.inviteCode } : {}),
123
+ ...(opts?.guardianInstruction
124
+ ? { guardianInstruction: opts.guardianInstruction }
125
+ : {}),
126
+ ...(opts?.channelHandle ? { channelHandle: opts.channelHandle } : {}),
109
127
  createdAt: inv.createdAt,
110
128
  };
111
129
  }
@@ -127,6 +145,8 @@ export function createIngressInvite(params: {
127
145
  note?: string;
128
146
  maxUses?: number;
129
147
  expiresInMs?: number;
148
+ // Contact display name for personalizing guardian instructions
149
+ contactName?: string;
130
150
  // Voice invite parameters
131
151
  expectedExternalUserId?: string;
132
152
  voiceCodeDigits?: number;
@@ -142,8 +162,15 @@ export function createIngressInvite(params: {
142
162
  // exactly once and never stored.
143
163
  let voiceCode: string | undefined;
144
164
  let voiceCodeHash: string | undefined;
165
+ let effectiveGuardianName: string | undefined;
145
166
  const isVoice = params.sourceChannel === "voice";
146
167
 
168
+ // For non-voice invites: generate a 6-digit invite code for guardian-mediated
169
+ // redemption. The plaintext code is returned once in the response; only the
170
+ // hash is persisted for later redemption lookup.
171
+ let inviteCode: string | undefined;
172
+ let inviteCodeHash: string | undefined;
173
+
147
174
  if (isVoice) {
148
175
  if (!params.expectedExternalUserId) {
149
176
  return {
@@ -161,14 +188,20 @@ export function createIngressInvite(params: {
161
188
  if (typeof params.friendName !== "string" || !params.friendName.trim()) {
162
189
  return { ok: false, error: "friendName is required for voice invites" };
163
190
  }
191
+ effectiveGuardianName =
192
+ params.guardianName?.trim() || resolveGuardianName();
164
193
  if (
165
- typeof params.guardianName !== "string" ||
166
- !params.guardianName.trim()
194
+ !effectiveGuardianName ||
195
+ effectiveGuardianName === DEFAULT_USER_REFERENCE ||
196
+ effectiveGuardianName === DECLINED_BY_USER_SENTINEL
167
197
  ) {
168
198
  return { ok: false, error: "guardianName is required for voice invites" };
169
199
  }
170
200
  voiceCode = generateVoiceCode(6);
171
201
  voiceCodeHash = hashVoiceCode(voiceCode);
202
+ } else {
203
+ inviteCode = generateVoiceCode(6);
204
+ inviteCodeHash = hashVoiceCode(inviteCode);
172
205
  }
173
206
 
174
207
  const { invite, rawToken } = createInvite({
@@ -182,10 +215,44 @@ export function createIngressInvite(params: {
182
215
  voiceCodeHash,
183
216
  voiceCodeDigits: 6,
184
217
  friendName: params.friendName,
185
- guardianName: params.guardianName,
218
+ guardianName: effectiveGuardianName,
186
219
  }
187
- : {}),
220
+ : { inviteCodeHash }),
188
221
  });
222
+
223
+ // Build guardian instruction for non-voice invites
224
+ let guardianInstruction: string | undefined;
225
+ let channelHandle: string | undefined;
226
+ if (!isVoice && inviteCode) {
227
+ const channelId = isChannelId(params.sourceChannel)
228
+ ? params.sourceChannel
229
+ : undefined;
230
+ const adapter = channelId
231
+ ? getInviteAdapterRegistry().get(channelId)
232
+ : undefined;
233
+
234
+ if (adapter?.buildGuardianInstruction) {
235
+ try {
236
+ const adapterResult = adapter.buildGuardianInstruction({
237
+ inviteCode,
238
+ contactName: params.contactName,
239
+ });
240
+ if (adapterResult) {
241
+ guardianInstruction = adapterResult.instruction;
242
+ channelHandle = adapterResult.channelHandle;
243
+ }
244
+ } catch {
245
+ // Fall through to generic instruction if adapter fails
246
+ }
247
+ }
248
+
249
+ if (!guardianInstruction) {
250
+ const contactLabel = params.contactName || "the contact";
251
+ const channelLabel = params.sourceChannel;
252
+ guardianInstruction = `Tell ${contactLabel} to contact the assistant on ${channelLabel} and provide the code ${inviteCode}.`;
253
+ }
254
+ }
255
+
189
256
  // Voice invites must not expose the token — callers must redeem via the
190
257
  // identity-bound voice code flow, not the generic token redemption path.
191
258
  return {
@@ -193,6 +260,9 @@ export function createIngressInvite(params: {
193
260
  data: inviteToResponse(invite, {
194
261
  rawToken: isVoice ? undefined : rawToken,
195
262
  voiceCode,
263
+ inviteCode,
264
+ guardianInstruction,
265
+ channelHandle,
196
266
  }),
197
267
  };
198
268
  }
@@ -41,7 +41,7 @@ export function resolveLocalIpcTrustContext(
41
41
  const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
42
42
 
43
43
  // Try contacts-first for the vellum guardian channel
44
- const guardianResult = findGuardianForChannel("vellum", assistantId);
44
+ const guardianResult = findGuardianForChannel("vellum");
45
45
  if (guardianResult && guardianResult.contact.principalId) {
46
46
  const guardianPrincipalId = guardianResult.contact.principalId;
47
47
  const trustCtx = resolveTrustContext({
@@ -93,10 +93,7 @@ export function resolveLocalIpcAuthContext(sessionId: string): AuthContext {
93
93
  const authContext = buildIpcAuthContext(sessionId);
94
94
 
95
95
  // Enrich with the guardian principal ID from contacts-first path
96
- const guardianResult = findGuardianForChannel(
97
- "vellum",
98
- authContext.assistantId,
99
- );
96
+ const guardianResult = findGuardianForChannel("vellum");
100
97
  if (guardianResult && guardianResult.contact.principalId) {
101
98
  return {
102
99
  ...authContext,
@@ -86,7 +86,6 @@ export function handleAccessRequestDecision(
86
86
  // so only the original requester can consume the code. Mark as
87
87
  // trusted_contact so the consume path skips guardian binding creation.
88
88
  const session = createOutboundSession({
89
- assistantId: approval.assistantId,
90
89
  channel: approval.channel,
91
90
  expectedExternalUserId: approval.requesterExternalUserId,
92
91
  expectedChatId: approval.requesterChatId,
@@ -141,6 +140,40 @@ export async function deliverVerificationCodeToGuardian(params: {
141
140
  }
142
141
  }
143
142
 
143
+ /**
144
+ * Resolve the delivery target for requester notifications. On Slack,
145
+ * posting to a user ID (rather than the originating channel) delivers
146
+ * the message as a DM, which is less disruptive than replying in a
147
+ * shared channel. When routing to a DM, the `threadTs` query param is
148
+ * stripped from the callback URL because it belongs to the guardian's
149
+ * channel thread and would cause `thread_not_found` errors in the DM.
150
+ */
151
+ function resolveRequesterTarget(params: {
152
+ channel?: string;
153
+ replyCallbackUrl: string;
154
+ requesterChatId: string;
155
+ requesterExternalUserId?: string;
156
+ }): { chatId: string; callbackUrl: string } {
157
+ if (params.channel === "slack" && params.requesterExternalUserId) {
158
+ let callbackUrl = params.replyCallbackUrl;
159
+ try {
160
+ const url = new URL(params.replyCallbackUrl);
161
+ url.searchParams.delete("threadTs");
162
+ callbackUrl = url.toString();
163
+ } catch {
164
+ // Malformed URL — use as-is; the downstream fetch will handle the error.
165
+ }
166
+ return {
167
+ chatId: params.requesterExternalUserId,
168
+ callbackUrl,
169
+ };
170
+ }
171
+ return {
172
+ chatId: params.requesterChatId,
173
+ callbackUrl: params.replyCallbackUrl,
174
+ };
175
+ }
176
+
144
177
  /**
145
178
  * Notify the requester that the guardian has approved their access request
146
179
  * and they should enter the verification code they receive from the guardian.
@@ -150,16 +183,20 @@ export async function notifyRequesterOfApproval(params: {
150
183
  requesterChatId: string;
151
184
  assistantId: string;
152
185
  bearerToken?: string;
186
+ channel?: string;
187
+ requesterExternalUserId?: string;
153
188
  }): Promise<void> {
154
189
  const text =
155
190
  "Your access request has been approved! " +
156
191
  "Please enter the 6-digit verification code you receive from the guardian.";
157
192
 
193
+ const target = resolveRequesterTarget(params);
194
+
158
195
  try {
159
196
  await deliverChannelReply(
160
- params.replyCallbackUrl,
197
+ target.callbackUrl,
161
198
  {
162
- chatId: params.requesterChatId,
199
+ chatId: target.chatId,
163
200
  text,
164
201
  assistantId: params.assistantId,
165
202
  },
@@ -183,16 +220,20 @@ export async function notifyRequesterOfDeliveryFailure(params: {
183
220
  requesterChatId: string;
184
221
  assistantId: string;
185
222
  bearerToken?: string;
223
+ channel?: string;
224
+ requesterExternalUserId?: string;
186
225
  }): Promise<void> {
187
226
  const text =
188
227
  "Your access request was approved, but we were unable to " +
189
228
  "deliver the verification code. Please try again later.";
190
229
 
230
+ const target = resolveRequesterTarget(params);
231
+
191
232
  try {
192
233
  await deliverChannelReply(
193
- params.replyCallbackUrl,
234
+ target.callbackUrl,
194
235
  {
195
- chatId: params.requesterChatId,
236
+ chatId: target.chatId,
196
237
  text,
197
238
  assistantId: params.assistantId,
198
239
  },
@@ -214,14 +255,18 @@ export async function notifyRequesterOfDenial(params: {
214
255
  requesterChatId: string;
215
256
  assistantId: string;
216
257
  bearerToken?: string;
258
+ channel?: string;
259
+ requesterExternalUserId?: string;
217
260
  }): Promise<void> {
218
261
  const text = "Your access request has been denied by the guardian.";
219
262
 
263
+ const target = resolveRequesterTarget(params);
264
+
220
265
  try {
221
266
  await deliverChannelReply(
222
- params.replyCallbackUrl,
267
+ target.callbackUrl,
223
268
  {
224
- chatId: params.requesterChatId,
269
+ chatId: target.chatId,
225
270
  text,
226
271
  assistantId: params.assistantId,
227
272
  },
@@ -99,7 +99,6 @@ export async function handleGuardianCallbackDecision(
99
99
  callbackDecision.requestId,
100
100
  sourceChannel,
101
101
  conversationExternalId,
102
- assistantId,
103
102
  )
104
103
  : null;
105
104
 
@@ -110,7 +109,6 @@ export async function handleGuardianCallbackDecision(
110
109
  const allPending = getAllPendingApprovalsByGuardianChat(
111
110
  sourceChannel,
112
111
  conversationExternalId,
113
- assistantId,
114
112
  );
115
113
  if (allPending.length === 1) {
116
114
  guardianApproval = allPending[0];
@@ -141,7 +139,6 @@ export async function handleGuardianCallbackDecision(
141
139
  const allPending = getAllPendingApprovalsByGuardianChat(
142
140
  sourceChannel,
143
141
  conversationExternalId,
144
- assistantId,
145
142
  );
146
143
  if (allPending.length === 1) {
147
144
  guardianApproval = allPending[0];
@@ -204,7 +201,6 @@ export async function handleGuardianCallbackDecision(
204
201
  const allGuardianPending = getAllPendingApprovalsByGuardianChat(
205
202
  sourceChannel,
206
203
  conversationExternalId,
207
- assistantId,
208
204
  );
209
205
  // Only present approvals that belong to this sender so the engine
210
206
  // does not offer disambiguation for requests assigned to a rotated
@@ -609,7 +605,6 @@ async function handleLegacyDecision(params: {
609
605
  legacyGuardianDecision.requestId,
610
606
  sourceChannel,
611
607
  conversationExternalId,
612
- assistantId,
613
608
  );
614
609
  if (!resolvedByRequest) {
615
610
  // The referenced request doesn't match any pending guardian
@@ -789,6 +784,8 @@ async function handleAccessRequestApproval(
789
784
  requesterChatId: approval.requesterChatId,
790
785
  assistantId,
791
786
  bearerToken,
787
+ channel: approval.channel,
788
+ requesterExternalUserId: approval.requesterExternalUserId,
792
789
  });
793
790
 
794
791
  // Emit both guardian_decision and denied signals so all lifecycle
@@ -805,7 +802,6 @@ async function handleAccessRequestApproval(
805
802
  sourceEventName: "ingress.trusted_contact.guardian_decision",
806
803
  sourceChannel: approval.channel,
807
804
  sourceSessionId: approval.conversationId,
808
- assistantId,
809
805
  attentionHints: {
810
806
  requiresAction: false,
811
807
  urgency: "medium",
@@ -820,7 +816,6 @@ async function handleAccessRequestApproval(
820
816
  sourceEventName: "ingress.trusted_contact.denied",
821
817
  sourceChannel: approval.channel,
822
818
  sourceSessionId: approval.conversationId,
823
- assistantId,
824
819
  attentionHints: {
825
820
  requiresAction: false,
826
821
  urgency: "low",
@@ -863,6 +858,8 @@ async function handleAccessRequestApproval(
863
858
  requesterChatId: approval.requesterChatId,
864
859
  assistantId,
865
860
  bearerToken,
861
+ channel: approval.channel,
862
+ requesterExternalUserId: approval.requesterExternalUserId,
866
863
  });
867
864
  } else {
868
865
  // Let the requester know something went wrong without revealing details
@@ -871,6 +868,8 @@ async function handleAccessRequestApproval(
871
868
  requesterChatId: approval.requesterChatId,
872
869
  assistantId,
873
870
  bearerToken,
871
+ channel: approval.channel,
872
+ requesterExternalUserId: approval.requesterExternalUserId,
874
873
  });
875
874
  }
876
875
 
@@ -885,7 +884,6 @@ async function handleAccessRequestApproval(
885
884
  sourceEventName: "ingress.trusted_contact.guardian_decision",
886
885
  sourceChannel: approval.channel,
887
886
  sourceSessionId: approval.conversationId,
888
- assistantId,
889
887
  attentionHints: {
890
888
  requiresAction: false,
891
889
  urgency: "medium",
@@ -912,7 +910,6 @@ async function handleAccessRequestApproval(
912
910
  sourceEventName: "ingress.trusted_contact.verification_sent",
913
911
  sourceChannel: approval.channel,
914
912
  sourceSessionId: approval.conversationId,
915
- assistantId,
916
913
  attentionHints: {
917
914
  requiresAction: false,
918
915
  urgency: "low",