@vellumai/assistant 0.3.18 → 0.3.20

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 (202) hide show
  1. package/ARCHITECTURE.md +155 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/docs/architecture/security.md +80 -0
  6. package/package.json +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +605 -104
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/checker.test.ts +60 -0
  15. package/src/__tests__/cli.test.ts +42 -1
  16. package/src/__tests__/config-schema.test.ts +11 -127
  17. package/src/__tests__/config-watcher.test.ts +0 -8
  18. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  20. package/src/__tests__/diff.test.ts +22 -0
  21. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  22. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
  23. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  24. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  25. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  26. package/src/__tests__/guardian-dispatch.test.ts +185 -1
  27. package/src/__tests__/guardian-grant-minting.test.ts +532 -0
  28. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  29. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  30. package/src/__tests__/ipc-snapshot.test.ts +58 -0
  31. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  32. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  33. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  34. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  35. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  36. package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
  37. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  38. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  39. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  40. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  41. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  42. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  43. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  44. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  45. package/src/__tests__/system-prompt.test.ts +1 -1
  46. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  47. package/src/__tests__/terminal-tools.test.ts +2 -93
  48. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  49. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  50. package/src/__tests__/trust-store.test.ts +2 -0
  51. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  52. package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
  53. package/src/agent/loop.ts +36 -1
  54. package/src/approvals/approval-primitive.ts +381 -0
  55. package/src/approvals/guardian-decision-primitive.ts +191 -0
  56. package/src/calls/call-controller.ts +276 -212
  57. package/src/calls/call-domain.ts +56 -6
  58. package/src/calls/guardian-dispatch.ts +56 -0
  59. package/src/calls/relay-server.ts +13 -0
  60. package/src/calls/types.ts +1 -1
  61. package/src/calls/voice-session-bridge.ts +59 -4
  62. package/src/cli/core-commands.ts +0 -4
  63. package/src/cli.ts +76 -34
  64. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  65. package/src/config/assistant-feature-flags.ts +162 -0
  66. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  68. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  69. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  70. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  71. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  72. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  73. package/src/config/core-schema.ts +1 -1
  74. package/src/config/env-registry.ts +10 -0
  75. package/src/config/feature-flag-registry.json +61 -0
  76. package/src/config/loader.ts +22 -1
  77. package/src/config/sandbox-schema.ts +0 -39
  78. package/src/config/schema.ts +12 -2
  79. package/src/config/skill-state.ts +34 -0
  80. package/src/config/skills-schema.ts +26 -0
  81. package/src/config/skills.ts +9 -0
  82. package/src/config/system-prompt.ts +110 -46
  83. package/src/config/templates/SOUL.md +1 -1
  84. package/src/config/types.ts +19 -1
  85. package/src/config/vellum-skills/catalog.json +1 -1
  86. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  87. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  88. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  89. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  90. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  91. package/src/daemon/config-watcher.ts +0 -1
  92. package/src/daemon/daemon-control.ts +1 -1
  93. package/src/daemon/guardian-invite-intent.ts +124 -0
  94. package/src/daemon/handlers/avatar.ts +68 -0
  95. package/src/daemon/handlers/browser.ts +2 -2
  96. package/src/daemon/handlers/config-channels.ts +18 -0
  97. package/src/daemon/handlers/guardian-actions.ts +120 -0
  98. package/src/daemon/handlers/index.ts +4 -0
  99. package/src/daemon/handlers/sessions.ts +19 -0
  100. package/src/daemon/handlers/shared.ts +3 -1
  101. package/src/daemon/handlers/skills.ts +45 -2
  102. package/src/daemon/install-cli-launchers.ts +58 -13
  103. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  104. package/src/daemon/ipc-contract/sessions.ts +8 -2
  105. package/src/daemon/ipc-contract/settings.ts +25 -2
  106. package/src/daemon/ipc-contract/skills.ts +1 -0
  107. package/src/daemon/ipc-contract-inventory.json +10 -0
  108. package/src/daemon/ipc-contract.ts +4 -0
  109. package/src/daemon/lifecycle.ts +6 -2
  110. package/src/daemon/main.ts +1 -0
  111. package/src/daemon/server.ts +1 -0
  112. package/src/daemon/session-lifecycle.ts +52 -7
  113. package/src/daemon/session-memory.ts +45 -0
  114. package/src/daemon/session-process.ts +260 -422
  115. package/src/daemon/session-runtime-assembly.ts +12 -0
  116. package/src/daemon/session-skill-tools.ts +14 -1
  117. package/src/daemon/session-tool-setup.ts +5 -0
  118. package/src/daemon/session.ts +11 -0
  119. package/src/daemon/tool-side-effects.ts +35 -9
  120. package/src/index.ts +0 -2
  121. package/src/memory/conversation-display-order-migration.ts +44 -0
  122. package/src/memory/conversation-queries.ts +2 -0
  123. package/src/memory/conversation-store.ts +91 -0
  124. package/src/memory/db-init.ts +13 -1
  125. package/src/memory/embedding-local.ts +22 -8
  126. package/src/memory/guardian-action-store.ts +133 -2
  127. package/src/memory/guardian-verification.ts +1 -1
  128. package/src/memory/ingress-invite-store.ts +95 -1
  129. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  130. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  131. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  132. package/src/memory/migrations/index.ts +3 -0
  133. package/src/memory/schema.ts +35 -1
  134. package/src/memory/scoped-approval-grants.ts +518 -0
  135. package/src/messaging/providers/slack/client.ts +12 -0
  136. package/src/messaging/providers/slack/types.ts +5 -0
  137. package/src/notifications/decision-engine.ts +49 -12
  138. package/src/notifications/emit-signal.ts +7 -0
  139. package/src/notifications/signal.ts +7 -0
  140. package/src/notifications/thread-seed-composer.ts +2 -1
  141. package/src/permissions/checker.ts +27 -0
  142. package/src/runtime/channel-approval-types.ts +16 -6
  143. package/src/runtime/channel-approvals.ts +19 -15
  144. package/src/runtime/channel-invite-transport.ts +85 -0
  145. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  146. package/src/runtime/guardian-action-grant-minter.ts +154 -0
  147. package/src/runtime/guardian-action-message-composer.ts +30 -0
  148. package/src/runtime/guardian-decision-types.ts +91 -0
  149. package/src/runtime/http-server.ts +23 -1
  150. package/src/runtime/ingress-service.ts +22 -0
  151. package/src/runtime/invite-redemption-service.ts +181 -0
  152. package/src/runtime/invite-redemption-templates.ts +39 -0
  153. package/src/runtime/routes/call-routes.ts +2 -1
  154. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  155. package/src/runtime/routes/guardian-approval-interception.ts +66 -74
  156. package/src/runtime/routes/inbound-message-handler.ts +568 -409
  157. package/src/runtime/routes/pairing-routes.ts +4 -0
  158. package/src/security/encrypted-store.ts +31 -17
  159. package/src/security/keychain.ts +176 -2
  160. package/src/security/secure-keys.ts +97 -0
  161. package/src/security/tool-approval-digest.ts +67 -0
  162. package/src/skills/remote-skill-policy.ts +131 -0
  163. package/src/tools/browser/browser-execution.ts +2 -2
  164. package/src/tools/browser/browser-manager.ts +46 -32
  165. package/src/tools/browser/browser-screencast.ts +2 -2
  166. package/src/tools/calls/call-start.ts +1 -1
  167. package/src/tools/executor.ts +22 -17
  168. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  169. package/src/tools/skills/load.ts +22 -8
  170. package/src/tools/system/avatar-generator.ts +119 -0
  171. package/src/tools/system/navigate-settings.ts +65 -0
  172. package/src/tools/system/open-system-settings.ts +75 -0
  173. package/src/tools/system/voice-config.ts +121 -32
  174. package/src/tools/terminal/backends/native.ts +40 -19
  175. package/src/tools/terminal/backends/types.ts +3 -3
  176. package/src/tools/terminal/parser.ts +1 -1
  177. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  178. package/src/tools/terminal/sandbox.ts +1 -12
  179. package/src/tools/terminal/shell.ts +3 -31
  180. package/src/tools/tool-approval-handler.ts +141 -3
  181. package/src/tools/tool-manifest.ts +6 -0
  182. package/src/tools/types.ts +6 -0
  183. package/src/util/diff.ts +36 -13
  184. package/Dockerfile.sandbox +0 -5
  185. package/src/__tests__/doordash-client.test.ts +0 -187
  186. package/src/__tests__/doordash-session.test.ts +0 -154
  187. package/src/__tests__/signup-e2e.test.ts +0 -354
  188. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  189. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  190. package/src/cli/doordash.ts +0 -1057
  191. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  192. package/src/config/templates/LOOKS.md +0 -25
  193. package/src/doordash/cart-queries.ts +0 -787
  194. package/src/doordash/client.ts +0 -1016
  195. package/src/doordash/order-queries.ts +0 -85
  196. package/src/doordash/queries.ts +0 -13
  197. package/src/doordash/query-extractor.ts +0 -94
  198. package/src/doordash/search-queries.ts +0 -203
  199. package/src/doordash/session.ts +0 -84
  200. package/src/doordash/store-queries.ts +0 -246
  201. package/src/doordash/types.ts +0 -367
  202. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Integration tests for the inbound invite redemption intercept.
3
+ *
4
+ * Validates that non-members with valid `/start iv_<token>` payloads are
5
+ * granted access without guardian approval, and that invalid/expired/revoked
6
+ * tokens produce the correct deterministic refusal messages.
7
+ */
8
+ import { mkdtempSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Test isolation: in-memory SQLite via temp directory
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const testDir = mkdtempSync(join(tmpdir(), 'inbound-invite-redemption-test-'));
19
+
20
+ mock.module('../util/platform.js', () => ({
21
+ getRootDir: () => testDir,
22
+ getDataDir: () => testDir,
23
+ isMacOS: () => process.platform === 'darwin',
24
+ isLinux: () => process.platform === 'linux',
25
+ isWindows: () => process.platform === 'win32',
26
+ getSocketPath: () => join(testDir, 'test.sock'),
27
+ getPidPath: () => join(testDir, 'test.pid'),
28
+ getDbPath: () => join(testDir, 'test.db'),
29
+ getLogPath: () => join(testDir, 'test.log'),
30
+ ensureDataDir: () => {},
31
+ normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
32
+ readHttpToken: () => 'test-bearer-token',
33
+ }));
34
+
35
+ mock.module('../util/logger.js', () => ({
36
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
37
+ get: () => () => {},
38
+ }),
39
+ }));
40
+
41
+ mock.module('../security/secret-ingress.js', () => ({
42
+ checkIngressForSecrets: () => ({ blocked: false }),
43
+ }));
44
+
45
+ mock.module('../config/env.js', () => ({
46
+ getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830',
47
+ }));
48
+
49
+ // Mock the credential metadata store so the Telegram transport adapter
50
+ // resolves without touching the filesystem.
51
+ mock.module('../tools/credentials/metadata-store.js', () => ({
52
+ getCredentialMetadata: () => undefined,
53
+ upsertCredentialMetadata: () => {},
54
+ deleteCredentialMetadata: () => {},
55
+ listCredentialMetadata: () => [],
56
+ }));
57
+
58
+ const emitSignalCalls: Array<Record<string, unknown>> = [];
59
+ mock.module('../notifications/emit-signal.js', () => ({
60
+ emitNotificationSignal: async (params: Record<string, unknown>) => {
61
+ emitSignalCalls.push(params);
62
+ return {
63
+ signalId: 'mock-signal-id',
64
+ deduplicated: false,
65
+ dispatched: true,
66
+ reason: 'mock',
67
+ deliveryResults: [],
68
+ };
69
+ },
70
+ }));
71
+
72
+ const deliverReplyCalls: Array<{ url: string; payload: Record<string, unknown> }> = [];
73
+ mock.module('../runtime/gateway-client.js', () => ({
74
+ deliverChannelReply: async (url: string, payload: Record<string, unknown>) => {
75
+ deliverReplyCalls.push({ url, payload });
76
+ },
77
+ }));
78
+
79
+ mock.module('../runtime/approval-message-composer.js', () => ({
80
+ composeApprovalMessage: () => 'mock approval message',
81
+ composeApprovalMessageGenerative: async () => 'mock generative message',
82
+ }));
83
+
84
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
85
+ import { createInvite, revokeInvite } from '../memory/ingress-invite-store.js';
86
+ import { findMember, upsertMember } from '../memory/ingress-member-store.js';
87
+ import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
88
+
89
+ initializeDb();
90
+
91
+ afterAll(() => {
92
+ resetDb();
93
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Helpers
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const TEST_BEARER_TOKEN = 'test-token';
101
+ let msgCounter = 0;
102
+
103
+ function resetState(): void {
104
+ const db = getDb();
105
+ db.run('DELETE FROM assistant_ingress_members');
106
+ db.run('DELETE FROM assistant_ingress_invites');
107
+ db.run('DELETE FROM channel_inbound_events');
108
+ db.run('DELETE FROM conversations');
109
+ db.run('DELETE FROM channel_guardian_approval_requests');
110
+ db.run('DELETE FROM channel_guardian_bindings');
111
+ db.run('DELETE FROM notification_events');
112
+ emitSignalCalls.length = 0;
113
+ deliverReplyCalls.length = 0;
114
+ msgCounter = 0;
115
+ }
116
+
117
+ function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
118
+ msgCounter++;
119
+ const body: Record<string, unknown> = {
120
+ sourceChannel: 'telegram',
121
+ interface: 'telegram',
122
+ externalChatId: 'chat-invite-test',
123
+ externalMessageId: `msg-invite-${Date.now()}-${msgCounter}`,
124
+ content: '/start iv_sometoken',
125
+ senderExternalUserId: 'user-invite-123',
126
+ senderName: 'Invite User',
127
+ senderUsername: 'invite_user',
128
+ replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
129
+ sourceMetadata: {
130
+ commandIntent: { type: 'start', payload: 'iv_sometoken' },
131
+ },
132
+ ...overrides,
133
+ };
134
+
135
+ return new Request('http://localhost:8080/channels/inbound', {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ 'X-Gateway-Origin': TEST_BEARER_TOKEN,
140
+ },
141
+ body: JSON.stringify(body),
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Build a request with a specific invite token, using the structured
147
+ * commandIntent that the gateway produces for `/start <payload>`.
148
+ */
149
+ function buildInviteRequest(rawToken: string, overrides: Record<string, unknown> = {}): Request {
150
+ return buildInboundRequest({
151
+ content: `/start iv_${rawToken}`,
152
+ sourceMetadata: {
153
+ commandIntent: { type: 'start', payload: `iv_${rawToken}` },
154
+ },
155
+ ...overrides,
156
+ });
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Tests
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe('inbound invite redemption intercept', () => {
164
+ beforeEach(resetState);
165
+
166
+ test('non-member with valid invite token becomes active member without guardian approval', async () => {
167
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
168
+
169
+ const req = buildInviteRequest(rawToken);
170
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
171
+ const json = await resp.json() as Record<string, unknown>;
172
+
173
+ expect(json.accepted).toBe(true);
174
+ expect(json.inviteRedemption).toBe('redeemed');
175
+ expect(json.memberId).toEqual(expect.any(String));
176
+ expect(json.denied).toBeUndefined();
177
+
178
+ // Verify the user is now an active member
179
+ const member = findMember({
180
+ assistantId: 'self',
181
+ sourceChannel: 'telegram',
182
+ externalUserId: 'user-invite-123',
183
+ });
184
+ expect(member).not.toBeNull();
185
+ expect(member!.status).toBe('active');
186
+
187
+ // Verify a welcome reply was delivered
188
+ expect(deliverReplyCalls.length).toBe(1);
189
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
190
+ expect(replyText).toContain("Welcome! You've been granted access via invite link.");
191
+ });
192
+
193
+ test('non-member with invalid token gets refusal text', async () => {
194
+ const req = buildInviteRequest('completely-bogus-token-xyz');
195
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
196
+ const json = await resp.json() as Record<string, unknown>;
197
+
198
+ expect(json.accepted).toBe(true);
199
+ expect(json.denied).toBe(true);
200
+ expect(json.inviteRedemption).toBe('invalid_token');
201
+
202
+ // Verify refusal reply was delivered
203
+ expect(deliverReplyCalls.length).toBe(1);
204
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
205
+ expect(replyText).toContain('no longer valid');
206
+
207
+ // Verify the user was NOT made a member
208
+ const member = findMember({
209
+ assistantId: 'self',
210
+ sourceChannel: 'telegram',
211
+ externalUserId: 'user-invite-123',
212
+ });
213
+ expect(member).toBeNull();
214
+ });
215
+
216
+ test('non-member with expired token gets appropriate message', async () => {
217
+ const { rawToken } = createInvite({
218
+ sourceChannel: 'telegram',
219
+ maxUses: 1,
220
+ expiresInMs: -1, // already expired
221
+ });
222
+
223
+ const req = buildInviteRequest(rawToken);
224
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
225
+ const json = await resp.json() as Record<string, unknown>;
226
+
227
+ expect(json.accepted).toBe(true);
228
+ expect(json.denied).toBe(true);
229
+ expect(json.inviteRedemption).toBe('expired');
230
+
231
+ expect(deliverReplyCalls.length).toBe(1);
232
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
233
+ expect(replyText).toContain('no longer valid');
234
+ });
235
+
236
+ test('non-member with revoked token gets refusal text', async () => {
237
+ const { rawToken, invite } = createInvite({
238
+ sourceChannel: 'telegram',
239
+ maxUses: 5,
240
+ });
241
+ revokeInvite(invite.id);
242
+
243
+ const req = buildInviteRequest(rawToken);
244
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
245
+ const json = await resp.json() as Record<string, unknown>;
246
+
247
+ expect(json.accepted).toBe(true);
248
+ expect(json.denied).toBe(true);
249
+ expect(json.inviteRedemption).toBe('revoked');
250
+
251
+ expect(deliverReplyCalls.length).toBe(1);
252
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
253
+ expect(replyText).toContain('no longer valid');
254
+ });
255
+
256
+ test('existing /start gv_<token> guardian bootstrap flow is unaffected', async () => {
257
+ // Send a /start gv_ command — should not be intercepted by the invite flow.
258
+ // Without a valid bootstrap session, it should be denied at the ACL gate.
259
+ const req = buildInboundRequest({
260
+ content: '/start gv_some_bootstrap_token',
261
+ sourceMetadata: {
262
+ commandIntent: { type: 'start', payload: 'gv_some_bootstrap_token' },
263
+ },
264
+ });
265
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
266
+ const json = await resp.json() as Record<string, unknown>;
267
+
268
+ // Should be denied as a non-member (bootstrap token is invalid/no session)
269
+ expect(json.denied).toBe(true);
270
+ expect(json.reason).toBe('not_a_member');
271
+ // Should NOT have invite redemption fields
272
+ expect(json.inviteRedemption).toBeUndefined();
273
+ });
274
+
275
+ test('duplicate Telegram webhook deliveries do not double-redeem', async () => {
276
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
277
+
278
+ const sharedMessageId = `msg-dedup-${Date.now()}`;
279
+ const makeReq = () => buildInviteRequest(rawToken, {
280
+ externalMessageId: sharedMessageId,
281
+ });
282
+
283
+ // First delivery
284
+ const resp1 = await handleChannelInbound(makeReq(), undefined, TEST_BEARER_TOKEN);
285
+ const json1 = await resp1.json() as Record<string, unknown>;
286
+ expect(json1.inviteRedemption).toBe('redeemed');
287
+
288
+ // Second delivery (duplicate webhook)
289
+ const resp2 = await handleChannelInbound(makeReq(), undefined, TEST_BEARER_TOKEN);
290
+ const json2 = await resp2.json() as Record<string, unknown>;
291
+ // Dedup kicks in — the message is treated as a duplicate and no second
292
+ // redemption attempt occurs.
293
+ expect(json2.duplicate).toBe(true);
294
+
295
+ // Only one welcome reply was delivered
296
+ expect(deliverReplyCalls.length).toBe(1);
297
+ });
298
+
299
+ test('existing active member sending normal message is unaffected', async () => {
300
+ // Pre-create an active member
301
+ upsertMember({
302
+ assistantId: 'self',
303
+ sourceChannel: 'telegram',
304
+ externalUserId: 'user-active-member',
305
+ externalChatId: 'chat-active',
306
+ status: 'active',
307
+ policy: 'allow',
308
+ });
309
+
310
+ // Active member sends a normal message (no invite token)
311
+ const req = buildInboundRequest({
312
+ content: 'Hello, just a normal message!',
313
+ senderExternalUserId: 'user-active-member',
314
+ externalChatId: 'chat-active',
315
+ sourceMetadata: {},
316
+ });
317
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
318
+ const json = await resp.json() as Record<string, unknown>;
319
+
320
+ // Should be accepted normally, not denied, not invite-redeemed
321
+ expect(json.accepted).toBe(true);
322
+ expect(json.denied).toBeUndefined();
323
+ expect(json.inviteRedemption).toBeUndefined();
324
+ });
325
+
326
+ test('channel mismatch returns appropriate message', async () => {
327
+ // Create an invite for SMS, but try to redeem via Telegram
328
+ const { rawToken } = createInvite({ sourceChannel: 'sms', maxUses: 5 });
329
+
330
+ const req = buildInviteRequest(rawToken);
331
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
332
+ const json = await resp.json() as Record<string, unknown>;
333
+
334
+ expect(json.accepted).toBe(true);
335
+ expect(json.denied).toBe(true);
336
+ expect(json.inviteRedemption).toBe('channel_mismatch');
337
+
338
+ expect(deliverReplyCalls.length).toBe(1);
339
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
340
+ expect(replyText).toContain('not valid for this channel');
341
+ });
342
+
343
+ test('already-active member with invite token gets acknowledgement', async () => {
344
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
345
+
346
+ // Pre-create an active member that will click the invite link
347
+ upsertMember({
348
+ assistantId: 'self',
349
+ sourceChannel: 'telegram',
350
+ externalUserId: 'user-already-active',
351
+ externalChatId: 'chat-invite-test',
352
+ status: 'active',
353
+ policy: 'allow',
354
+ });
355
+
356
+ const req = buildInviteRequest(rawToken, {
357
+ senderExternalUserId: 'user-already-active',
358
+ });
359
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
360
+ const json = await resp.json() as Record<string, unknown>;
361
+
362
+ // Active members pass through the ACL gate, so the invite intercept
363
+ // does not fire. The message proceeds to normal processing.
364
+ expect(json.accepted).toBe(true);
365
+ expect(json.denied).toBeUndefined();
366
+ });
367
+ });
@@ -0,0 +1,306 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'invite-redemption-service-test-'));
8
+
9
+ mock.module('../util/platform.js', () => ({
10
+ getDataDir: () => testDir,
11
+ isMacOS: () => process.platform === 'darwin',
12
+ isLinux: () => process.platform === 'linux',
13
+ isWindows: () => process.platform === 'win32',
14
+ getSocketPath: () => join(testDir, 'test.sock'),
15
+ getPidPath: () => join(testDir, 'test.pid'),
16
+ getDbPath: () => join(testDir, 'test.db'),
17
+ getLogPath: () => join(testDir, 'test.log'),
18
+ ensureDataDir: () => {},
19
+ }));
20
+
21
+ mock.module('../util/logger.js', () => ({
22
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
23
+ get: () => () => {},
24
+ }),
25
+ }));
26
+
27
+ import { getSqlite, initializeDb, resetDb } from '../memory/db.js';
28
+ import { createInvite, revokeInvite as revokeStoreFn } from '../memory/ingress-invite-store.js';
29
+ import { upsertMember } from '../memory/ingress-member-store.js';
30
+ import { type InviteRedemptionOutcome,redeemInvite } from '../runtime/invite-redemption-service.js';
31
+
32
+ initializeDb();
33
+
34
+ afterAll(() => {
35
+ resetDb();
36
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
37
+ });
38
+
39
+ function resetTables() {
40
+ getSqlite().run('DELETE FROM assistant_ingress_members');
41
+ getSqlite().run('DELETE FROM assistant_ingress_invites');
42
+ }
43
+
44
+ describe('invite-redemption-service', () => {
45
+ beforeEach(resetTables);
46
+
47
+ test('redeems a valid invite and returns typed outcome', () => {
48
+ const { rawToken, invite } = createInvite({ sourceChannel: 'telegram', maxUses: 1 });
49
+
50
+ const outcome = redeemInvite({
51
+ rawToken,
52
+ sourceChannel: 'telegram',
53
+ externalUserId: 'user-1',
54
+ });
55
+
56
+ expect(outcome.ok).toBe(true);
57
+ expect(outcome).toEqual({
58
+ ok: true,
59
+ type: 'redeemed',
60
+ memberId: expect.any(String),
61
+ inviteId: invite.id,
62
+ });
63
+ });
64
+
65
+ test('returns invalid_token for a bogus token', () => {
66
+ const outcome = redeemInvite({
67
+ rawToken: 'totally-bogus-token',
68
+ sourceChannel: 'telegram',
69
+ externalUserId: 'user-1',
70
+ });
71
+
72
+ expect(outcome).toEqual({ ok: false, reason: 'invalid_token' });
73
+ });
74
+
75
+ test('returns expired for an expired invite', () => {
76
+ // Create an invite that expired 1 ms ago
77
+ const { rawToken } = createInvite({
78
+ sourceChannel: 'telegram',
79
+ maxUses: 1,
80
+ expiresInMs: -1,
81
+ });
82
+
83
+ const outcome = redeemInvite({
84
+ rawToken,
85
+ sourceChannel: 'telegram',
86
+ externalUserId: 'user-1',
87
+ });
88
+
89
+ expect(outcome).toEqual({ ok: false, reason: 'expired' });
90
+ });
91
+
92
+ test('returns revoked for a revoked invite', () => {
93
+ const { rawToken, invite } = createInvite({
94
+ sourceChannel: 'telegram',
95
+ maxUses: 1,
96
+ });
97
+ revokeStoreFn(invite.id);
98
+
99
+ const outcome = redeemInvite({
100
+ rawToken,
101
+ sourceChannel: 'telegram',
102
+ externalUserId: 'user-1',
103
+ });
104
+
105
+ expect(outcome).toEqual({ ok: false, reason: 'revoked' });
106
+ });
107
+
108
+ test('returns max_uses_reached when invite is fully consumed', () => {
109
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 1 });
110
+
111
+ // First redemption should succeed
112
+ const first = redeemInvite({
113
+ rawToken,
114
+ sourceChannel: 'telegram',
115
+ externalUserId: 'user-1',
116
+ });
117
+ expect(first.ok).toBe(true);
118
+
119
+ // Second attempt should fail — the invite is now fully redeemed
120
+ const second = redeemInvite({
121
+ rawToken,
122
+ sourceChannel: 'telegram',
123
+ externalUserId: 'user-2',
124
+ });
125
+
126
+ expect(second).toEqual({ ok: false, reason: 'max_uses_reached' });
127
+ });
128
+
129
+ test('returns channel_mismatch when redeeming on wrong channel', () => {
130
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 1 });
131
+
132
+ const outcome = redeemInvite({
133
+ rawToken,
134
+ sourceChannel: 'sms',
135
+ externalUserId: 'user-1',
136
+ });
137
+
138
+ expect(outcome).toEqual({ ok: false, reason: 'channel_mismatch' });
139
+ });
140
+
141
+ test('returns missing_identity when no externalUserId or externalChatId', () => {
142
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 1 });
143
+
144
+ const outcome = redeemInvite({
145
+ rawToken,
146
+ sourceChannel: 'telegram',
147
+ });
148
+
149
+ expect(outcome).toEqual({ ok: false, reason: 'missing_identity' });
150
+ });
151
+
152
+ test('returns already_member when user is already an active member', () => {
153
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
154
+
155
+ // Pre-create an active member
156
+ upsertMember({
157
+ sourceChannel: 'telegram',
158
+ externalUserId: 'existing-user',
159
+ status: 'active',
160
+ });
161
+
162
+ const outcome = redeemInvite({
163
+ rawToken,
164
+ sourceChannel: 'telegram',
165
+ externalUserId: 'existing-user',
166
+ });
167
+
168
+ expect(outcome.ok).toBe(true);
169
+ expect((outcome as Extract<InviteRedemptionOutcome, { type: 'already_member' }>).type).toBe('already_member');
170
+ expect((outcome as Extract<InviteRedemptionOutcome, { type: 'already_member' }>).memberId).toEqual(expect.any(String));
171
+ });
172
+
173
+ test('returns invalid_token for a blocked member to avoid leaking membership status', () => {
174
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
175
+
176
+ // Pre-create a blocked member — simulates a guardian-initiated block
177
+ upsertMember({
178
+ sourceChannel: 'telegram',
179
+ externalUserId: 'blocked-user',
180
+ status: 'blocked',
181
+ });
182
+
183
+ const outcome = redeemInvite({
184
+ rawToken,
185
+ sourceChannel: 'telegram',
186
+ externalUserId: 'blocked-user',
187
+ });
188
+
189
+ expect(outcome).toEqual({ ok: false, reason: 'invalid_token' });
190
+ });
191
+
192
+ test('does not return already_member for a revoked member', () => {
193
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
194
+
195
+ // Pre-create a revoked member
196
+ const member = upsertMember({
197
+ sourceChannel: 'telegram',
198
+ externalUserId: 'revoked-user',
199
+ status: 'revoked',
200
+ });
201
+ expect(member.status).toBe('revoked');
202
+
203
+ const outcome = redeemInvite({
204
+ rawToken,
205
+ sourceChannel: 'telegram',
206
+ externalUserId: 'revoked-user',
207
+ });
208
+
209
+ // Should redeem, not return already_member
210
+ expect(outcome.ok).toBe(true);
211
+ expect((outcome as Extract<InviteRedemptionOutcome, { type: 'redeemed' }>).type).toBe('redeemed');
212
+ });
213
+
214
+ test('raw token is not present in the outcome object', () => {
215
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 1 });
216
+
217
+ const outcome = redeemInvite({
218
+ rawToken,
219
+ sourceChannel: 'telegram',
220
+ externalUserId: 'user-1',
221
+ });
222
+
223
+ // Verify the raw token does not appear anywhere in the serialized outcome
224
+ const serialized = JSON.stringify(outcome);
225
+ expect(serialized).not.toContain(rawToken);
226
+ });
227
+
228
+ test('channel enforcement blocks cross-channel redemption (voice invite via slack)', () => {
229
+ const { rawToken } = createInvite({ sourceChannel: 'voice', maxUses: 1 });
230
+
231
+ const outcome = redeemInvite({
232
+ rawToken,
233
+ sourceChannel: 'slack',
234
+ externalUserId: 'user-1',
235
+ });
236
+
237
+ expect(outcome).toEqual({ ok: false, reason: 'channel_mismatch' });
238
+ });
239
+
240
+ test('returns invalid_token for an active member with a bogus token (no membership probing)', () => {
241
+ // Pre-create an active member
242
+ upsertMember({
243
+ sourceChannel: 'telegram',
244
+ externalUserId: 'probed-user',
245
+ status: 'active',
246
+ });
247
+
248
+ // Attempt to redeem with a bogus token — must NOT leak membership status
249
+ const outcome = redeemInvite({
250
+ rawToken: 'completely-bogus-token',
251
+ sourceChannel: 'telegram',
252
+ externalUserId: 'probed-user',
253
+ });
254
+
255
+ expect(outcome).toEqual({ ok: false, reason: 'invalid_token' });
256
+ });
257
+
258
+ test('returns expired for an active member with an expired invite token', () => {
259
+ // Create an expired invite
260
+ const { rawToken } = createInvite({
261
+ sourceChannel: 'telegram',
262
+ maxUses: 5,
263
+ expiresInMs: -1,
264
+ });
265
+
266
+ // Pre-create an active member
267
+ upsertMember({
268
+ sourceChannel: 'telegram',
269
+ externalUserId: 'expired-token-user',
270
+ status: 'active',
271
+ });
272
+
273
+ // Expired token must return expired, not already_member
274
+ const outcome = redeemInvite({
275
+ rawToken,
276
+ sourceChannel: 'telegram',
277
+ externalUserId: 'expired-token-user',
278
+ });
279
+
280
+ expect(outcome).toEqual({ ok: false, reason: 'expired' });
281
+ });
282
+
283
+ test('returns channel_mismatch for an active member with a valid token for a different channel', () => {
284
+ // Create an invite for SMS
285
+ const { rawToken } = createInvite({
286
+ sourceChannel: 'sms',
287
+ maxUses: 5,
288
+ });
289
+
290
+ // Pre-create an active member on telegram
291
+ upsertMember({
292
+ sourceChannel: 'telegram',
293
+ externalUserId: 'cross-channel-user',
294
+ status: 'active',
295
+ });
296
+
297
+ // Valid token for wrong channel must return channel_mismatch, not already_member
298
+ const outcome = redeemInvite({
299
+ rawToken,
300
+ sourceChannel: 'telegram',
301
+ externalUserId: 'cross-channel-user',
302
+ });
303
+
304
+ expect(outcome).toEqual({ ok: false, reason: 'channel_mismatch' });
305
+ });
306
+ });