@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.
@@ -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
- async revokeThread(threadId) { await Promise.all([this.messages.revoke(threadId), this.groups?.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,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?.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,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.publish(entry, { loading: false, error: Object.assign(new Error("GROUP_ACCESS_REVOKED"), { status: 403, threadId }) });
203
- await this.cache.remove(threadId);
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
- this.denied.add(entry.thread.id);
286
- await this.cache.remove(entry.thread.id);
287
- 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
+ }
288
319
  }
289
320
  }
290
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.76",
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",