@vellumai/assistant 0.11.2-dev.202608070442.84135c4 → 0.11.2-dev.202608070534.d55eaf1
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/node_modules/@vellumai/gateway-client/src/index.ts +4 -0
- package/node_modules/@vellumai/gateway-client/src/verification-session-contract.ts +78 -3
- package/openapi.yaml +4 -4
- package/package.json +1 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
- package/src/__tests__/channel-guardian.test.ts +28 -8
- package/src/__tests__/conversation-placement-promotion.test.ts +16 -42
- package/src/__tests__/conversation-retry-route.test.ts +1 -1
- package/src/__tests__/conversation-summarize-route.test.ts +1 -1
- package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +16 -59
- package/src/__tests__/http-conversation-lineage.test.ts +2 -5
- package/src/persistence/conversation-crud.ts +18 -27
- package/src/persistence/conversation-group-migration.ts +1 -1
- package/src/runtime/routes/conversation-management-routes.ts +6 -7
- package/src/runtime/verification-outbound-actions.ts +4 -7
- package/src/tools/conversation-groups/move_to_group.ts +2 -10
|
@@ -157,6 +157,8 @@ export {
|
|
|
157
157
|
hashVerificationSecret,
|
|
158
158
|
IdentityBindingStatusSchema,
|
|
159
159
|
ResolveBootstrapSessionIpcParamsSchema,
|
|
160
|
+
bindsSameIdentity,
|
|
161
|
+
boundIdentity,
|
|
160
162
|
RevokePendingSessionsIpcParamsSchema,
|
|
161
163
|
SessionLookupIpcResponseSchema,
|
|
162
164
|
SessionMutationIpcResponseSchema,
|
|
@@ -172,6 +174,7 @@ export {
|
|
|
172
174
|
|
|
173
175
|
export type {
|
|
174
176
|
BindSessionIdentityIpcParams,
|
|
177
|
+
BoundIdentity,
|
|
175
178
|
CountRecentSendsIpcParams,
|
|
176
179
|
CountRecentSendsIpcResponse,
|
|
177
180
|
CreateInboundSessionIpcParams,
|
|
@@ -183,6 +186,7 @@ export type {
|
|
|
183
186
|
FindActiveSessionIpcParams,
|
|
184
187
|
GetPendingSessionIpcParams,
|
|
185
188
|
IdentityBindingStatus,
|
|
189
|
+
IdentityBoundSession,
|
|
186
190
|
ResolveBootstrapSessionIpcParams,
|
|
187
191
|
RevokePendingSessionsIpcParams,
|
|
188
192
|
SessionLookupIpcResponse,
|
|
@@ -113,6 +113,78 @@ export const VerificationSessionSchema = z.object({
|
|
|
113
113
|
|
|
114
114
|
export type VerificationSessionWire = z.infer<typeof VerificationSessionSchema>;
|
|
115
115
|
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Bound identity (which of the three identity columns decides who may redeem)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** The identity fields the rules read. Callers may pass a full session row. */
|
|
121
|
+
export interface IdentityBoundSession {
|
|
122
|
+
expectedExternalUserId?: string | null;
|
|
123
|
+
expectedChatId?: string | null;
|
|
124
|
+
expectedPhoneE164?: string | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The identity a session redeems on.
|
|
129
|
+
*
|
|
130
|
+
* `chatId` is compared against the actor's conversation and everything else
|
|
131
|
+
* against the actor themselves, which is why the field travels with the value.
|
|
132
|
+
*/
|
|
133
|
+
export interface BoundIdentity {
|
|
134
|
+
field: "phoneE164" | "externalUserId" | "chatId";
|
|
135
|
+
value: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The one identity a session is bound to, or null when it is bound to none
|
|
140
|
+
* (an inbound challenge, or a bootstrap session before its deep link is
|
|
141
|
+
* redeemed).
|
|
142
|
+
*
|
|
143
|
+
* This lives in the contract because more than one side has to agree on it:
|
|
144
|
+
* the gateway consume path decides who may redeem a code, and the supersede
|
|
145
|
+
* scope decides whose earlier codes a fresh mint kills. Those two disagreeing
|
|
146
|
+
* is a silent one-time-code bug, so the rule is stated once.
|
|
147
|
+
*
|
|
148
|
+
* Precedence, most to least specific:
|
|
149
|
+
*
|
|
150
|
+
* 1. `expectedPhoneE164` (a voice session's subject is the number itself).
|
|
151
|
+
* 2. `expectedExternalUserId` (the person).
|
|
152
|
+
* 3. `expectedChatId` (the conversation), and only when nothing identifies a
|
|
153
|
+
* person. A chat id can be shared (a Slack channel, a Telegram group), so
|
|
154
|
+
* on its own it would let any participant satisfy the binding. It counts
|
|
155
|
+
* only where there is no better answer.
|
|
156
|
+
*/
|
|
157
|
+
export function boundIdentity(
|
|
158
|
+
session: IdentityBoundSession,
|
|
159
|
+
): BoundIdentity | null {
|
|
160
|
+
// Presence, not truthiness. An empty string is a set-but-useless identity,
|
|
161
|
+
// and treating it as absent would report the session unbound, which the
|
|
162
|
+
// consume path reads as "no identity to check" and admits anyone holding
|
|
163
|
+
// the code. Kept as a value it can compare against and fail instead.
|
|
164
|
+
if (session.expectedPhoneE164 != null) {
|
|
165
|
+
return { field: "phoneE164", value: session.expectedPhoneE164 };
|
|
166
|
+
}
|
|
167
|
+
if (session.expectedExternalUserId != null) {
|
|
168
|
+
return { field: "externalUserId", value: session.expectedExternalUserId };
|
|
169
|
+
}
|
|
170
|
+
if (session.expectedChatId != null) {
|
|
171
|
+
return { field: "chatId", value: session.expectedChatId };
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether two sessions are bound to the same identity. Two sessions bound to
|
|
178
|
+
* no identity are not the same: bootstrap sessions are claimed by id, so an
|
|
179
|
+
* unbound mint must not sweep up every other unbound row.
|
|
180
|
+
*/
|
|
181
|
+
export function bindsSameIdentity(
|
|
182
|
+
a: BoundIdentity | null,
|
|
183
|
+
b: BoundIdentity | null,
|
|
184
|
+
): boolean {
|
|
185
|
+
return a !== null && b !== null && a.field === b.field && a.value === b.value;
|
|
186
|
+
}
|
|
187
|
+
|
|
116
188
|
// ---------------------------------------------------------------------------
|
|
117
189
|
// IPC methods
|
|
118
190
|
// ---------------------------------------------------------------------------
|
|
@@ -193,9 +265,12 @@ export type CreateInboundSessionIpcResponse = z.infer<
|
|
|
193
265
|
/** Request for `verification_sessions_create_outbound`. */
|
|
194
266
|
export const CreateOutboundSessionIpcParamsSchema = z.object({
|
|
195
267
|
channel: z.string().min(1),
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
268
|
+
// An identity field is either absent or a real value. An empty string is
|
|
269
|
+
// neither, and a session bound to one can never be redeemed by anybody,
|
|
270
|
+
// so reject it at the boundary rather than mint a dead code.
|
|
271
|
+
expectedExternalUserId: z.string().min(1).optional(),
|
|
272
|
+
expectedChatId: z.string().min(1).optional(),
|
|
273
|
+
expectedPhoneE164: z.string().min(1).optional(),
|
|
199
274
|
identityBindingStatus: IdentityBindingStatusSchema.optional(),
|
|
200
275
|
destinationAddress: z.string().optional(),
|
|
201
276
|
codeDigits: z.number().int().positive().optional(),
|
package/openapi.yaml
CHANGED
|
@@ -9293,8 +9293,10 @@ paths:
|
|
|
9293
9293
|
/v1/conversations/reorder:
|
|
9294
9294
|
post:
|
|
9295
9295
|
operationId: conversations_reorder_post
|
|
9296
|
-
summary:
|
|
9297
|
-
description:
|
|
9296
|
+
summary: Move conversations between sections
|
|
9297
|
+
description:
|
|
9298
|
+
Batch-update which group holds each conversation, and its pin state. Lists are ordered by recency, so this
|
|
9299
|
+
sets placement, not order.
|
|
9298
9300
|
tags:
|
|
9299
9301
|
- conversations
|
|
9300
9302
|
requestBody:
|
|
@@ -9311,8 +9313,6 @@ paths:
|
|
|
9311
9313
|
properties:
|
|
9312
9314
|
conversationId:
|
|
9313
9315
|
type: string
|
|
9314
|
-
displayOrder:
|
|
9315
|
-
type: number
|
|
9316
9316
|
isPinned:
|
|
9317
9317
|
type: boolean
|
|
9318
9318
|
groupId:
|
package/package.json
CHANGED
|
@@ -61,7 +61,7 @@ mock.module("../persistence/conversation-crud.js", () => ({
|
|
|
61
61
|
getConversationPersistedSeq: () => null,
|
|
62
62
|
addMessage: mock(() => ({ id: "msg-1" })),
|
|
63
63
|
archiveConversation: mock(() => true),
|
|
64
|
-
|
|
64
|
+
batchSetConversationPlacement: mock(() => {}),
|
|
65
65
|
createConversation: (opts: { conversationType: string }) => {
|
|
66
66
|
createdConversations.push(opts);
|
|
67
67
|
return { id: "conv-1", ...opts };
|
|
@@ -1782,26 +1782,46 @@ describe("outbound verification sessions", () => {
|
|
|
1782
1782
|
expect(result.success).toBe(true);
|
|
1783
1783
|
});
|
|
1784
1784
|
|
|
1785
|
-
// ── Voice identity
|
|
1785
|
+
// ── Voice identity is the number ──
|
|
1786
1786
|
|
|
1787
|
-
test("
|
|
1787
|
+
test("a voice session is redeemed by the number it was sent to", async () => {
|
|
1788
|
+
// Voice knows the caller by one thing, so the consume path receives that
|
|
1789
|
+
// number as both actor axes. Every voice mint records it in both
|
|
1790
|
+
// identity columns, and either way the session is bound to the number.
|
|
1788
1791
|
const { secret } = await createOutboundSession({
|
|
1789
1792
|
channel: "phone",
|
|
1790
|
-
expectedExternalUserId: "
|
|
1791
|
-
expectedPhoneE164: "+
|
|
1792
|
-
destinationAddress: "+
|
|
1793
|
+
expectedExternalUserId: "+15555550101",
|
|
1794
|
+
expectedPhoneE164: "+15555550101",
|
|
1795
|
+
destinationAddress: "+15555550101",
|
|
1793
1796
|
});
|
|
1794
1797
|
|
|
1795
|
-
// Actor matches expectedExternalUserId
|
|
1796
1798
|
const result = await validateAndConsumeVerification(
|
|
1797
1799
|
"phone",
|
|
1798
1800
|
secret,
|
|
1799
|
-
"
|
|
1800
|
-
"
|
|
1801
|
+
"+15555550101",
|
|
1802
|
+
"+15555550101",
|
|
1801
1803
|
);
|
|
1802
1804
|
|
|
1803
1805
|
expect(result.success).toBe(true);
|
|
1804
1806
|
});
|
|
1807
|
+
|
|
1808
|
+
test("a voice session refuses a caller on a different number", async () => {
|
|
1809
|
+
const { secret } = await createOutboundSession({
|
|
1810
|
+
channel: "phone",
|
|
1811
|
+
expectedExternalUserId: "+15555550101",
|
|
1812
|
+
expectedPhoneE164: "+15555550101",
|
|
1813
|
+
destinationAddress: "+15555550101",
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
const result = await validateAndConsumeVerification(
|
|
1817
|
+
"phone",
|
|
1818
|
+
secret,
|
|
1819
|
+
"+15555550102",
|
|
1820
|
+
"+15555550102",
|
|
1821
|
+
);
|
|
1822
|
+
|
|
1823
|
+
expect(result.success).toBe(false);
|
|
1824
|
+
});
|
|
1805
1825
|
});
|
|
1806
1826
|
|
|
1807
1827
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
|
-
|
|
20
|
+
batchSetConversationPlacement,
|
|
21
21
|
createConversation,
|
|
22
22
|
} from "../persistence/conversation-crud.js";
|
|
23
23
|
import { listConversations } from "../persistence/conversation-queries.js";
|
|
@@ -58,22 +58,16 @@ describe("pinning a background conversation", () => {
|
|
|
58
58
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
59
59
|
expect(visibleTitles()).toEqual([]);
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
63
|
-
]);
|
|
61
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
|
|
64
62
|
|
|
65
63
|
expect(titlesInGroup("system:pinned")).toEqual(["bg-job"]);
|
|
66
64
|
});
|
|
67
65
|
|
|
68
66
|
test("unpinning lands it in Recents rather than hiding it again", () => {
|
|
69
67
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
70
|
-
|
|
71
|
-
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
72
|
-
]);
|
|
68
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
|
|
73
69
|
|
|
74
|
-
|
|
75
|
-
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
76
|
-
]);
|
|
70
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
|
|
77
71
|
|
|
78
72
|
expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
|
|
79
73
|
});
|
|
@@ -84,9 +78,7 @@ describe("filing a background conversation into a custom group", () => {
|
|
|
84
78
|
const group = createGroup("Car Chat");
|
|
85
79
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
86
80
|
|
|
87
|
-
|
|
88
|
-
{ id: conv.id, displayOrder: 0, groupId: group.id },
|
|
89
|
-
]);
|
|
81
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
|
|
90
82
|
|
|
91
83
|
expect(titlesInGroup(group.id)).toEqual(["bg-job"]);
|
|
92
84
|
});
|
|
@@ -94,13 +86,9 @@ describe("filing a background conversation into a custom group", () => {
|
|
|
94
86
|
test("removing it from the group lands it in Recents rather than losing it", () => {
|
|
95
87
|
const group = createGroup("Car Chat");
|
|
96
88
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
97
|
-
|
|
98
|
-
{ id: conv.id, displayOrder: 0, groupId: group.id },
|
|
99
|
-
]);
|
|
89
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
|
|
100
90
|
|
|
101
|
-
|
|
102
|
-
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
103
|
-
]);
|
|
91
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
|
|
104
92
|
|
|
105
93
|
expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
|
|
106
94
|
expect(visibleTitles()).toEqual(["bg-job"]);
|
|
@@ -110,12 +98,8 @@ describe("filing a background conversation into a custom group", () => {
|
|
|
110
98
|
const group = createGroup("Morning Briefs");
|
|
111
99
|
const conv = seedRoutedBackground("sched-job", "scheduled");
|
|
112
100
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
]);
|
|
116
|
-
batchSetDisplayOrders([
|
|
117
|
-
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
118
|
-
]);
|
|
101
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
|
|
102
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
|
|
119
103
|
|
|
120
104
|
expect(titlesInGroup("system:all")).toEqual(["sched-job"]);
|
|
121
105
|
});
|
|
@@ -125,13 +109,11 @@ describe("what promotion must not do", () => {
|
|
|
125
109
|
test("filing into system:background still demotes", () => {
|
|
126
110
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
127
111
|
// Promote first, so the demotion has something to undo.
|
|
128
|
-
|
|
129
|
-
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
130
|
-
]);
|
|
112
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
|
|
131
113
|
expect(titlesInGroup("system:pinned")).toEqual(["bg-job"]);
|
|
132
114
|
|
|
133
|
-
|
|
134
|
-
{ id: conv.id,
|
|
115
|
+
batchSetConversationPlacement([
|
|
116
|
+
{ id: conv.id, groupId: "system:background" },
|
|
135
117
|
]);
|
|
136
118
|
|
|
137
119
|
expect(visibleTitles()).toEqual([]);
|
|
@@ -149,9 +131,7 @@ describe("what promotion must not do", () => {
|
|
|
149
131
|
// system bucket.
|
|
150
132
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
151
133
|
|
|
152
|
-
|
|
153
|
-
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
154
|
-
]);
|
|
134
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
|
|
155
135
|
|
|
156
136
|
expect(visibleTitles()).toEqual([]);
|
|
157
137
|
});
|
|
@@ -168,13 +148,9 @@ describe("what promotion must not do", () => {
|
|
|
168
148
|
// came from, `surfaced_at` records that the user promoted it, and only
|
|
169
149
|
// an explicit demotion clears the latter.
|
|
170
150
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
171
|
-
|
|
172
|
-
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
173
|
-
]);
|
|
151
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
|
|
174
152
|
|
|
175
|
-
|
|
176
|
-
{ id: conv.id, displayOrder: null, isPinned: false },
|
|
177
|
-
]);
|
|
153
|
+
batchSetConversationPlacement([{ id: conv.id, isPinned: false }]);
|
|
178
154
|
|
|
179
155
|
expect(visibleTitles()).toEqual(["bg-job"]);
|
|
180
156
|
expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
|
|
@@ -186,9 +162,7 @@ describe("what promotion must not do", () => {
|
|
|
186
162
|
const group = createGroup("Car Chat");
|
|
187
163
|
const conv = createConversation("plain-chat");
|
|
188
164
|
|
|
189
|
-
|
|
190
|
-
{ id: conv.id, displayOrder: 0, groupId: group.id },
|
|
191
|
-
]);
|
|
165
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
|
|
192
166
|
|
|
193
167
|
const row = getDb()
|
|
194
168
|
.all(`SELECT surfaced_at FROM conversations WHERE id = '${conv.id}'`)
|
|
@@ -74,7 +74,7 @@ const getConversationMock = mock((id: string) =>
|
|
|
74
74
|
mock.module("../persistence/conversation-crud.js", () => ({
|
|
75
75
|
addMessage: async () => ({ id: "unused", deduplicated: false }),
|
|
76
76
|
archiveConversation: () => true,
|
|
77
|
-
|
|
77
|
+
batchSetConversationPlacement: () => {},
|
|
78
78
|
countConversationsByScheduleJobId: () => 0,
|
|
79
79
|
deleteConversation: () => ({ segmentIds: [], deletedSummaryIds: [] }),
|
|
80
80
|
extractImageSourcePaths: () => undefined,
|
|
@@ -53,7 +53,7 @@ const getConversationMock = mock((id: string) =>
|
|
|
53
53
|
mock.module("../persistence/conversation-crud.js", () => ({
|
|
54
54
|
addMessage: addMessageMock,
|
|
55
55
|
archiveConversation: () => true,
|
|
56
|
-
|
|
56
|
+
batchSetConversationPlacement: () => {},
|
|
57
57
|
countConversationsByScheduleJobId: () => 0,
|
|
58
58
|
deleteConversation: () => ({ segmentIds: [], deletedSummaryIds: [] }),
|
|
59
59
|
extractImageSourcePaths: () => undefined,
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
23
23
|
|
|
24
24
|
import {
|
|
25
|
+
bindsSameIdentity,
|
|
26
|
+
boundIdentity,
|
|
25
27
|
hashVerificationSecret,
|
|
26
28
|
VERIFICATION_SESSIONS_IPC_METHODS,
|
|
27
29
|
type VerificationSessionWire,
|
|
@@ -64,30 +66,11 @@ function isInterceptable(s: VerificationSessionWire): boolean {
|
|
|
64
66
|
|
|
65
67
|
const OUTBOUND_LIVE_STATUSES = ["pending_bootstrap", "awaiting_response"];
|
|
66
68
|
|
|
67
|
-
/**
|
|
68
|
-
* The identity a session redeems on, in `checkIdentityMatch`'s precedence
|
|
69
|
-
* order. Null when the session carries none, which is a bootstrap session.
|
|
70
|
-
*/
|
|
71
|
-
function boundIdentity(session: {
|
|
72
|
-
expectedExternalUserId?: string | null;
|
|
73
|
-
expectedChatId?: string | null;
|
|
74
|
-
expectedPhoneE164?: string | null;
|
|
75
|
-
}): string | null {
|
|
76
|
-
if (session.expectedExternalUserId) {
|
|
77
|
-
return `user:${session.expectedExternalUserId}`;
|
|
78
|
-
}
|
|
79
|
-
if (session.expectedPhoneE164) {
|
|
80
|
-
return `phone:${session.expectedPhoneE164}`;
|
|
81
|
-
}
|
|
82
|
-
if (session.expectedChatId) {
|
|
83
|
-
return `chat:${session.expectedChatId}`;
|
|
84
|
-
}
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
69
|
/**
|
|
89
70
|
* Mirrors the gateway store: an outbound mint supersedes only sessions bound
|
|
90
71
|
* to the same identity, leaving other actors and inbound challenges alone.
|
|
72
|
+
* The identity rule itself comes from the shared contract, so this cannot
|
|
73
|
+
* drift from what the gateway does.
|
|
91
74
|
*/
|
|
92
75
|
function revokeSameActorOutbound(
|
|
93
76
|
channel: string,
|
|
@@ -102,7 +85,7 @@ function revokeSameActorOutbound(
|
|
|
102
85
|
if (
|
|
103
86
|
s.channel === channel &&
|
|
104
87
|
OUTBOUND_LIVE_STATUSES.includes(s.status) &&
|
|
105
|
-
boundIdentity(s)
|
|
88
|
+
bindsSameIdentity(boundIdentity(s), identity)
|
|
106
89
|
) {
|
|
107
90
|
s.status = "revoked";
|
|
108
91
|
s.updatedAt = now;
|
|
@@ -522,50 +505,24 @@ function recordInvalidAttempt(key: string): void {
|
|
|
522
505
|
rateLimits.set(key, entry);
|
|
523
506
|
}
|
|
524
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Mirrors the gateway consume path. Both sides read the shared
|
|
510
|
+
* `boundIdentity`, so the sim cannot accept a code the real service would
|
|
511
|
+
* refuse. A session bound to no identity, or not yet finally bound
|
|
512
|
+
* (`pending_bootstrap`), bypasses the check as it does at the gateway.
|
|
513
|
+
*/
|
|
525
514
|
function checkIdentityMatch(
|
|
526
515
|
session: VerificationSessionWire,
|
|
527
516
|
actorExternalUserId: string,
|
|
528
517
|
actorChatId: string,
|
|
529
518
|
): boolean {
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
session.expectedChatId != null ||
|
|
533
|
-
session.expectedPhoneE164 != null;
|
|
534
|
-
// pending_bootstrap (and unbound inbound) sessions bypass the check.
|
|
535
|
-
if (!hasExpectedIdentity || session.identityBindingStatus !== "bound") {
|
|
536
|
-
return true;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
if (
|
|
540
|
-
session.expectedPhoneE164 != null &&
|
|
541
|
-
(actorExternalUserId === session.expectedPhoneE164 ||
|
|
542
|
-
actorExternalUserId === session.expectedExternalUserId)
|
|
543
|
-
) {
|
|
544
|
-
return true;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// Shared-chatId caveat: when both expected fields are set, require the
|
|
548
|
-
// externalUserId match — chat IDs can be shared.
|
|
549
|
-
if (session.expectedChatId != null) {
|
|
550
|
-
if (session.expectedExternalUserId != null) {
|
|
551
|
-
if (actorExternalUserId === session.expectedExternalUserId) {
|
|
552
|
-
return true;
|
|
553
|
-
}
|
|
554
|
-
} else if (actorChatId === session.expectedChatId) {
|
|
555
|
-
return true;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
if (
|
|
560
|
-
session.expectedPhoneE164 == null &&
|
|
561
|
-
session.expectedChatId == null &&
|
|
562
|
-
session.expectedExternalUserId != null &&
|
|
563
|
-
actorExternalUserId === session.expectedExternalUserId
|
|
564
|
-
) {
|
|
519
|
+
const identity = boundIdentity(session);
|
|
520
|
+
if (identity === null || session.identityBindingStatus !== "bound") {
|
|
565
521
|
return true;
|
|
566
522
|
}
|
|
567
|
-
|
|
568
|
-
|
|
523
|
+
return identity.field === "chatId"
|
|
524
|
+
? actorChatId === identity.value
|
|
525
|
+
: actorExternalUserId === identity.value;
|
|
569
526
|
}
|
|
570
527
|
|
|
571
528
|
export function validateAndConsumeVerification(
|
|
@@ -13,7 +13,7 @@ mock.module("../config/env.js", () => ({
|
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
-
|
|
16
|
+
batchSetConversationPlacement,
|
|
17
17
|
createConversation,
|
|
18
18
|
updateConversationTitle,
|
|
19
19
|
} from "../persistence/conversation-crud.js";
|
|
@@ -100,9 +100,7 @@ describe("conversation lineage in HTTP reads", () => {
|
|
|
100
100
|
|
|
101
101
|
test("GET /v1/conversations/:id includes pin metadata when present", async () => {
|
|
102
102
|
const conversation = createConversation("Pinned conversation");
|
|
103
|
-
|
|
104
|
-
{ id: conversation.id, displayOrder: 7, isPinned: true },
|
|
105
|
-
]);
|
|
103
|
+
batchSetConversationPlacement([{ id: conversation.id, isPinned: true }]);
|
|
106
104
|
await startServer();
|
|
107
105
|
|
|
108
106
|
const response = await fetch(url(`/conversations/${conversation.id}`));
|
|
@@ -115,7 +113,6 @@ describe("conversation lineage in HTTP reads", () => {
|
|
|
115
113
|
expect(body.conversation).toMatchObject({
|
|
116
114
|
id: conversation.id,
|
|
117
115
|
title: conversation.title ?? "Untitled",
|
|
118
|
-
displayOrder: 7,
|
|
119
116
|
isPinned: true,
|
|
120
117
|
});
|
|
121
118
|
});
|
|
@@ -4209,13 +4209,19 @@ export function getConversationRecentProvenanceTrustClass(
|
|
|
4209
4209
|
}
|
|
4210
4210
|
|
|
4211
4211
|
// ---------------------------------------------------------------------------
|
|
4212
|
-
//
|
|
4212
|
+
// Conversation placement (group + pin) and display metadata
|
|
4213
4213
|
// ---------------------------------------------------------------------------
|
|
4214
4214
|
|
|
4215
|
-
|
|
4215
|
+
/**
|
|
4216
|
+
* Move conversations between sections: set the group, and the pin state
|
|
4217
|
+
* derived from it.
|
|
4218
|
+
*
|
|
4219
|
+
* Writes no ordering. Every list is ordered by recency, so a conversation's
|
|
4220
|
+
* placement is which section holds it, not where it sits inside one.
|
|
4221
|
+
*/
|
|
4222
|
+
export function batchSetConversationPlacement(
|
|
4216
4223
|
updates: Array<{
|
|
4217
4224
|
id: string;
|
|
4218
|
-
displayOrder: number | null;
|
|
4219
4225
|
isPinned?: boolean;
|
|
4220
4226
|
groupId?: string | null;
|
|
4221
4227
|
}>,
|
|
@@ -4236,7 +4242,7 @@ export function batchSetDisplayOrders(
|
|
|
4236
4242
|
safeGroupId = UNGROUPED_GROUP_ID;
|
|
4237
4243
|
} else if (
|
|
4238
4244
|
!rawGet<{ id: string }>(
|
|
4239
|
-
"conversation:
|
|
4245
|
+
"conversation:batchSetConversationPlacement:groupCheck",
|
|
4240
4246
|
"SELECT id FROM conversation_groups WHERE id = ?",
|
|
4241
4247
|
safeGroupId,
|
|
4242
4248
|
)
|
|
@@ -4270,13 +4276,8 @@ export function batchSetDisplayOrders(
|
|
|
4270
4276
|
safeGroupId !== UNGROUPED_GROUP_ID &&
|
|
4271
4277
|
(safeGroupId === PINNED_GROUP_ID ||
|
|
4272
4278
|
!safeGroupId.startsWith("system:"));
|
|
4273
|
-
const setClauses = [
|
|
4274
|
-
"display_order = ?",
|
|
4275
|
-
"is_pinned = ?",
|
|
4276
|
-
"group_id = ?",
|
|
4277
|
-
];
|
|
4279
|
+
const setClauses = ["is_pinned = ?", "group_id = ?"];
|
|
4278
4280
|
const params: Array<string | number | null> = [
|
|
4279
|
-
update.displayOrder,
|
|
4280
4281
|
safeGroupId === PINNED_GROUP_ID ? 1 : 0,
|
|
4281
4282
|
safeGroupId,
|
|
4282
4283
|
];
|
|
@@ -4291,31 +4292,22 @@ export function batchSetDisplayOrders(
|
|
|
4291
4292
|
}
|
|
4292
4293
|
params.push(update.id);
|
|
4293
4294
|
rawRun(
|
|
4294
|
-
"conversation:
|
|
4295
|
+
"conversation:batchSetConversationPlacement:group",
|
|
4295
4296
|
`UPDATE conversations SET ${setClauses.join(", ")} WHERE id = ?`,
|
|
4296
4297
|
...params,
|
|
4297
4298
|
);
|
|
4298
|
-
} else if (update.isPinned ===
|
|
4299
|
-
// Only displayOrder provided — preserve existing pin state and group.
|
|
4300
|
-
rawRun(
|
|
4301
|
-
"conversation:batchSetDisplayOrders:orderOnly",
|
|
4302
|
-
"UPDATE conversations SET display_order = ? WHERE id = ?",
|
|
4303
|
-
update.displayOrder,
|
|
4304
|
-
update.id,
|
|
4305
|
-
);
|
|
4306
|
-
} else if (update.isPinned) {
|
|
4299
|
+
} else if (update.isPinned === true) {
|
|
4307
4300
|
rawRun(
|
|
4308
|
-
"conversation:
|
|
4309
|
-
"UPDATE conversations SET
|
|
4310
|
-
update.displayOrder,
|
|
4301
|
+
"conversation:batchSetConversationPlacement:pin",
|
|
4302
|
+
"UPDATE conversations SET is_pinned = 1, group_id = 'system:pinned' WHERE id = ?",
|
|
4311
4303
|
update.id,
|
|
4312
4304
|
);
|
|
4313
|
-
} else {
|
|
4305
|
+
} else if (update.isPinned === false) {
|
|
4314
4306
|
// Restore system group from source/conversationType when unpinning,
|
|
4315
4307
|
// instead of clearing to NULL (which would lose provenance).
|
|
4316
4308
|
rawRun(
|
|
4317
|
-
"conversation:
|
|
4318
|
-
`UPDATE conversations SET
|
|
4309
|
+
"conversation:batchSetConversationPlacement:unpin",
|
|
4310
|
+
`UPDATE conversations SET is_pinned = 0,
|
|
4319
4311
|
group_id = CASE WHEN group_id = 'system:pinned' THEN
|
|
4320
4312
|
CASE
|
|
4321
4313
|
WHEN source IN ('schedule', 'reminder') THEN 'system:scheduled'
|
|
@@ -4325,7 +4317,6 @@ export function batchSetDisplayOrders(
|
|
|
4325
4317
|
END
|
|
4326
4318
|
ELSE group_id END
|
|
4327
4319
|
WHERE id = ?`,
|
|
4328
|
-
update.displayOrder,
|
|
4329
4320
|
update.id,
|
|
4330
4321
|
);
|
|
4331
4322
|
}
|
|
@@ -36,7 +36,7 @@ export function _resetGroupMigrationForTests(): void {
|
|
|
36
36
|
* for the first time inside a Drizzle db.transaction() block — SQLite
|
|
37
37
|
* does not support nested transactions. In practice this is safe because
|
|
38
38
|
* the migration is triggered by early startup queries (listConversations,
|
|
39
|
-
*
|
|
39
|
+
* batchSetConversationPlacement) before any transaction-wrapped paths run, and
|
|
40
40
|
* the `migrated` flag makes subsequent calls no-ops.
|
|
41
41
|
*/
|
|
42
42
|
export function ensureGroupMigration(): void {
|
|
@@ -46,7 +46,7 @@ import { normalizeConversationType } from "../../daemon/message-types/shared.js"
|
|
|
46
46
|
import { stripConversationIds } from "../../home/feed-writer.js";
|
|
47
47
|
import {
|
|
48
48
|
archiveConversation,
|
|
49
|
-
|
|
49
|
+
batchSetConversationPlacement,
|
|
50
50
|
countConversationsByScheduleJobId,
|
|
51
51
|
deleteConversation,
|
|
52
52
|
forkConversation as forkConversationInStore,
|
|
@@ -845,7 +845,6 @@ function handleReorderConversations({ body = {}, headers }: RouteHandlerArgs) {
|
|
|
845
845
|
const updates = body.updates as
|
|
846
846
|
| Array<{
|
|
847
847
|
conversationId: string;
|
|
848
|
-
displayOrder?: number;
|
|
849
848
|
isPinned?: boolean;
|
|
850
849
|
groupId?: string | null;
|
|
851
850
|
}>
|
|
@@ -853,10 +852,9 @@ function handleReorderConversations({ body = {}, headers }: RouteHandlerArgs) {
|
|
|
853
852
|
if (!Array.isArray(updates)) {
|
|
854
853
|
throw new BadRequestError("Missing updates array");
|
|
855
854
|
}
|
|
856
|
-
|
|
855
|
+
batchSetConversationPlacement(
|
|
857
856
|
updates.map((u) => ({
|
|
858
857
|
id: u.conversationId,
|
|
859
|
-
displayOrder: u.displayOrder ?? null,
|
|
860
858
|
isPinned: u.isPinned,
|
|
861
859
|
groupId: u.groupId,
|
|
862
860
|
})),
|
|
@@ -1304,14 +1302,15 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
1304
1302
|
requiredScopes: ["chat.write"],
|
|
1305
1303
|
allowedPrincipalTypes: ACTOR_PRINCIPALS,
|
|
1306
1304
|
},
|
|
1307
|
-
summary: "
|
|
1308
|
-
description:
|
|
1305
|
+
summary: "Move conversations between sections",
|
|
1306
|
+
description:
|
|
1307
|
+
"Batch-update which group holds each conversation, and its pin state. " +
|
|
1308
|
+
"Lists are ordered by recency, so this sets placement, not order.",
|
|
1309
1309
|
tags: ["conversations"],
|
|
1310
1310
|
requestBody: z.object({
|
|
1311
1311
|
updates: z.array(
|
|
1312
1312
|
z.object({
|
|
1313
1313
|
conversationId: z.string(),
|
|
1314
|
-
displayOrder: z.number().optional(),
|
|
1315
1314
|
isPinned: z.boolean().optional(),
|
|
1316
1315
|
groupId: z.string().nullable().optional(),
|
|
1317
1316
|
}),
|
|
@@ -920,14 +920,15 @@ export async function resendOutbound(
|
|
|
920
920
|
};
|
|
921
921
|
}
|
|
922
922
|
|
|
923
|
-
const
|
|
923
|
+
const destination =
|
|
924
924
|
session.destinationAddress ??
|
|
925
925
|
session.expectedPhoneE164 ??
|
|
926
926
|
session.expectedChatId;
|
|
927
|
-
|
|
927
|
+
|
|
928
|
+
if (destination) {
|
|
928
929
|
const recentDestSends = await countRecentSendsToDestination(
|
|
929
930
|
channel,
|
|
930
|
-
|
|
931
|
+
destination,
|
|
931
932
|
DESTINATION_RATE_WINDOW_MS,
|
|
932
933
|
);
|
|
933
934
|
if (recentDestSends >= MAX_SENDS_PER_DESTINATION_WINDOW) {
|
|
@@ -941,10 +942,6 @@ export async function resendOutbound(
|
|
|
941
942
|
}
|
|
942
943
|
}
|
|
943
944
|
|
|
944
|
-
const destination =
|
|
945
|
-
session.destinationAddress ??
|
|
946
|
-
session.expectedPhoneE164 ??
|
|
947
|
-
session.expectedChatId;
|
|
948
945
|
if (!destination) {
|
|
949
946
|
return {
|
|
950
947
|
success: false,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
batchSetConversationPlacement,
|
|
5
5
|
getConversation,
|
|
6
6
|
getDisplayMetaForConversations,
|
|
7
7
|
} from "../../persistence/conversation-crud.js";
|
|
@@ -73,15 +73,7 @@ export async function executeConversationMoveToGroup(
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
// group assignment changes.
|
|
78
|
-
batchSetDisplayOrders([
|
|
79
|
-
{
|
|
80
|
-
id: conversationId,
|
|
81
|
-
displayOrder: meta?.displayOrder ?? null,
|
|
82
|
-
groupId: group.id,
|
|
83
|
-
},
|
|
84
|
-
]);
|
|
76
|
+
batchSetConversationPlacement([{ id: conversationId, groupId: group.id }]);
|
|
85
77
|
publishConversationListAndMetadataChanged("reordered", [conversationId]);
|
|
86
78
|
|
|
87
79
|
const notes: string[] = [];
|