@pmate/chat-sdk 0.12.0-next.20 → 0.12.0-next.21

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.
@@ -5,6 +5,7 @@ export class MessageClient {
5
5
  options;
6
6
  storage;
7
7
  watches = new Map();
8
+ accessListeners = new Set();
8
9
  allListeners = new Set();
9
10
  reads = new Map();
10
11
  syncing = new Map();
@@ -24,6 +25,15 @@ export class MessageClient {
24
25
  pendingBytes = 0;
25
26
  pendingRecords = 0;
26
27
  legacyImports = 0;
28
+ revokingScope;
29
+ scopeReady;
30
+ scopeVersion = 0;
31
+ scopeAllowed = true;
32
+ scopeDenied = false;
33
+ enqueuing = new Set();
34
+ ensureScope() {
35
+ return this.scopeReady ??= this.storage.scopeAccess().then(access => { this.scopeVersion = access.version; this.scopeAllowed = !access.revoked && !this.scopeDenied; });
36
+ }
27
37
  constructor(options) {
28
38
  this.options = options;
29
39
  this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
@@ -44,10 +54,22 @@ export class MessageClient {
44
54
  this.channel.onmessage = event => {
45
55
  if (this.closed)
46
56
  return;
47
- if (event.data?.type === "revoked" && typeof event.data.thread === "string" && Number.isSafeInteger(event.data.version)) {
57
+ if (event.data?.type === "scope-revoked" && Number.isSafeInteger(event.data.version)) {
58
+ const version = event.data.version;
59
+ void this.ensureScope().then(async () => {
60
+ const current = await this.storage.scopeAccess();
61
+ if (!this.closed && (this.scopeVersion < version || current.revoked)) {
62
+ this.denyScope();
63
+ options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), "");
64
+ }
65
+ }).catch(error => options.onError?.(error, ""));
66
+ }
67
+ else if (event.data?.type === "revoked" && typeof event.data.thread === "string" && Number.isSafeInteger(event.data.version)) {
48
68
  const denied = event.data;
49
69
  void (async () => {
50
70
  const current = await this.storage.read(denied.thread, 1).then(page => page.coverage).catch(() => undefined);
71
+ if ((current?.scopeVersion ?? 0) !== (denied.scopeVersion ?? 0))
72
+ return;
51
73
  if (this.closed || (current && !current.revoked && (current.resetVersion ?? 0) >= denied.version))
52
74
  return;
53
75
  this.denyView(denied.thread, denied.version);
@@ -65,6 +87,15 @@ export class MessageClient {
65
87
  };
66
88
  }
67
89
  }
90
+ /** Shared guard for sibling caches; it never authorizes a denied old instance. */
91
+ subscribeAccessRevoked(listener) { this.accessListeners.add(listener); return () => { this.accessListeners.delete(listener); }; }
92
+ async scopeAccess() {
93
+ await this.ensureScope();
94
+ if (this.closed)
95
+ return { version: this.scopeVersion, allowed: false };
96
+ const current = await this.storage.scopeAccess();
97
+ return { version: current.version, allowed: !this.scopeDenied && !current.revoked && current.version === this.scopeVersion };
98
+ }
68
99
  getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
69
100
  subscribe(thread, listener) {
70
101
  let watch = this.watches.get(thread);
@@ -101,7 +132,14 @@ export class MessageClient {
101
132
  if (running)
102
133
  return running;
103
134
  const task = (async () => {
135
+ await this.ensureScope();
136
+ if (this.scopeDenied)
137
+ throw new Error("MESSAGE_ACCESS_REVOKED");
104
138
  const local = await this.storage.read(thread, limit);
139
+ if ((local.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
140
+ this.denyScope();
141
+ throw new Error("MESSAGE_SCOPE_CHANGED");
142
+ }
105
143
  if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
106
144
  if (typeof navigator !== "undefined" && navigator.onLine === false) {
107
145
  if (!refresh && !local.coverage?.revoked) {
@@ -116,6 +154,10 @@ export class MessageClient {
116
154
  await this.synchronize(thread);
117
155
  }
118
156
  catch (error) {
157
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
158
+ this.denyScope();
159
+ throw error;
160
+ }
119
161
  if (refresh || [401, 403].includes(error?.status ?? 0))
120
162
  throw error;
121
163
  const fallback = await this.fallback(thread, limit);
@@ -132,6 +174,10 @@ export class MessageClient {
132
174
  result = await this.fillWindow(thread, limit);
133
175
  }
134
176
  catch (error) {
177
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
178
+ this.denyScope();
179
+ throw error;
180
+ }
135
181
  if ([401, 403].includes(error?.status ?? 0))
136
182
  throw error;
137
183
  const cached = await this.fallback(thread, limit);
@@ -156,7 +202,12 @@ export class MessageClient {
156
202
  this.syncTasks.schedule(thread, () => this.synchronize(thread));
157
203
  }
158
204
  async fallback(thread, limit, before) {
205
+ await this.ensureScope();
159
206
  const page = await this.storage.readFallback(thread, limit, before);
207
+ if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
208
+ this.denyScope();
209
+ throw new Error("MESSAGE_SCOPE_CHANGED");
210
+ }
160
211
  if (!before && page.messages.length)
161
212
  await this.reload(thread, true);
162
213
  return { messages: page.messages, hasEarlier: true, complete: false, resetVersion: page.coverage?.resetVersion ?? 0 };
@@ -192,11 +243,16 @@ export class MessageClient {
192
243
  return task;
193
244
  }
194
245
  async reload(thread, fallback = false) {
246
+ await this.ensureScope();
195
247
  const limit = 100;
196
248
  const page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
197
249
  if (this.closed)
198
250
  throw new Error("MESSAGE_CLIENT_CLOSED");
199
- if (this.revoked.has(thread)) {
251
+ if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
252
+ this.denyScope();
253
+ throw new Error("MESSAGE_SCOPE_CHANGED");
254
+ }
255
+ if (this.scopeDenied || this.revoked.has(thread)) {
200
256
  page.messages = [];
201
257
  page.hasEarlier = false;
202
258
  }
@@ -207,7 +263,7 @@ export class MessageClient {
207
263
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
208
264
  });
209
265
  const old = watch?.snapshot;
210
- const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !fallback && !this.revoked.has(thread) && (page.coverage?.complete ?? false);
266
+ const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !fallback && !this.scopeDenied && !this.revoked.has(thread) && (page.coverage?.complete ?? false);
211
267
  const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
212
268
  if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === hasEarlier && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
213
269
  return old;
@@ -227,7 +283,6 @@ export class MessageClient {
227
283
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
228
284
  if (this.revoked.has(thread))
229
285
  return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
230
- const queue = this.writes.get(thread) ?? [];
231
286
  if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
232
287
  return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
233
288
  let bytes = 0;
@@ -244,15 +299,25 @@ export class MessageClient {
244
299
  }
245
300
  this.pendingBytes += bytes;
246
301
  this.pendingRecords += messages.length;
247
- const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, resolve, reject }));
248
- this.writes.set(thread, queue);
249
- this.writeTasks.schedule(thread, () => this.flushWrites(thread));
250
- return promise;
302
+ let resolve, reject;
303
+ const committed = new Promise((yes, no) => { resolve = yes; reject = no; });
304
+ const enqueue = this.ensureScope().then(() => {
305
+ if (this.closed || this.scopeDenied)
306
+ throw new Error("MESSAGE_ACCESS_REVOKED");
307
+ if (!this.scopeAllowed)
308
+ throw new Error("MESSAGE_SCOPE_NOT_READY");
309
+ const queue = this.writes.get(thread) ?? [];
310
+ queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, scopeVersion: this.scopeVersion, resolve, reject });
311
+ this.writes.set(thread, queue);
312
+ this.writeTasks.schedule(thread, () => this.flushWrites(thread));
313
+ }).catch(error => { this.pendingBytes -= bytes; this.pendingRecords -= messages.length; reject(error); }).finally(() => this.enqueuing.delete(enqueue));
314
+ this.enqueuing.add(enqueue);
315
+ return committed;
251
316
  }
252
- async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion) {
317
+ async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion, expectedScopeVersion) {
253
318
  const initial = (await this.storage.read(thread, 1)).coverage;
254
319
  const version = expectedVersion ?? initial?.resetVersion ?? 0;
255
- const guard = { version, authoritative: complete === true };
320
+ const guard = { version, scopeVersion: expectedScopeVersion ?? this.scopeVersion, authoritative: complete === true };
256
321
  let changed = 0;
257
322
  let navigationChanged = false;
258
323
  // Coverage advances only after all bounded batches have durably committed.
@@ -260,7 +325,7 @@ export class MessageClient {
260
325
  const commit = async () => {
261
326
  if (!batch.length)
262
327
  return;
263
- if (this.closed || this.revoked.has(thread))
328
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
264
329
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
265
330
  changed += await this.storage.commit(thread, batch, undefined, guard);
266
331
  batch = [];
@@ -276,11 +341,13 @@ export class MessageClient {
276
341
  await commit();
277
342
  if (complete !== undefined || checkpoint !== undefined) {
278
343
  const old = (await this.storage.read(thread, 1)).coverage;
279
- if (this.closed || this.revoked.has(thread))
344
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
280
345
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
281
346
  navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
282
347
  await this.storage.commit(thread, [], { resetVersion: version, revoked: complete === true ? false : old?.revoked, syncedAt: syncedAt ?? old?.syncedAt, complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore }, guard);
283
348
  }
349
+ if (complete === true)
350
+ this.scopeAllowed = true;
284
351
  if (changed || navigationChanged) {
285
352
  await this.reload(thread);
286
353
  this.channel?.postMessage(thread);
@@ -311,14 +378,14 @@ export class MessageClient {
311
378
  try {
312
379
  const groups = new Map();
313
380
  for (const write of queue) {
314
- const group = groups.get(write.version) ?? [];
381
+ const key = `${write.version}:${write.scopeVersion}`, group = groups.get(key) ?? [];
315
382
  group.push(write);
316
- groups.set(write.version, group);
383
+ groups.set(key, group);
317
384
  }
318
385
  let failed = false, failure;
319
- for (const [version, writes] of groups) {
386
+ for (const writes of groups.values()) {
320
387
  try {
321
- await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, version);
388
+ await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, writes[0].version, writes[0].scopeVersion);
322
389
  writes.forEach(write => write.resolve());
323
390
  }
324
391
  catch (error) {
@@ -335,31 +402,37 @@ export class MessageClient {
335
402
  }
336
403
  }
337
404
  synchronize(thread) {
338
- if (this.closed || this.closing || this.revoked.has(thread))
405
+ if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
339
406
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
340
407
  const running = this.syncing.get(thread);
341
408
  if (running)
342
409
  return running;
343
410
  const task = (async () => {
411
+ await this.ensureScope();
344
412
  const coverage = (await this.storage.read(thread, 1)).coverage;
413
+ const scopeVersion = coverage?.scopeVersion ?? 0;
414
+ if (scopeVersion !== this.scopeVersion) {
415
+ this.denyScope();
416
+ throw new Error("MESSAGE_SCOPE_CHANGED");
417
+ }
345
418
  let version = coverage?.resetVersion ?? 0;
346
419
  let after = coverage?.complete ? coverage.checkpoint : undefined;
347
420
  let page = after && this.options.transport.changes
348
421
  ? await this.options.transport.changes(thread, after, this.abort.signal)
349
422
  : await this.options.transport.load(thread, this.abort.signal);
350
423
  if (page.resetRequired) {
351
- if (this.closed || this.revoked.has(thread))
424
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
352
425
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
353
- version = await this.storage.resetThread(thread);
426
+ version = await this.storage.resetThread(thread, false, scopeVersion);
354
427
  after = undefined;
355
428
  page = await this.options.transport.load(thread, this.abort.signal);
356
429
  }
357
430
  for (;;) {
358
- if (this.closed || this.revoked.has(thread))
431
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
359
432
  throw new Error("MESSAGE_ACCESS_REVOKED");
360
433
  if (page.resetRequired)
361
434
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
362
- await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
435
+ await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version, scopeVersion);
363
436
  if (!page.hasMoreChanges)
364
437
  break;
365
438
  if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
@@ -368,6 +441,8 @@ export class MessageClient {
368
441
  page = await this.options.transport.changes(thread, after, this.abort.signal);
369
442
  }
370
443
  })().catch(async (error) => {
444
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED"))
445
+ this.denyScope();
371
446
  if (!this.closed && !this.closing && error?.status === 403) {
372
447
  try {
373
448
  await this.revoke(thread);
@@ -383,6 +458,9 @@ export class MessageClient {
383
458
  return task;
384
459
  }
385
460
  async earlier(thread, before, limit = 100) {
461
+ await this.ensureScope();
462
+ if (this.scopeDenied)
463
+ throw new Error("MESSAGE_ACCESS_REVOKED");
386
464
  if (this.closed)
387
465
  throw new Error("MESSAGE_CLIENT_CLOSED");
388
466
  if (this.revoked.has(thread))
@@ -396,6 +474,10 @@ export class MessageClient {
396
474
  return await this.fillWindow(thread, limit, before);
397
475
  }
398
476
  catch (error) {
477
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
478
+ this.denyScope();
479
+ throw error;
480
+ }
399
481
  if ([401, 403].includes(error?.status ?? 0))
400
482
  throw error;
401
483
  const fallback = await this.fallback(thread, limit, before);
@@ -415,21 +497,21 @@ export class MessageClient {
415
497
  page = await this.storage.read(thread, limit, before);
416
498
  continue;
417
499
  }
418
- const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
500
+ const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0, scopeVersion = page.coverage.scopeVersion ?? 0;
419
501
  const task = (async () => {
420
502
  const remote = await this.options.transport.older(thread, token, this.abort.signal);
421
- if (this.closed || this.revoked.has(thread))
503
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
422
504
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
423
505
  if (remote.resetRequired) {
424
506
  if (++resets > 2)
425
507
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
426
- await this.storage.resetThread(thread);
508
+ await this.storage.resetThread(thread, false, scopeVersion);
427
509
  await this.synchronize(thread);
428
510
  return;
429
511
  }
430
512
  if (remote.nextBefore === token)
431
513
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
432
- await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
514
+ await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version, scopeVersion);
433
515
  })().catch(async (error) => {
434
516
  if (!this.closed && !this.closing && error?.status === 403) {
435
517
  try {
@@ -462,6 +544,38 @@ export class MessageClient {
462
544
  this.watches.set(thread, { snapshot, listeners: new Set() });
463
545
  this.allListeners.forEach(listener => listener(thread));
464
546
  }
547
+ denyScope() {
548
+ const notify = !this.scopeDenied;
549
+ this.scopeDenied = true;
550
+ this.scopeAllowed = false;
551
+ this.abort.abort();
552
+ this.syncTasks.close();
553
+ this.writeTasks.close();
554
+ for (const thread of this.watches.keys())
555
+ this.denyView(thread);
556
+ for (const queue of this.writes.values()) {
557
+ queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
558
+ this.release(queue);
559
+ }
560
+ this.writes.clear();
561
+ if (notify)
562
+ this.accessListeners.forEach(listener => listener());
563
+ }
564
+ revokeScope() {
565
+ if (this.closed || this.closing)
566
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
567
+ return this.revokingScope ??= (async () => {
568
+ this.denyScope();
569
+ try {
570
+ await this.ensureScope();
571
+ return await this.storage.revokeScope(this.scopeVersion);
572
+ }
573
+ finally {
574
+ const access = await this.storage.scopeAccess();
575
+ this.channel?.postMessage({ type: "scope-revoked", version: access.version });
576
+ }
577
+ })();
578
+ }
465
579
  async revoke(thread) {
466
580
  this.denyView(thread);
467
581
  const queue = this.writes.get(thread);
@@ -471,25 +585,36 @@ export class MessageClient {
471
585
  this.release(queue);
472
586
  }
473
587
  try {
474
- await this.storage.resetThread(thread, true);
588
+ await this.storage.resetThread(thread, true, this.scopeVersion);
475
589
  await this.reload(thread);
476
590
  }
477
591
  finally {
478
- this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion });
592
+ this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion, scopeVersion: this.scopeVersion });
479
593
  }
480
594
  }
481
- async close() {
482
- // Flush accepted event batches before closing; new requests are rejected afterwards.
595
+ async close(beforeDispose) {
596
+ if (this.closed)
597
+ return;
483
598
  this.closing = true;
484
- this.syncTasks.close();
485
- this.writeTasks.close();
486
- await Promise.all([...this.writes.keys()].map(thread => this.flushWrites(thread)));
487
- await Promise.all([...this.activeWrites]);
488
- this.closed = true;
489
- this.abort.abort();
490
- this.channel?.close();
491
- this.watches.clear();
492
- this.allListeners.clear();
493
- this.storage.close();
599
+ try {
600
+ await Promise.all([...this.enqueuing]);
601
+ this.syncTasks.close();
602
+ this.writeTasks.close();
603
+ const flushes = [...this.writes.keys()].map(thread => this.flushWrites(thread));
604
+ const results = await Promise.allSettled([...flushes, ...this.activeWrites, ...(this.revokingScope ? [this.revokingScope] : [])]);
605
+ const failed = results.find((result) => result.status === "rejected");
606
+ if (failed)
607
+ throw failed.reason;
608
+ await beforeDispose;
609
+ }
610
+ finally {
611
+ this.closed = true;
612
+ this.abort.abort();
613
+ this.channel?.close();
614
+ this.watches.clear();
615
+ this.allListeners.clear();
616
+ this.accessListeners.clear();
617
+ this.storage.close();
618
+ }
494
619
  }
495
620
  }
@@ -1,3 +1,4 @@
1
+ import { type ResponseCacheAccess } from "./responseCache.js";
1
2
  import { type MessageScope } from "./messageStore.js";
2
3
  export type MessageReadersPage = {
3
4
  appId: string;
@@ -23,6 +24,7 @@ export type MessageReadersOptions = {
23
24
  hubHttpUrl: string;
24
25
  fetch?: typeof fetch;
25
26
  scope?: MessageScope;
27
+ access?: ResponseCacheAccess;
26
28
  onError?: (error: unknown) => void;
27
29
  };
28
30
  export declare class MessageReadersError extends Error {
@@ -42,5 +44,7 @@ export declare class MessageReadersClient {
42
44
  getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
43
45
  private cachedRequest;
44
46
  private request;
47
+ withdraw(): void;
48
+ retireScope(version: number): Promise<void>;
45
49
  close(): Promise<void>;
46
50
  }
@@ -1,4 +1,4 @@
1
- import { IndexedDbResponseCache } from "./responseCache.js";
1
+ import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
2
2
  import { messageScopeKey } from "./messageStore.js";
3
3
  export class MessageReadersError extends Error {
4
4
  status;
@@ -22,7 +22,7 @@ export class MessageReadersClient {
22
22
  if (options.scope) {
23
23
  if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
24
24
  throw new Error("MESSAGE_READERS_SCOPE_MISMATCH");
25
- this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope));
25
+ this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
26
26
  }
27
27
  }
28
28
  getReaders(query, signal) {
@@ -56,7 +56,8 @@ export class MessageReadersClient {
56
56
  const cache = input.limit === 50 ? this.cache : undefined;
57
57
  if (!cache)
58
58
  return this.request(input);
59
- const stored = await cache.read(key).catch(error => { this.options.onError?.(error); return undefined; });
59
+ const stored = await cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
60
+ throw error; this.options.onError?.(error); return undefined; });
60
61
  if (this.lifetime.signal.aborted)
61
62
  throw new Error("MESSAGE_READERS_CLOSED");
62
63
  const generation = this.generation;
@@ -84,13 +85,16 @@ export class MessageReadersClient {
84
85
  if (value.resetRequired)
85
86
  await cache?.invalidate().catch(error => this.options.onError?.(error));
86
87
  else if (stored)
87
- await cache?.write(key, value, stored.epoch).catch(error => { if (String(error).includes("RESPONSE_CACHE_INVALIDATED"))
88
+ await cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
88
89
  throw error; this.options.onError?.(error); });
89
90
  if (this.lifetime.signal.aborted)
90
91
  throw new Error("MESSAGE_READERS_CLOSED");
91
92
  return value;
92
93
  }
93
94
  async request(input) {
95
+ const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
96
+ if (this.lifetime.signal.aborted)
97
+ throw new Error("MESSAGE_READERS_CLOSED");
94
98
  const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
95
99
  const controller = new AbortController();
96
100
  const cancel = () => controller.abort(this.lifetime.signal.reason);
@@ -113,6 +117,8 @@ export class MessageReadersClient {
113
117
  throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
114
118
  if (this.lifetime.signal.aborted)
115
119
  throw new Error("MESSAGE_READERS_CLOSED");
120
+ if (this.options.access)
121
+ await assertResponseCacheAccess(this.options.access, scopeVersion);
116
122
  return data;
117
123
  }
118
124
  finally {
@@ -120,5 +126,7 @@ export class MessageReadersClient {
120
126
  this.lifetime.signal.removeEventListener("abort", cancel);
121
127
  }
122
128
  }
129
+ withdraw() { this.lifetime.abort(); this.pending.clear(); }
130
+ retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
123
131
  close() { this.lifetime.abort(); this.pending.clear(); return this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve(); }
124
132
  }
@@ -18,6 +18,12 @@ export type MessageCoverage = {
18
18
  syncedAt?: number;
19
19
  resetVersion?: number;
20
20
  revoked?: boolean;
21
+ scopeVersion?: number;
22
+ };
23
+ export type MessageScopeAccess = {
24
+ scope: string;
25
+ version: number;
26
+ revoked: boolean;
21
27
  };
22
28
  type CoverageRow = MessageCoverage & {
23
29
  scope: string;
@@ -32,6 +38,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
32
38
  private rows;
33
39
  private legacy;
34
40
  private coverage;
41
+ private accessRows;
35
42
  private key;
36
43
  constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
37
44
  indexedDB?: IDBFactory;
@@ -44,7 +51,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
44
51
  }): Promise<{
45
52
  messages: T[];
46
53
  hasEarlier: boolean;
47
- coverage: CoverageRow | undefined;
54
+ coverage: MessageCoverage | undefined;
48
55
  }>;
49
56
  /** Migration records never claim coverage or overwrite authoritative rows. */
50
57
  importLegacy(thread: string, messages: T[]): Promise<number>;
@@ -55,15 +62,21 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
55
62
  }): Promise<{
56
63
  messages: T[];
57
64
  hasEarlier: boolean;
58
- coverage: CoverageRow | undefined;
65
+ coverage: MessageCoverage | undefined;
59
66
  }>;
60
67
  commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
61
68
  version: number;
62
69
  authoritative?: boolean;
70
+ scopeVersion?: number;
63
71
  }): Promise<number>;
64
72
  windows(): Promise<CoverageRow[]>;
73
+ scopeAccess(): Promise<MessageScopeAccess>;
74
+ private scopedCoverage;
75
+ private clearScopeRows;
76
+ /** Persist the denial first; failed cleanup must never make cached rows readable. */
77
+ revokeScope(expectedVersion?: number): Promise<number>;
65
78
  /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
66
- resetThread(thread: string, revoked?: boolean): Promise<number>;
79
+ resetThread(thread: string, revoked?: boolean, scopeVersion?: number): Promise<number>;
67
80
  removeThread(thread: string): Promise<void>;
68
81
  clear(): Promise<void>;
69
82
  close(): void;