@vellumai/assistant 0.4.16 → 0.4.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +6 -6
- package/README.md +1 -2
- package/package.json +1 -1
- package/src/__tests__/call-controller.test.ts +1074 -751
- package/src/__tests__/call-routes-http.test.ts +329 -279
- package/src/__tests__/channel-approval-routes.test.ts +0 -11
- package/src/__tests__/channel-approvals.test.ts +227 -182
- package/src/__tests__/channel-guardian.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +157 -114
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +164 -104
- package/src/__tests__/conversation-routes.test.ts +71 -41
- package/src/__tests__/daemon-server-session-init.test.ts +258 -191
- package/src/__tests__/deterministic-verification-control-plane.test.ts +183 -134
- package/src/__tests__/extract-email.test.ts +42 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +467 -368
- package/src/__tests__/gateway-only-guard.test.ts +54 -55
- package/src/__tests__/gmail-integration.test.ts +48 -46
- package/src/__tests__/guardian-action-followup-executor.test.ts +215 -150
- package/src/__tests__/guardian-outbound-http.test.ts +334 -208
- package/src/__tests__/guardian-routing-invariants.test.ts +680 -613
- package/src/__tests__/guardian-routing-state.test.ts +257 -209
- package/src/__tests__/guardian-verification-voice-binding.test.ts +47 -40
- package/src/__tests__/handle-user-message-secret-resume.test.ts +44 -21
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +269 -195
- package/src/__tests__/inbound-invite-redemption.test.ts +194 -151
- package/src/__tests__/ingress-reconcile.test.ts +184 -142
- package/src/__tests__/non-member-access-request.test.ts +291 -247
- package/src/__tests__/notification-telegram-adapter.test.ts +60 -46
- package/src/__tests__/recording-intent-handler.test.ts +422 -291
- package/src/__tests__/runtime-attachment-metadata.test.ts +107 -69
- package/src/__tests__/runtime-events-sse.test.ts +67 -50
- package/src/__tests__/send-endpoint-busy.test.ts +314 -232
- package/src/__tests__/session-approval-overrides.test.ts +93 -91
- package/src/__tests__/sms-messaging-provider.test.ts +74 -47
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +339 -274
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +484 -372
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +261 -239
- package/src/__tests__/trusted-contact-multichannel.test.ts +179 -140
- package/src/__tests__/twilio-config.test.ts +49 -41
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +189 -162
- package/src/__tests__/twilio-routes.test.ts +389 -280
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +29 -4
- package/src/config/bundled-skills/messaging/SKILL.md +5 -4
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +11 -7
- package/src/config/env.ts +39 -29
- package/src/daemon/handlers/skills.ts +18 -10
- package/src/daemon/ipc-contract/messages.ts +1 -0
- package/src/daemon/ipc-contract/surfaces.ts +7 -1
- package/src/daemon/session-agent-loop-handlers.ts +5 -0
- package/src/daemon/session-agent-loop.ts +1 -1
- package/src/daemon/session-process.ts +1 -1
- package/src/daemon/session-surfaces.ts +42 -2
- package/src/runtime/auth/token-service.ts +74 -47
- package/src/sequence/reply-matcher.ts +10 -6
- package/src/skills/frontmatter.ts +9 -6
- package/src/tools/ui-surface/definitions.ts +2 -1
- package/src/util/platform.ts +0 -12
- package/docs/architecture/http-token-refresh.md +0 -274
|
@@ -5,49 +5,51 @@
|
|
|
5
5
|
* granted access without guardian approval, and that invalid/expired/revoked
|
|
6
6
|
* tokens produce the correct deterministic refusal messages.
|
|
7
7
|
*/
|
|
8
|
-
import { mkdtempSync, rmSync } from
|
|
9
|
-
import { tmpdir } from
|
|
10
|
-
import { join } from
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
-
import { afterAll, beforeEach, describe, expect, mock, test } from
|
|
12
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Test isolation: in-memory SQLite via temp directory
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
|
-
const testDir = mkdtempSync(join(tmpdir(),
|
|
18
|
+
const testDir = mkdtempSync(join(tmpdir(), "inbound-invite-redemption-test-"));
|
|
19
19
|
|
|
20
|
-
mock.module(
|
|
20
|
+
mock.module("../util/platform.js", () => ({
|
|
21
21
|
getRootDir: () => testDir,
|
|
22
22
|
getDataDir: () => testDir,
|
|
23
|
-
isMacOS: () => process.platform ===
|
|
24
|
-
isLinux: () => process.platform ===
|
|
25
|
-
isWindows: () => process.platform ===
|
|
26
|
-
getSocketPath: () => join(testDir,
|
|
27
|
-
getPidPath: () => join(testDir,
|
|
28
|
-
getDbPath: () => join(testDir,
|
|
29
|
-
getLogPath: () => join(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
30
|
ensureDataDir: () => {},
|
|
31
|
-
readHttpToken: () =>
|
|
31
|
+
readHttpToken: () => "test-bearer-token",
|
|
32
32
|
}));
|
|
33
33
|
|
|
34
|
-
mock.module(
|
|
35
|
-
getLogger: () =>
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
mock.module("../util/logger.js", () => ({
|
|
35
|
+
getLogger: () =>
|
|
36
|
+
new Proxy({} as Record<string, unknown>, {
|
|
37
|
+
get: () => () => {},
|
|
38
|
+
}),
|
|
38
39
|
}));
|
|
39
40
|
|
|
40
|
-
mock.module(
|
|
41
|
+
mock.module("../security/secret-ingress.js", () => ({
|
|
41
42
|
checkIngressForSecrets: () => ({ blocked: false }),
|
|
42
43
|
}));
|
|
43
44
|
|
|
44
|
-
mock.module(
|
|
45
|
-
|
|
45
|
+
mock.module("../config/env.js", () => ({
|
|
46
|
+
isHttpAuthDisabled: () => true,
|
|
47
|
+
getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830",
|
|
46
48
|
}));
|
|
47
49
|
|
|
48
50
|
// Mock the credential metadata store so the Telegram transport adapter
|
|
49
51
|
// resolves without touching the filesystem.
|
|
50
|
-
mock.module(
|
|
52
|
+
mock.module("../tools/credentials/metadata-store.js", () => ({
|
|
51
53
|
getCredentialMetadata: () => undefined,
|
|
52
54
|
upsertCredentialMetadata: () => {},
|
|
53
55
|
deleteCredentialMetadata: () => {},
|
|
@@ -55,59 +57,69 @@ mock.module('../tools/credentials/metadata-store.js', () => ({
|
|
|
55
57
|
}));
|
|
56
58
|
|
|
57
59
|
const emitSignalCalls: Array<Record<string, unknown>> = [];
|
|
58
|
-
mock.module(
|
|
60
|
+
mock.module("../notifications/emit-signal.js", () => ({
|
|
59
61
|
emitNotificationSignal: async (params: Record<string, unknown>) => {
|
|
60
62
|
emitSignalCalls.push(params);
|
|
61
63
|
return {
|
|
62
|
-
signalId:
|
|
64
|
+
signalId: "mock-signal-id",
|
|
63
65
|
deduplicated: false,
|
|
64
66
|
dispatched: true,
|
|
65
|
-
reason:
|
|
67
|
+
reason: "mock",
|
|
66
68
|
deliveryResults: [],
|
|
67
69
|
};
|
|
68
70
|
},
|
|
69
71
|
}));
|
|
70
72
|
|
|
71
|
-
const deliverReplyCalls: Array<{
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
const deliverReplyCalls: Array<{
|
|
74
|
+
url: string;
|
|
75
|
+
payload: Record<string, unknown>;
|
|
76
|
+
}> = [];
|
|
77
|
+
mock.module("../runtime/gateway-client.js", () => ({
|
|
78
|
+
deliverChannelReply: async (
|
|
79
|
+
url: string,
|
|
80
|
+
payload: Record<string, unknown>,
|
|
81
|
+
) => {
|
|
74
82
|
deliverReplyCalls.push({ url, payload });
|
|
75
83
|
},
|
|
76
84
|
}));
|
|
77
85
|
|
|
78
|
-
mock.module(
|
|
79
|
-
composeApprovalMessage: () =>
|
|
80
|
-
composeApprovalMessageGenerative: async () =>
|
|
86
|
+
mock.module("../runtime/approval-message-composer.js", () => ({
|
|
87
|
+
composeApprovalMessage: () => "mock approval message",
|
|
88
|
+
composeApprovalMessageGenerative: async () => "mock generative message",
|
|
81
89
|
}));
|
|
82
90
|
|
|
83
|
-
import { getDb, initializeDb, resetDb } from
|
|
84
|
-
import { createInvite, revokeInvite } from
|
|
85
|
-
import { findMember, upsertMember } from
|
|
86
|
-
import { handleChannelInbound } from
|
|
91
|
+
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
92
|
+
import { createInvite, revokeInvite } from "../memory/ingress-invite-store.js";
|
|
93
|
+
import { findMember, upsertMember } from "../memory/ingress-member-store.js";
|
|
94
|
+
import { handleChannelInbound } from "../runtime/routes/channel-routes.js";
|
|
87
95
|
|
|
88
96
|
initializeDb();
|
|
89
97
|
|
|
90
98
|
afterAll(() => {
|
|
91
99
|
resetDb();
|
|
92
|
-
try {
|
|
100
|
+
try {
|
|
101
|
+
rmSync(testDir, { recursive: true });
|
|
102
|
+
} catch {
|
|
103
|
+
/* best effort */
|
|
104
|
+
}
|
|
93
105
|
});
|
|
94
106
|
|
|
95
107
|
// ---------------------------------------------------------------------------
|
|
96
108
|
// Helpers
|
|
97
109
|
// ---------------------------------------------------------------------------
|
|
98
110
|
|
|
99
|
-
const TEST_BEARER_TOKEN =
|
|
111
|
+
const TEST_BEARER_TOKEN = "test-token";
|
|
100
112
|
let msgCounter = 0;
|
|
101
113
|
|
|
102
114
|
function resetState(): void {
|
|
103
115
|
const db = getDb();
|
|
104
|
-
db.run(
|
|
105
|
-
db.run(
|
|
106
|
-
db.run(
|
|
107
|
-
db.run(
|
|
108
|
-
db.run(
|
|
109
|
-
db.run(
|
|
110
|
-
db.run(
|
|
116
|
+
db.run("DELETE FROM assistant_ingress_members");
|
|
117
|
+
db.run("DELETE FROM assistant_ingress_invites");
|
|
118
|
+
db.run("DELETE FROM channel_inbound_events");
|
|
119
|
+
db.run("DELETE FROM conversations");
|
|
120
|
+
db.run("DELETE FROM channel_guardian_approval_requests");
|
|
121
|
+
db.run("DELETE FROM channel_guardian_bindings");
|
|
122
|
+
db.run("DELETE FROM notification_events");
|
|
111
123
|
emitSignalCalls.length = 0;
|
|
112
124
|
deliverReplyCalls.length = 0;
|
|
113
125
|
msgCounter = 0;
|
|
@@ -116,26 +128,26 @@ function resetState(): void {
|
|
|
116
128
|
function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
117
129
|
msgCounter++;
|
|
118
130
|
const body: Record<string, unknown> = {
|
|
119
|
-
sourceChannel:
|
|
120
|
-
interface:
|
|
121
|
-
conversationExternalId:
|
|
131
|
+
sourceChannel: "telegram",
|
|
132
|
+
interface: "telegram",
|
|
133
|
+
conversationExternalId: "chat-invite-test",
|
|
122
134
|
externalMessageId: `msg-invite-${Date.now()}-${msgCounter}`,
|
|
123
|
-
content:
|
|
124
|
-
actorExternalId:
|
|
125
|
-
actorDisplayName:
|
|
126
|
-
actorUsername:
|
|
127
|
-
replyCallbackUrl:
|
|
135
|
+
content: "/start iv_sometoken",
|
|
136
|
+
actorExternalId: "user-invite-123",
|
|
137
|
+
actorDisplayName: "Invite User",
|
|
138
|
+
actorUsername: "invite_user",
|
|
139
|
+
replyCallbackUrl: "http://localhost:7830/deliver/telegram",
|
|
128
140
|
sourceMetadata: {
|
|
129
|
-
commandIntent: { type:
|
|
141
|
+
commandIntent: { type: "start", payload: "iv_sometoken" },
|
|
130
142
|
},
|
|
131
143
|
...overrides,
|
|
132
144
|
};
|
|
133
145
|
|
|
134
|
-
return new Request(
|
|
135
|
-
method:
|
|
146
|
+
return new Request("http://localhost:8080/channels/inbound", {
|
|
147
|
+
method: "POST",
|
|
136
148
|
headers: {
|
|
137
|
-
|
|
138
|
-
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
"X-Gateway-Origin": TEST_BEARER_TOKEN,
|
|
139
151
|
},
|
|
140
152
|
body: JSON.stringify(body),
|
|
141
153
|
});
|
|
@@ -145,11 +157,14 @@ function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
|
145
157
|
* Build a request with a specific invite token, using the structured
|
|
146
158
|
* commandIntent that the gateway produces for `/start <payload>`.
|
|
147
159
|
*/
|
|
148
|
-
function buildInviteRequest(
|
|
160
|
+
function buildInviteRequest(
|
|
161
|
+
rawToken: string,
|
|
162
|
+
overrides: Record<string, unknown> = {},
|
|
163
|
+
): Request {
|
|
149
164
|
return buildInboundRequest({
|
|
150
165
|
content: `/start iv_${rawToken}`,
|
|
151
166
|
sourceMetadata: {
|
|
152
|
-
commandIntent: { type:
|
|
167
|
+
commandIntent: { type: "start", payload: `iv_${rawToken}` },
|
|
153
168
|
},
|
|
154
169
|
...overrides,
|
|
155
170
|
});
|
|
@@ -159,134 +174,155 @@ function buildInviteRequest(rawToken: string, overrides: Record<string, unknown>
|
|
|
159
174
|
// Tests
|
|
160
175
|
// ---------------------------------------------------------------------------
|
|
161
176
|
|
|
162
|
-
describe(
|
|
177
|
+
describe("inbound invite redemption intercept", () => {
|
|
163
178
|
beforeEach(resetState);
|
|
164
179
|
|
|
165
|
-
test(
|
|
166
|
-
const { rawToken } = createInvite({
|
|
180
|
+
test("non-member with valid invite token becomes active member without guardian approval", async () => {
|
|
181
|
+
const { rawToken } = createInvite({
|
|
182
|
+
sourceChannel: "telegram",
|
|
183
|
+
maxUses: 5,
|
|
184
|
+
});
|
|
167
185
|
|
|
168
186
|
const req = buildInviteRequest(rawToken);
|
|
169
187
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
170
|
-
const json = await resp.json() as Record<string, unknown>;
|
|
188
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
171
189
|
|
|
172
190
|
expect(json.accepted).toBe(true);
|
|
173
|
-
expect(json.inviteRedemption).toBe(
|
|
191
|
+
expect(json.inviteRedemption).toBe("redeemed");
|
|
174
192
|
expect(json.memberId).toEqual(expect.any(String));
|
|
175
193
|
expect(json.denied).toBeUndefined();
|
|
176
194
|
|
|
177
195
|
// Verify the user is now an active member
|
|
178
196
|
const member = findMember({
|
|
179
|
-
assistantId:
|
|
180
|
-
sourceChannel:
|
|
181
|
-
externalUserId:
|
|
197
|
+
assistantId: "self",
|
|
198
|
+
sourceChannel: "telegram",
|
|
199
|
+
externalUserId: "user-invite-123",
|
|
182
200
|
});
|
|
183
201
|
expect(member).not.toBeNull();
|
|
184
|
-
expect(member!.status).toBe(
|
|
202
|
+
expect(member!.status).toBe("active");
|
|
185
203
|
|
|
186
204
|
// Verify a welcome reply was delivered
|
|
187
205
|
expect(deliverReplyCalls.length).toBe(1);
|
|
188
|
-
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>)
|
|
189
|
-
|
|
206
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>)
|
|
207
|
+
.text;
|
|
208
|
+
expect(replyText).toContain(
|
|
209
|
+
"Welcome! You've been granted access via invite link.",
|
|
210
|
+
);
|
|
190
211
|
});
|
|
191
212
|
|
|
192
|
-
test(
|
|
193
|
-
const req = buildInviteRequest(
|
|
213
|
+
test("non-member with invalid token gets refusal text", async () => {
|
|
214
|
+
const req = buildInviteRequest("completely-bogus-token-xyz");
|
|
194
215
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
195
|
-
const json = await resp.json() as Record<string, unknown>;
|
|
216
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
196
217
|
|
|
197
218
|
expect(json.accepted).toBe(true);
|
|
198
219
|
expect(json.denied).toBe(true);
|
|
199
|
-
expect(json.inviteRedemption).toBe(
|
|
220
|
+
expect(json.inviteRedemption).toBe("invalid_token");
|
|
200
221
|
|
|
201
222
|
// Verify refusal reply was delivered
|
|
202
223
|
expect(deliverReplyCalls.length).toBe(1);
|
|
203
|
-
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>)
|
|
204
|
-
|
|
224
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>)
|
|
225
|
+
.text;
|
|
226
|
+
expect(replyText).toContain("no longer valid");
|
|
205
227
|
|
|
206
228
|
// Verify the user was NOT made a member
|
|
207
229
|
const member = findMember({
|
|
208
|
-
assistantId:
|
|
209
|
-
sourceChannel:
|
|
210
|
-
externalUserId:
|
|
230
|
+
assistantId: "self",
|
|
231
|
+
sourceChannel: "telegram",
|
|
232
|
+
externalUserId: "user-invite-123",
|
|
211
233
|
});
|
|
212
234
|
expect(member).toBeNull();
|
|
213
235
|
});
|
|
214
236
|
|
|
215
|
-
test(
|
|
237
|
+
test("non-member with expired token gets appropriate message", async () => {
|
|
216
238
|
const { rawToken } = createInvite({
|
|
217
|
-
sourceChannel:
|
|
239
|
+
sourceChannel: "telegram",
|
|
218
240
|
maxUses: 1,
|
|
219
241
|
expiresInMs: -1, // already expired
|
|
220
242
|
});
|
|
221
243
|
|
|
222
244
|
const req = buildInviteRequest(rawToken);
|
|
223
245
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
224
|
-
const json = await resp.json() as Record<string, unknown>;
|
|
246
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
225
247
|
|
|
226
248
|
expect(json.accepted).toBe(true);
|
|
227
249
|
expect(json.denied).toBe(true);
|
|
228
|
-
expect(json.inviteRedemption).toBe(
|
|
250
|
+
expect(json.inviteRedemption).toBe("expired");
|
|
229
251
|
|
|
230
252
|
expect(deliverReplyCalls.length).toBe(1);
|
|
231
|
-
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>)
|
|
232
|
-
|
|
253
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>)
|
|
254
|
+
.text;
|
|
255
|
+
expect(replyText).toContain("no longer valid");
|
|
233
256
|
});
|
|
234
257
|
|
|
235
|
-
test(
|
|
258
|
+
test("non-member with revoked token gets refusal text", async () => {
|
|
236
259
|
const { rawToken, invite } = createInvite({
|
|
237
|
-
sourceChannel:
|
|
260
|
+
sourceChannel: "telegram",
|
|
238
261
|
maxUses: 5,
|
|
239
262
|
});
|
|
240
263
|
revokeInvite(invite.id);
|
|
241
264
|
|
|
242
265
|
const req = buildInviteRequest(rawToken);
|
|
243
266
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
244
|
-
const json = await resp.json() as Record<string, unknown>;
|
|
267
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
245
268
|
|
|
246
269
|
expect(json.accepted).toBe(true);
|
|
247
270
|
expect(json.denied).toBe(true);
|
|
248
|
-
expect(json.inviteRedemption).toBe(
|
|
271
|
+
expect(json.inviteRedemption).toBe("revoked");
|
|
249
272
|
|
|
250
273
|
expect(deliverReplyCalls.length).toBe(1);
|
|
251
|
-
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>)
|
|
252
|
-
|
|
274
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>)
|
|
275
|
+
.text;
|
|
276
|
+
expect(replyText).toContain("no longer valid");
|
|
253
277
|
});
|
|
254
278
|
|
|
255
|
-
test(
|
|
279
|
+
test("existing /start gv_<token> guardian bootstrap flow is unaffected", async () => {
|
|
256
280
|
// Send a /start gv_ command — should not be intercepted by the invite flow.
|
|
257
281
|
// Without a valid bootstrap session, it should be denied at the ACL gate.
|
|
258
282
|
const req = buildInboundRequest({
|
|
259
|
-
content:
|
|
283
|
+
content: "/start gv_some_bootstrap_token",
|
|
260
284
|
sourceMetadata: {
|
|
261
|
-
commandIntent: { type:
|
|
285
|
+
commandIntent: { type: "start", payload: "gv_some_bootstrap_token" },
|
|
262
286
|
},
|
|
263
287
|
});
|
|
264
288
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
265
|
-
const json = await resp.json() as Record<string, unknown>;
|
|
289
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
266
290
|
|
|
267
291
|
// Should be denied as a non-member (bootstrap token is invalid/no session)
|
|
268
292
|
expect(json.denied).toBe(true);
|
|
269
|
-
expect(json.reason).toBe(
|
|
293
|
+
expect(json.reason).toBe("not_a_member");
|
|
270
294
|
// Should NOT have invite redemption fields
|
|
271
295
|
expect(json.inviteRedemption).toBeUndefined();
|
|
272
296
|
});
|
|
273
297
|
|
|
274
|
-
test(
|
|
275
|
-
const { rawToken } = createInvite({
|
|
298
|
+
test("duplicate Telegram webhook deliveries do not double-redeem", async () => {
|
|
299
|
+
const { rawToken } = createInvite({
|
|
300
|
+
sourceChannel: "telegram",
|
|
301
|
+
maxUses: 5,
|
|
302
|
+
});
|
|
276
303
|
|
|
277
304
|
const sharedMessageId = `msg-dedup-${Date.now()}`;
|
|
278
|
-
const makeReq = () =>
|
|
279
|
-
|
|
280
|
-
|
|
305
|
+
const makeReq = () =>
|
|
306
|
+
buildInviteRequest(rawToken, {
|
|
307
|
+
externalMessageId: sharedMessageId,
|
|
308
|
+
});
|
|
281
309
|
|
|
282
310
|
// First delivery
|
|
283
|
-
const resp1 = await handleChannelInbound(
|
|
284
|
-
|
|
285
|
-
|
|
311
|
+
const resp1 = await handleChannelInbound(
|
|
312
|
+
makeReq(),
|
|
313
|
+
undefined,
|
|
314
|
+
TEST_BEARER_TOKEN,
|
|
315
|
+
);
|
|
316
|
+
const json1 = (await resp1.json()) as Record<string, unknown>;
|
|
317
|
+
expect(json1.inviteRedemption).toBe("redeemed");
|
|
286
318
|
|
|
287
319
|
// Second delivery (duplicate webhook)
|
|
288
|
-
const resp2 = await handleChannelInbound(
|
|
289
|
-
|
|
320
|
+
const resp2 = await handleChannelInbound(
|
|
321
|
+
makeReq(),
|
|
322
|
+
undefined,
|
|
323
|
+
TEST_BEARER_TOKEN,
|
|
324
|
+
);
|
|
325
|
+
const json2 = (await resp2.json()) as Record<string, unknown>;
|
|
290
326
|
// Dedup kicks in — the message is treated as a duplicate and no second
|
|
291
327
|
// redemption attempt occurs.
|
|
292
328
|
expect(json2.duplicate).toBe(true);
|
|
@@ -295,26 +331,26 @@ describe('inbound invite redemption intercept', () => {
|
|
|
295
331
|
expect(deliverReplyCalls.length).toBe(1);
|
|
296
332
|
});
|
|
297
333
|
|
|
298
|
-
test(
|
|
334
|
+
test("existing active member sending normal message is unaffected", async () => {
|
|
299
335
|
// Pre-create an active member
|
|
300
336
|
upsertMember({
|
|
301
|
-
assistantId:
|
|
302
|
-
sourceChannel:
|
|
303
|
-
externalUserId:
|
|
304
|
-
externalChatId:
|
|
305
|
-
status:
|
|
306
|
-
policy:
|
|
337
|
+
assistantId: "self",
|
|
338
|
+
sourceChannel: "telegram",
|
|
339
|
+
externalUserId: "user-active-member",
|
|
340
|
+
externalChatId: "chat-active",
|
|
341
|
+
status: "active",
|
|
342
|
+
policy: "allow",
|
|
307
343
|
});
|
|
308
344
|
|
|
309
345
|
// Active member sends a normal message (no invite token)
|
|
310
346
|
const req = buildInboundRequest({
|
|
311
|
-
content:
|
|
312
|
-
actorExternalId:
|
|
313
|
-
conversationExternalId:
|
|
347
|
+
content: "Hello, just a normal message!",
|
|
348
|
+
actorExternalId: "user-active-member",
|
|
349
|
+
conversationExternalId: "chat-active",
|
|
314
350
|
sourceMetadata: {},
|
|
315
351
|
});
|
|
316
352
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
317
|
-
const json = await resp.json() as Record<string, unknown>;
|
|
353
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
318
354
|
|
|
319
355
|
// Should be accepted normally, not denied, not invite-redeemed
|
|
320
356
|
expect(json.accepted).toBe(true);
|
|
@@ -322,41 +358,45 @@ describe('inbound invite redemption intercept', () => {
|
|
|
322
358
|
expect(json.inviteRedemption).toBeUndefined();
|
|
323
359
|
});
|
|
324
360
|
|
|
325
|
-
test(
|
|
361
|
+
test("channel mismatch returns appropriate message", async () => {
|
|
326
362
|
// Create an invite for SMS, but try to redeem via Telegram
|
|
327
|
-
const { rawToken } = createInvite({ sourceChannel:
|
|
363
|
+
const { rawToken } = createInvite({ sourceChannel: "sms", maxUses: 5 });
|
|
328
364
|
|
|
329
365
|
const req = buildInviteRequest(rawToken);
|
|
330
366
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
331
|
-
const json = await resp.json() as Record<string, unknown>;
|
|
367
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
332
368
|
|
|
333
369
|
expect(json.accepted).toBe(true);
|
|
334
370
|
expect(json.denied).toBe(true);
|
|
335
|
-
expect(json.inviteRedemption).toBe(
|
|
371
|
+
expect(json.inviteRedemption).toBe("channel_mismatch");
|
|
336
372
|
|
|
337
373
|
expect(deliverReplyCalls.length).toBe(1);
|
|
338
|
-
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>)
|
|
339
|
-
|
|
374
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>)
|
|
375
|
+
.text;
|
|
376
|
+
expect(replyText).toContain("not valid for this channel");
|
|
340
377
|
});
|
|
341
378
|
|
|
342
|
-
test(
|
|
343
|
-
const { rawToken } = createInvite({
|
|
379
|
+
test("already-active member with invite token gets acknowledgement", async () => {
|
|
380
|
+
const { rawToken } = createInvite({
|
|
381
|
+
sourceChannel: "telegram",
|
|
382
|
+
maxUses: 5,
|
|
383
|
+
});
|
|
344
384
|
|
|
345
385
|
// Pre-create an active member that will click the invite link
|
|
346
386
|
upsertMember({
|
|
347
|
-
assistantId:
|
|
348
|
-
sourceChannel:
|
|
349
|
-
externalUserId:
|
|
350
|
-
externalChatId:
|
|
351
|
-
status:
|
|
352
|
-
policy:
|
|
387
|
+
assistantId: "self",
|
|
388
|
+
sourceChannel: "telegram",
|
|
389
|
+
externalUserId: "user-already-active",
|
|
390
|
+
externalChatId: "chat-invite-test",
|
|
391
|
+
status: "active",
|
|
392
|
+
policy: "allow",
|
|
353
393
|
});
|
|
354
394
|
|
|
355
395
|
const req = buildInviteRequest(rawToken, {
|
|
356
|
-
actorExternalId:
|
|
396
|
+
actorExternalId: "user-already-active",
|
|
357
397
|
});
|
|
358
398
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
359
|
-
const json = await resp.json() as Record<string, unknown>;
|
|
399
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
360
400
|
|
|
361
401
|
// Active members pass through the ACL gate, so the invite intercept
|
|
362
402
|
// does not fire. The message proceeds to normal processing.
|
|
@@ -364,36 +404,39 @@ describe('inbound invite redemption intercept', () => {
|
|
|
364
404
|
expect(json.denied).toBeUndefined();
|
|
365
405
|
});
|
|
366
406
|
|
|
367
|
-
test(
|
|
368
|
-
const { rawToken } = createInvite({
|
|
407
|
+
test("reactivation via invite preserves existing guardian-managed member display name", async () => {
|
|
408
|
+
const { rawToken } = createInvite({
|
|
409
|
+
sourceChannel: "telegram",
|
|
410
|
+
maxUses: 5,
|
|
411
|
+
});
|
|
369
412
|
|
|
370
413
|
upsertMember({
|
|
371
|
-
assistantId:
|
|
372
|
-
sourceChannel:
|
|
373
|
-
externalUserId:
|
|
374
|
-
externalChatId:
|
|
375
|
-
status:
|
|
376
|
-
policy:
|
|
377
|
-
displayName:
|
|
414
|
+
assistantId: "self",
|
|
415
|
+
sourceChannel: "telegram",
|
|
416
|
+
externalUserId: "user-invite-123",
|
|
417
|
+
externalChatId: "chat-invite-test",
|
|
418
|
+
status: "revoked",
|
|
419
|
+
policy: "allow",
|
|
420
|
+
displayName: "Jeff",
|
|
378
421
|
});
|
|
379
422
|
|
|
380
423
|
const req = buildInviteRequest(rawToken, {
|
|
381
|
-
actorDisplayName:
|
|
424
|
+
actorDisplayName: "Noa Flaherty",
|
|
382
425
|
});
|
|
383
426
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
384
|
-
const json = await resp.json() as Record<string, unknown>;
|
|
427
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
385
428
|
|
|
386
429
|
expect(json.accepted).toBe(true);
|
|
387
|
-
expect(json.inviteRedemption).toBe(
|
|
430
|
+
expect(json.inviteRedemption).toBe("redeemed");
|
|
388
431
|
|
|
389
432
|
const member = findMember({
|
|
390
|
-
assistantId:
|
|
391
|
-
sourceChannel:
|
|
392
|
-
externalUserId:
|
|
393
|
-
externalChatId:
|
|
433
|
+
assistantId: "self",
|
|
434
|
+
sourceChannel: "telegram",
|
|
435
|
+
externalUserId: "user-invite-123",
|
|
436
|
+
externalChatId: "chat-invite-test",
|
|
394
437
|
});
|
|
395
438
|
expect(member).not.toBeNull();
|
|
396
|
-
expect(member!.status).toBe(
|
|
397
|
-
expect(member!.displayName).toBe(
|
|
439
|
+
expect(member!.status).toBe("active");
|
|
440
|
+
expect(member!.displayName).toBe("Jeff");
|
|
398
441
|
});
|
|
399
442
|
});
|