@pmate/chat-sdk 0.12.0-next.76 → 0.12.0-next.77
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/lib/internal/chatEngine.js +8 -5
- package/lib/runtime/chatSession.d.ts +1 -0
- package/lib/runtime/chatSession.js +46 -3
- package/lib/runtime/createChat.js +1 -2
- package/lib/runtime/groupClient.d.ts +2 -1
- package/lib/runtime/groupClient.js +44 -13
- package/lib/runtime/groupDenial.d.ts +2 -0
- package/lib/runtime/groupDenial.js +4 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createMessagePresenter } from "../messageView.js";
|
|
2
2
|
import { createChat as createSession } from "../runtime/createChat.js";
|
|
3
|
-
import { asChatError
|
|
3
|
+
import { asChatError } from "../runtime/chatError.js";
|
|
4
4
|
import { ChatError } from "../chatError.js";
|
|
5
5
|
export { ChatError } from "../chatError.js";
|
|
6
6
|
const emptyMessages = Object.freeze([]);
|
|
@@ -79,8 +79,8 @@ export class ChatEngine {
|
|
|
79
79
|
return await run();
|
|
80
80
|
}
|
|
81
81
|
catch (error) {
|
|
82
|
-
const failure = threadId ?
|
|
83
|
-
if (threadId && failure.code === "access-denied")
|
|
82
|
+
const failure = threadId ? await this.advanced.conversationFailure(error, threadId) : asChatError(error);
|
|
83
|
+
if (threadId && failure.code === "access-denied" && failure.threadId)
|
|
84
84
|
await this.deny(threadId, failure);
|
|
85
85
|
throw failure;
|
|
86
86
|
}
|
|
@@ -177,6 +177,9 @@ export class ChatEngine {
|
|
|
177
177
|
}
|
|
178
178
|
async getMessages(threadId, query = {}) {
|
|
179
179
|
try {
|
|
180
|
+
const denied = this.errors.get(threadId);
|
|
181
|
+
if (denied?.code === "access-denied")
|
|
182
|
+
throw denied;
|
|
180
183
|
if (Object.keys(query).some(key => !["earlier", "all", "refresh"].includes(key)))
|
|
181
184
|
throw new ChatError("invalid-input", "Unsupported message query");
|
|
182
185
|
const page = await this.advanced.getMessages(threadId, query);
|
|
@@ -187,9 +190,9 @@ export class ChatEngine {
|
|
|
187
190
|
return this.getSnapshot(threadId);
|
|
188
191
|
}
|
|
189
192
|
catch (error) {
|
|
190
|
-
const failure = this.advanced.
|
|
193
|
+
const failure = await this.advanced.conversationFailure(error, threadId);
|
|
191
194
|
if (!this.closed) {
|
|
192
|
-
if (failure.code === "access-denied")
|
|
195
|
+
if (failure.code === "access-denied" && failure.threadId)
|
|
193
196
|
await this.deny(threadId, failure);
|
|
194
197
|
else {
|
|
195
198
|
this.errors.set(threadId, failure);
|
|
@@ -193,6 +193,7 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
193
193
|
private buildGlobal;
|
|
194
194
|
private publishGlobal;
|
|
195
195
|
private publish;
|
|
196
|
+
conversationFailure(error: unknown, threadId: string): Promise<import("./chatError.js").ChatError>;
|
|
196
197
|
/** Withdraw one conversation without retiring sibling conversations. */
|
|
197
198
|
revokeThread(threadId: string): Promise<void>;
|
|
198
199
|
/** Permanently retire this identity/team session. UI withdrawal is immediate;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { asChatError } from "./chatError.js";
|
|
2
|
+
import { scopedGroupDenial, isScopedGroupDenial } from "./groupDenial.js";
|
|
1
3
|
import { threadChatError } from "./chatError.js";
|
|
2
4
|
import { ConversationDirectory } from "./conversationDirectory.js";
|
|
3
5
|
import { groupThreadId } from "./thread.js";
|
|
@@ -70,7 +72,12 @@ export class ChatSession {
|
|
|
70
72
|
if (shared.appId !== scope.appId || shared.profileId !== scope.profileId || (shared.accountId !== undefined && shared.accountId !== scope.accountId) || (shared.teamId !== undefined && shared.teamId !== scope.teamId))
|
|
71
73
|
throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
|
|
72
74
|
}
|
|
73
|
-
this.messages = new MessageClient(options.messages)
|
|
75
|
+
this.messages = new MessageClient({ ...options.messages, onError: (error, thread) => {
|
|
76
|
+
if (thread && error?.status === 403)
|
|
77
|
+
void this.conversationFailure(error, thread).then(failure => options.onError(failure));
|
|
78
|
+
else
|
|
79
|
+
options.messages.onError?.(error, thread);
|
|
80
|
+
} });
|
|
74
81
|
this.conversations = new ConversationDirectory(scope, () => this.messages.scopeAccess());
|
|
75
82
|
this.connection = options.sharedConnection ?? new ChatClient({ ...options.connection, onReach: async (event) => {
|
|
76
83
|
const handled = await this.handleReach(event);
|
|
@@ -83,7 +90,19 @@ export class ChatSession {
|
|
|
83
90
|
this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true, metadataOnly: true });
|
|
84
91
|
this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, access: () => this.messages.scopeAccess(), onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
|
|
85
92
|
if (options.loadGroup) {
|
|
86
|
-
this.groups = new GroupClient({ scope, access: () => this.messages.scopeAccess(), load: options.loadGroup, onError: options.onError, onDenied: async (error,
|
|
93
|
+
this.groups = new GroupClient({ scope, access: () => this.messages.scopeAccess(), load: options.loadGroup, onError: options.onError, onDenied: async (error, _threadId, signal) => {
|
|
94
|
+
if (error?.status === 403 && options.messages.transport.authorizeScope) {
|
|
95
|
+
try {
|
|
96
|
+
await options.messages.transport.authorizeScope(signal ?? new AbortController().signal);
|
|
97
|
+
this.assertOpen();
|
|
98
|
+
if ((await this.messages.scopeAccess()).allowed)
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
catch { }
|
|
102
|
+
}
|
|
103
|
+
void this.revokeAccess().catch(error => options.onError(error));
|
|
104
|
+
return false;
|
|
105
|
+
} });
|
|
87
106
|
this.off.push(this.groups.subscribe(thread => this.publish(thread)));
|
|
88
107
|
}
|
|
89
108
|
this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice, reconcile: async (record, signal) => {
|
|
@@ -837,8 +856,32 @@ export class ChatSession {
|
|
|
837
856
|
if (notifyGlobal)
|
|
838
857
|
this.publishGlobal();
|
|
839
858
|
}
|
|
859
|
+
async conversationFailure(error, threadId) {
|
|
860
|
+
const failure = asChatError(error);
|
|
861
|
+
if (failure.code !== "access-denied" && failure.status !== 401)
|
|
862
|
+
return failure;
|
|
863
|
+
if (isScopedGroupDenial(error))
|
|
864
|
+
return scopedGroupDenial(threadChatError(error, threadId));
|
|
865
|
+
if (failure.status === 403 && this.active && this.options.messages.transport.authorizeScope) {
|
|
866
|
+
try {
|
|
867
|
+
await this.options.messages.transport.authorizeScope(new AbortController().signal);
|
|
868
|
+
this.assertOpen();
|
|
869
|
+
if ((await this.messages.scopeAccess()).allowed)
|
|
870
|
+
return scopedGroupDenial(threadChatError(error, threadId));
|
|
871
|
+
}
|
|
872
|
+
catch { }
|
|
873
|
+
}
|
|
874
|
+
void this.revokeAccess().catch(error => this.options.onError(error));
|
|
875
|
+
return failure;
|
|
876
|
+
}
|
|
840
877
|
/** Withdraw one conversation without retiring sibling conversations. */
|
|
841
|
-
async revokeThread(threadId) {
|
|
878
|
+
async revokeThread(threadId) { try {
|
|
879
|
+
await Promise.all([this.messages.revoke(threadId), this.groups?.revoke(threadId)]);
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
void this.revokeAccess().catch(failure => this.options.onError(failure));
|
|
883
|
+
throw error;
|
|
884
|
+
} }
|
|
842
885
|
/** Permanently retire this identity/team session. UI withdrawal is immediate;
|
|
843
886
|
* completion means all durable stores have crossed the same authority epoch. */
|
|
844
887
|
revokeAccess() {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { threadChatError } from "./chatError.js";
|
|
2
1
|
import { ChatCredentials } from "../credentials.js";
|
|
3
2
|
import { voiceUploader } from "./voiceUpload.js";
|
|
4
3
|
import { isOffline } from "./connectivity.js";
|
|
@@ -42,7 +41,7 @@ export function createChatSession(options) {
|
|
|
42
41
|
},
|
|
43
42
|
loadGroup: (groupId, signal) => reader.group({ groupId, teamId }, signal),
|
|
44
43
|
activeThreadId: options.threadId,
|
|
45
|
-
messages: { scope: { appId, accountId, profileId, teamId }, transport: { authorizeScope: async (signal) => { await reader.contacts(teamId, signal); }, load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError
|
|
44
|
+
messages: { scope: { appId, accountId, profileId, teamId }, transport: { authorizeScope: async (signal) => { await reader.contacts(teamId, signal); }, load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
|
|
46
45
|
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: fetcher, onError },
|
|
47
46
|
connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: fetcher,
|
|
48
47
|
getToken: credentials?.getToken,
|
|
@@ -34,7 +34,7 @@ export type GroupClientOptions = {
|
|
|
34
34
|
access: ResponseCacheAccess;
|
|
35
35
|
load: (groupId: string, signal: AbortSignal) => Promise<SocialGroup>;
|
|
36
36
|
onError: (error: unknown) => void;
|
|
37
|
-
onDenied?: (error: unknown, threadId: string) => Promise<void> | void;
|
|
37
|
+
onDenied?: (error: unknown, threadId: string, signal?: AbortSignal) => Promise<boolean | void> | boolean | void;
|
|
38
38
|
indexedDB?: IDBFactory;
|
|
39
39
|
name?: string;
|
|
40
40
|
};
|
|
@@ -77,6 +77,7 @@ export declare class GroupClient {
|
|
|
77
77
|
refresh?: boolean;
|
|
78
78
|
}): Promise<ChatGroup>;
|
|
79
79
|
private schedule;
|
|
80
|
+
private denyGroup;
|
|
80
81
|
revoke(threadId: string): Promise<void>;
|
|
81
82
|
wake(): void;
|
|
82
83
|
hint(thread: ChatConversation, versionNumber?: number): void;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { scopedGroupDenial } from "./groupDenial.js";
|
|
1
2
|
import { IndexedDbResponseCache } from "./responseCache.js";
|
|
2
3
|
import { WindowedTasks } from "./coalescer.js";
|
|
3
4
|
import { isOffline } from "./connectivity.js";
|
|
4
5
|
import { messageScopeKey } from "./messageStore.js";
|
|
5
6
|
import { validateConversation, validMessageIdentifier } from "./outgoingContent.js";
|
|
6
7
|
const empty = Object.freeze({ loading: false });
|
|
7
|
-
const token = (value) => `${value
|
|
8
|
+
const token = (value) => value.status === "access-denied" ? `denied:${value.revision}` : `${value.versionNumber}:${value.revision}`;
|
|
8
9
|
/** SDK-owned roster cache. Read sources, concurrency and reconciliation stay private. */
|
|
9
10
|
export class GroupClient {
|
|
10
11
|
options;
|
|
@@ -147,7 +148,10 @@ export class GroupClient {
|
|
|
147
148
|
for (const item of batch) {
|
|
148
149
|
try {
|
|
149
150
|
const row = rows.get(item.entry.thread.id), entry = item.entry;
|
|
150
|
-
|
|
151
|
+
const denied = row.cached?.value.status === "access-denied";
|
|
152
|
+
let data = row.cached && !denied ? this.cached(row.cached.value, entry) : undefined;
|
|
153
|
+
if (denied)
|
|
154
|
+
this.denied.add(entry.thread.id);
|
|
151
155
|
const old = entry.snapshot.data;
|
|
152
156
|
if (old && data && old.versionNumber > data.versionNumber)
|
|
153
157
|
data = old;
|
|
@@ -156,7 +160,7 @@ export class GroupClient {
|
|
|
156
160
|
entry.epoch = row.epoch;
|
|
157
161
|
entry.token = row.cached ? token(row.cached.value) : null;
|
|
158
162
|
entry.loaded = true;
|
|
159
|
-
this.publish(entry, { data: data ?? old, loading: false });
|
|
163
|
+
this.publish(entry, { data: denied ? undefined : data ?? old, loading: false, ...(denied ? { error: scopedGroupDenial(Object.assign(new Error("GROUP_ACCESS_DENIED"), { status: 403, threadId: entry.thread.id })) } : {}) });
|
|
160
164
|
item.resolve();
|
|
161
165
|
}
|
|
162
166
|
catch (error) {
|
|
@@ -178,12 +182,14 @@ export class GroupClient {
|
|
|
178
182
|
}
|
|
179
183
|
async reload(entry) { entry.loaded = false; await this.local(entry); }
|
|
180
184
|
async get(thread, options = {}) {
|
|
181
|
-
if (this.denied.has(thread.id))
|
|
182
|
-
throw Object.assign(new Error("GROUP_ACCESS_REVOKED"), { status: 403, threadId: thread.id });
|
|
183
185
|
const entry = this.entry(thread);
|
|
184
186
|
await this.local(entry);
|
|
185
187
|
await this.checkAuthority();
|
|
186
188
|
this.assertOpen();
|
|
189
|
+
if (options.refresh)
|
|
190
|
+
this.denied.delete(thread.id);
|
|
191
|
+
else if (this.denied.has(thread.id))
|
|
192
|
+
throw entry.snapshot.error ?? scopedGroupDenial(Object.assign(new Error("GROUP_ACCESS_DENIED"), { status: 403, threadId: thread.id }));
|
|
187
193
|
if (entry.snapshot.data && !options.refresh) {
|
|
188
194
|
if (!entry.checked && !isOffline())
|
|
189
195
|
this.schedule(entry);
|
|
@@ -195,12 +201,33 @@ export class GroupClient {
|
|
|
195
201
|
}
|
|
196
202
|
schedule(entry) { if (!this.closed && !this.withdrawn && !this.denied.has(entry.thread.id) && !isOffline())
|
|
197
203
|
this.tasks.schedule(entry.thread.id, async () => { await this.get(entry.thread, { refresh: true }); }); }
|
|
204
|
+
async denyGroup(entry, error) {
|
|
205
|
+
const value = { status: "access-denied", revision: crypto.randomUUID() };
|
|
206
|
+
this.denied.add(entry.thread.id);
|
|
207
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
208
|
+
const row = await this.cache.read(entry.thread.id);
|
|
209
|
+
try {
|
|
210
|
+
await this.cache.write(entry.thread.id, value, row.epoch, row.cached ? token(row.cached.value) : null);
|
|
211
|
+
entry.epoch = row.epoch;
|
|
212
|
+
entry.token = token(value);
|
|
213
|
+
entry.checked = true;
|
|
214
|
+
this.publish(entry, { loading: false, error });
|
|
215
|
+
this.channel?.postMessage({ threadId: entry.thread.id, authority: this.version });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
catch (failure) {
|
|
219
|
+
if (attempt === 2 || !String(failure).includes("RESPONSE_CACHE_CHANGED"))
|
|
220
|
+
throw failure;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
198
224
|
async revoke(threadId) {
|
|
199
225
|
this.denied.add(threadId);
|
|
200
226
|
const entry = this.entries.get(threadId);
|
|
201
227
|
if (entry)
|
|
202
|
-
this.
|
|
203
|
-
|
|
228
|
+
await this.denyGroup(entry, scopedGroupDenial(Object.assign(new Error("GROUP_ACCESS_DENIED"), { status: 403, threadId })));
|
|
229
|
+
else
|
|
230
|
+
await this.cache.remove(threadId);
|
|
204
231
|
}
|
|
205
232
|
wake() { for (const entry of this.entries.values())
|
|
206
233
|
this.schedule(entry); }
|
|
@@ -276,15 +303,19 @@ export class GroupClient {
|
|
|
276
303
|
return value;
|
|
277
304
|
}
|
|
278
305
|
catch (error) {
|
|
279
|
-
if (error?.status === 403)
|
|
280
|
-
Object.assign(error, { threadId: entry.thread.id });
|
|
281
306
|
if (!this.closed) {
|
|
282
307
|
const denied = [401, 403].includes(error?.status ?? 0);
|
|
283
308
|
this.publish(entry, { data: denied ? undefined : entry.snapshot.data, loading: false, error });
|
|
284
|
-
if (denied) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
309
|
+
if (denied && await this.options.onDenied?.(error, entry.thread.id, controller.signal) === true) {
|
|
310
|
+
Object.assign(error, { threadId: entry.thread.id });
|
|
311
|
+
scopedGroupDenial(error);
|
|
312
|
+
try {
|
|
313
|
+
await this.denyGroup(entry, error);
|
|
314
|
+
}
|
|
315
|
+
catch (failure) {
|
|
316
|
+
await this.options.onDenied?.(failure, entry.thread.id, controller.signal);
|
|
317
|
+
throw failure;
|
|
318
|
+
}
|
|
288
319
|
}
|
|
289
320
|
}
|
|
290
321
|
throw error;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Only SDK-verified failures may opt out of whole-session retirement.
|
|
2
|
+
const scoped = new WeakSet();
|
|
3
|
+
export function scopedGroupDenial(error) { scoped.add(error); return error; }
|
|
4
|
+
export function isScopedGroupDenial(error) { return error !== null && typeof error === "object" && scoped.has(error); }
|
package/package.json
CHANGED