@pmate/chat-sdk 0.12.0-next.75 → 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.
@@ -1,6 +1,6 @@
1
1
  import { createMessagePresenter } from "../messageView.js";
2
2
  import { createChat as createSession } from "../runtime/createChat.js";
3
- import { asChatError, threadChatError } from "../runtime/chatError.js";
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 ? threadChatError(error, threadId) : asChatError(error);
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.active ? threadChatError(error, threadId) : asChatError(error);
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, threadId) => { await this.messages.revoke(threadId); options.onError(threadChatError(error, threadId)); } });
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
- revokeThread(threadId) { return this.messages.revoke(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: (error, thread) => onError(thread ? threadChatError(error, thread) : error) },
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,8 @@ export declare class GroupClient {
77
77
  refresh?: boolean;
78
78
  }): Promise<ChatGroup>;
79
79
  private schedule;
80
+ private denyGroup;
81
+ revoke(threadId: string): Promise<void>;
80
82
  wake(): void;
81
83
  hint(thread: ChatConversation, versionNumber?: number): void;
82
84
  private slot;
@@ -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?.versionNumber}:${value?.revision}`;
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
- let data = row.cached ? this.cached(row.cached.value, entry) : undefined;
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,6 +201,34 @@ 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
+ }
224
+ async revoke(threadId) {
225
+ this.denied.add(threadId);
226
+ const entry = this.entries.get(threadId);
227
+ if (entry)
228
+ await this.denyGroup(entry, scopedGroupDenial(Object.assign(new Error("GROUP_ACCESS_DENIED"), { status: 403, threadId })));
229
+ else
230
+ await this.cache.remove(threadId);
231
+ }
198
232
  wake() { for (const entry of this.entries.values())
199
233
  this.schedule(entry); }
200
234
  hint(thread, versionNumber) {
@@ -232,6 +266,8 @@ export class GroupClient {
232
266
  const value = this.normalize(await this.options.load(entry.thread.targetId, controller.signal), entry);
233
267
  await this.checkAuthority();
234
268
  this.assertOpen();
269
+ if (this.denied.has(entry.thread.id))
270
+ throw Object.assign(new Error("GROUP_ACCESS_REVOKED"), { status: 403, threadId: entry.thread.id });
235
271
  if (entry.snapshot.data && value.versionNumber <= entry.snapshot.data.versionNumber)
236
272
  await this.reload(entry);
237
273
  const old = entry.snapshot.data;
@@ -267,15 +303,19 @@ export class GroupClient {
267
303
  return value;
268
304
  }
269
305
  catch (error) {
270
- if (error?.status === 403)
271
- Object.assign(error, { threadId: entry.thread.id });
272
306
  if (!this.closed) {
273
307
  const denied = [401, 403].includes(error?.status ?? 0);
274
308
  this.publish(entry, { data: denied ? undefined : entry.snapshot.data, loading: false, error });
275
- if (denied) {
276
- this.denied.add(entry.thread.id);
277
- await this.cache.remove(entry.thread.id);
278
- await this.options.onDenied?.(error, entry.thread.id);
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
+ }
279
319
  }
280
320
  }
281
321
  throw error;
@@ -0,0 +1,2 @@
1
+ export declare function scopedGroupDenial<T extends object>(error: T): T;
2
+ export declare function isScopedGroupDenial(error: unknown): boolean;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.75",
3
+ "version": "0.12.0-next.77",
4
4
  "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",