@pairlobby/server-core 0.32.0 → 0.33.0

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.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/dist/admission.d.ts +11 -0
  3. package/dist/admission.d.ts.map +1 -1
  4. package/dist/admission.js +18 -12
  5. package/dist/admission.js.map +1 -1
  6. package/dist/auto-close-store.d.ts +10 -8
  7. package/dist/auto-close-store.d.ts.map +1 -1
  8. package/dist/auto-close-store.js +35 -22
  9. package/dist/auto-close-store.js.map +1 -1
  10. package/dist/changes.d.ts +25 -11
  11. package/dist/changes.d.ts.map +1 -1
  12. package/dist/changes.js +81 -22
  13. package/dist/changes.js.map +1 -1
  14. package/dist/commit.d.ts +12 -0
  15. package/dist/commit.d.ts.map +1 -0
  16. package/dist/commit.js +33 -0
  17. package/dist/commit.js.map +1 -0
  18. package/dist/export.d.ts +12 -0
  19. package/dist/export.d.ts.map +1 -0
  20. package/dist/export.js +72 -0
  21. package/dist/export.js.map +1 -0
  22. package/dist/index.d.ts +4 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/paging.d.ts +12 -0
  27. package/dist/paging.d.ts.map +1 -0
  28. package/dist/paging.js +32 -0
  29. package/dist/paging.js.map +1 -0
  30. package/dist/ports.d.ts +12 -5
  31. package/dist/ports.d.ts.map +1 -1
  32. package/dist/ports.js +12 -9
  33. package/dist/ports.js.map +1 -1
  34. package/dist/relay.d.ts +14 -4
  35. package/dist/relay.d.ts.map +1 -1
  36. package/dist/relay.js +79 -39
  37. package/dist/relay.js.map +1 -1
  38. package/dist/service.d.ts +53 -3
  39. package/dist/service.d.ts.map +1 -1
  40. package/dist/service.js +459 -207
  41. package/dist/service.js.map +1 -1
  42. package/dist/settings.d.ts +33 -0
  43. package/dist/settings.d.ts.map +1 -0
  44. package/dist/settings.js +66 -0
  45. package/dist/settings.js.map +1 -0
  46. package/dist/store.d.ts +32 -13
  47. package/dist/store.d.ts.map +1 -1
  48. package/dist/turns.d.ts +1 -0
  49. package/dist/turns.d.ts.map +1 -1
  50. package/dist/turns.js +150 -117
  51. package/dist/turns.js.map +1 -1
  52. package/package.json +4 -3
  53. package/dist/request-backfill.d.ts +0 -3
  54. package/dist/request-backfill.d.ts.map +0 -1
  55. package/dist/request-backfill.js +0 -34
  56. package/dist/request-backfill.js.map +0 -1
package/dist/service.js CHANGED
@@ -1,14 +1,22 @@
1
1
  //! Room operations shared by every transport. The Cloudflare Durable Object and
2
2
  //! the Node server both run this; only the store and the socket layer differ.
3
- import { ABSOLUTE_MAX_FILE_BYTES, CONTACT_INVITE_LIFETIME_MS, DEFAULT_MAX_FILE_BYTES, DEFAULT_MAX_RETAINED_FILE_BYTES, DEFAULT_ROOM_POLICY, PROTOCOL_VERSION, ProtocolError, UNATTACHED_FILE_LIFETIME_MS, canSee, formatFileSize, hashCredential, newId, newInviteCode, normalizeInviteCode, roomType, safeFileName } from '@pairlobby/protocol';
4
- import { assertRoomWritable, appendEvent, assertRoomJoinable, assertCanWrite, assertCanAcknowledge, assertActiveMember, assertController, assertPermitted, authenticate, closeRoom, createRoom, joinAsGuest, joinOnLocalNetwork, recordInvitation, joinRoom, leaveRoom, rejoinRoom, renameRoom, renameSelf, requestControl, revokeParticipant, sendEvent, setExpiry, setAutoClose, setInviteRole, setJoinPolicy, setLocalJoin, setLocked, setMuted, setParticipantPermissions, setParticipantRole, setRoomPermissions, setRoomType, setRoomVisibility, emptyMutation, findParticipant, isActive, toSnapshot } from '@pairlobby/room-core';
3
+ import { sha256 } from '@noble/hashes/sha2.js';
4
+ import { ABSOLUTE_MAX_FILE_BYTES, CONTACT_INVITE_LIFETIME_MS, DEFAULT_MAX_FILE_BYTES, DEFAULT_MAX_RETAINED_FILE_BYTES, DEFAULT_ROOM_POLICY, FILE_CHUNK_BYTES, PROTOCOL_VERSION, ProtocolError, UNATTACHED_FILE_LIFETIME_MS, canSee, formatFileSize, hashCredential, newId, newInviteCode, normalizeInviteCode, roomType, safeFileName } from '@pairlobby/protocol';
5
+ import { assertRoomWritable, assertRoomReadable, appendEvent, assertRoomJoinable, assertCanWrite, assertCanAcknowledge, assertActiveMember, assertController, assertPermitted, authenticate, closeRoom, createRoom, joinAsGuest, joinOnLocalNetwork, recordInvitation, joinRoom, leaveRoom, rejoinRoom, renameRoom, renameSelf, requestControl, revokeParticipant, sendEvent, setExpiry, setAutoClose, setInviteRole, setJoinPolicy, setLocalJoin, setLocked, setMuted, setParticipantPermissions, setParticipantRole, setRoomPermissions, setRoomType, setRoomVisibility, emptyMutation, findParticipant, isActive, toSnapshot } from '@pairlobby/room-core';
5
6
  import { stableStringify } from './stable-json.js';
6
7
  import { TurnCoordinator, assertTurn } from './turns.js';
7
8
  import { AutoCloseStore } from './auto-close-store.js';
9
+ import { retryRoomConflict } from './commit.js';
10
+ import { exportResponse, retainedEvents } from './export.js';
11
+ /** Membership authorization for a transport attachment that was authenticated when issued. */
12
+ export function assertConnectedParticipant(snapshot, participantId) {
13
+ const participant = snapshot.participants.find((entry) => entry.participantId === participantId);
14
+ if (!participant || participant.revoked || participant.left) {
15
+ throw new ProtocolError('participant_revoked', 'this participant is no longer in the room');
16
+ }
17
+ }
8
18
  /** Why a code made by a member, or a join by name, did not let someone in. */
9
19
  const NOT_VISIBLE = 'the room\'s owner has not let you see this room: ask them for a code, or log in to the relay as someone who can see it (pairlobby login)';
10
- /** Requests a watermark marks received in one write; the rest wait for the next one. */
11
- const WATERMARK_REQUEST_LIMIT = 2000;
12
20
  /** A receipt that only says the message arrived: no Read stage, no action. */
13
21
  function isPlainReceipt(request) {
14
22
  return request.type === 'message.received' && !request.payload.stage && !request.payload.action;
@@ -61,13 +69,24 @@ export class RoomService {
61
69
  ctx() {
62
70
  return { now: this.now(), newEventId: () => newId('event') };
63
71
  }
64
- async view(roomId) {
65
- const view = await this.store.loadRoom(roomId);
72
+ async view(roomId, handovers = false) {
73
+ const view = await this.store.loadRoom(roomId, { handovers });
66
74
  if (!view) {
67
75
  throw new ProtocolError('room_not_found', 'no such room');
68
76
  }
69
77
  return view;
70
78
  }
79
+ /** Reading events or requests needs one credential, not every past member and handover. */
80
+ async reader(roomId, credential) {
81
+ const head = await this.store.roomHead(roomId);
82
+ if (!head) {
83
+ throw new ProtocolError('room_not_found', 'no such room');
84
+ }
85
+ const hash = await hashCredential(credential);
86
+ const participant = await this.store.participantByCredential(roomId, hash);
87
+ const actor = authenticate({ ...head, participants: participant ? [participant] : [], controls: [], handovers: [] }, hash, this.now());
88
+ return { head, actor };
89
+ }
71
90
  /** What the room looks like, plus what this relay can do that depends on its store rather than on the room. */
72
91
  snapshotOf(view) {
73
92
  return { ...toSnapshot(view), filesSupported: this.store.files !== undefined, receiptMode: this.receiptMode };
@@ -89,31 +108,86 @@ export class RoomService {
89
108
  */
90
109
  async uploadFile(roomId, credential, upload) {
91
110
  const files = this.fileStore();
111
+ const credentialHash = await hashCredential(credential);
112
+ const size = upload.bytes.byteLength;
113
+ const { uploaderId } = await this.uploadPermission(roomId, credentialHash, size);
114
+ const now = this.now();
115
+ await files.deleteUnattachedFiles(roomId, now - UNATTACHED_FILE_LIFETIME_MS);
116
+ const digest = hexDigest(sha256(upload.bytes));
117
+ const file = this.uploadRecord(roomId, uploaderId, upload.name, upload.mediaType, size, digest, now);
118
+ await retryRoomConflict(async () => {
119
+ const { view, retained } = await this.uploadPermission(roomId, credentialHash, size);
120
+ if (!(await files.putFile(file, upload.bytes, retained, view.room.stateRevision ?? 0))) {
121
+ throw new ProtocolError('quota_exceeded', `this room has reached its file storage limit of ${formatFileSize(retained)}`);
122
+ }
123
+ });
124
+ return toAttachment(file);
125
+ }
126
+ async uploadPermission(roomId, credentialHash, size) {
92
127
  const view = await this.view(roomId);
93
128
  const now = this.now();
94
- const actor = authenticate(view, await hashCredential(credential), now);
129
+ const actor = authenticate(view, credentialHash, now);
95
130
  assertRoomWritable(view, now);
96
131
  const sender = assertCanWrite(actor);
97
132
  assertPermitted(view, actor, 'attach');
98
133
  const limit = Math.min(view.room.policy.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES, ABSOLUTE_MAX_FILE_BYTES);
99
- const size = upload.bytes.byteLength;
100
134
  if (limit === 0) {
101
135
  throw new ProtocolError('unsupported_capability', 'this room does not accept files');
102
136
  }
103
137
  if (size === 0) {
104
138
  throw new ProtocolError('invalid_request', 'an empty file cannot be attached');
105
139
  }
140
+ if (!Number.isSafeInteger(size) || size < 0) {
141
+ throw new ProtocolError('invalid_request', 'a file needs a positive, declared byte length');
142
+ }
106
143
  if (size > limit) {
107
144
  throw new ProtocolError('payload_too_large', `this file is ${formatFileSize(size)}; this room accepts files up to ${formatFileSize(limit)}`);
108
145
  }
109
- await files.deleteUnattachedFiles(roomId, now - UNATTACHED_FILE_LIFETIME_MS);
110
- const digest = await globalThis.crypto.subtle.digest('SHA-256', upload.bytes);
111
- const sha256 = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
112
- const mediaType = /^[\w.+-]{1,60}\/[\w.+-]{1,60}$/.test(upload.mediaType.split(';')[0].trim()) ? upload.mediaType.split(';')[0].trim().toLowerCase() : 'application/octet-stream';
113
- const file = { fileId: newId('file'), roomId, uploaderId: sender.participantId, name: safeFileName(upload.name), size, mediaType, sha256, createdAt: now, eventId: null };
114
- const retained = view.room.policy.maxRetainedFileBytes ?? DEFAULT_MAX_RETAINED_FILE_BYTES;
115
- if (!(await files.putFile(file, upload.bytes, retained))) {
116
- throw new ProtocolError('quota_exceeded', `this room has reached its file storage limit of ${formatFileSize(retained)}`);
146
+ return { view, uploaderId: sender.participantId, retained: view.room.policy.maxRetainedFileBytes ?? DEFAULT_MAX_RETAINED_FILE_BYTES };
147
+ }
148
+ uploadRecord(roomId, uploaderId, name, claimedType, size, digest, now) {
149
+ const baseType = claimedType.split(';')[0].trim();
150
+ const mediaType = /^[\w.+-]{1,60}\/[\w.+-]{1,60}$/.test(baseType) ? baseType.toLowerCase() : 'application/octet-stream';
151
+ return { fileId: newId('file'), roomId, uploaderId, name: safeFileName(name), size, mediaType, sha256: digest, createdAt: now, eventId: null };
152
+ }
153
+ /** Reserve quota, stream bounded chunks, and make the upload visible only after validation and fresh authorization. */
154
+ async uploadFileStream(roomId, credential, upload, run = (action) => action()) {
155
+ const files = this.fileStore();
156
+ const credentialHash = await hashCredential(credential);
157
+ const { uploaderId } = await run(() => this.uploadPermission(roomId, credentialHash, upload.size));
158
+ const now = this.now();
159
+ await run(() => files.deleteUnattachedFiles(roomId, now - UNATTACHED_FILE_LIFETIME_MS));
160
+ const file = this.uploadRecord(roomId, uploaderId, upload.name, upload.mediaType, upload.size, '', now);
161
+ if (!files.createUpload || !files.writeChunk || !files.completeUpload || !files.cancelUpload) {
162
+ // Compatibility for older in-process adapters. The production SQLite adapters implement staging.
163
+ const bytes = new Uint8Array(upload.size);
164
+ let offset = 0;
165
+ await consumeUpload(upload, async (chunk) => { bytes.set(chunk, offset); offset += chunk.byteLength; });
166
+ return run(() => this.uploadFile(roomId, credential, { name: upload.name, mediaType: upload.mediaType, bytes }));
167
+ }
168
+ await retryRoomConflict(() => run(async () => {
169
+ const { view, retained } = await this.uploadPermission(roomId, credentialHash, upload.size);
170
+ if (!(await files.createUpload(file, retained, view.room.stateRevision ?? 0))) {
171
+ throw new ProtocolError('quota_exceeded', `this room has reached its file storage limit of ${formatFileSize(retained)}`);
172
+ }
173
+ }));
174
+ try {
175
+ let index = 0;
176
+ const digest = sha256.create();
177
+ await consumeUpload(upload, async (chunk) => {
178
+ digest.update(chunk);
179
+ await run(() => files.writeChunk(roomId, file.fileId, index++, chunk));
180
+ });
181
+ file.sha256 = hexDigest(digest.digest());
182
+ await retryRoomConflict(() => run(async () => {
183
+ upload.signal?.throwIfAborted();
184
+ const { view } = await this.uploadPermission(roomId, credentialHash, upload.size);
185
+ await files.completeUpload(file, view.room.stateRevision ?? 0);
186
+ }));
187
+ }
188
+ catch (error) {
189
+ await run(() => files.cancelUpload(roomId, file.fileId));
190
+ throw error;
117
191
  }
118
192
  return toAttachment(file);
119
193
  }
@@ -130,16 +204,24 @@ export class RoomService {
130
204
  return (await files.attachedFiles(roomId)).map((file) => ({ ...toAttachment(file), uploaderId: file.uploaderId, createdAt: file.createdAt, eventId: file.eventId }));
131
205
  }
132
206
  async downloadFile(roomId, credential, fileId) {
207
+ const { file, body } = await this.downloadFileStream(roomId, credential, fileId);
208
+ return { file, bytes: new Uint8Array(await new Response(body).arrayBuffer()) };
209
+ }
210
+ async downloadFileStream(roomId, credential, fileId) {
133
211
  const files = this.fileStore();
134
212
  const actor = authenticate(await this.view(roomId), await hashCredential(credential), this.now());
135
213
  const file = await files.file(roomId, fileId);
136
214
  // An upload not yet sent is visible to its uploader only, and says no more than a missing file does.
137
215
  const visible = file && (file.eventId !== null || (actor.kind === 'participant' && actor.participant.participantId === file.uploaderId));
138
- const bytes = visible ? await files.fileBytes(roomId, fileId) : null;
139
- if (!file || !bytes) {
216
+ let body = visible && files.fileStream ? await files.fileStream(roomId, fileId) : null;
217
+ if (visible && !files.fileStream) {
218
+ const bytes = await files.fileBytes(roomId, fileId);
219
+ body = bytes ? new Response(bytes).body : null;
220
+ }
221
+ if (!file || !body) {
140
222
  throw new ProtocolError('invalid_request', 'no such file in this room');
141
223
  }
142
- return { file, bytes };
224
+ return { file, body };
143
225
  }
144
226
  /** Replaces what the client said about each file with the relay's own record of it. */
145
227
  async attachments(view, actor, claimed) {
@@ -186,6 +268,9 @@ export class RoomService {
186
268
  return { roomId: created.room.roomId, participantId: created.participant.participantId, invite, snapshot: this.snapshotOf(view) };
187
269
  }
188
270
  async mintInvite(roomId, credential, role, reusable = true, expiresAt, defaultName) {
271
+ return retryRoomConflict(() => this.mintInviteAttempt(roomId, credential, role, reusable, expiresAt, defaultName));
272
+ }
273
+ async mintInviteAttempt(roomId, credential, role, reusable, expiresAt, defaultName) {
189
274
  const view = await this.view(roomId);
190
275
  const actor = authenticate(view, await hashCredential(credential), this.now());
191
276
  assertPermitted(view, actor, 'invite');
@@ -215,7 +300,7 @@ export class RoomService {
215
300
  boundCredentialHash: null,
216
301
  redeemedParticipantId: null,
217
302
  recoverableUntil: deadline === null ? null : deadline + (deadline - now)
218
- });
303
+ }, view.room.stateRevision ?? 0);
219
304
  return { code, expiresAt: deadline, reusable };
220
305
  }
221
306
  /** A code no invite has, claimed in the directory when there is one. */
@@ -244,36 +329,51 @@ export class RoomService {
244
329
  * The room checks again that the listing stands and that they see the room.
245
330
  */
246
331
  async contactInvite(roomId, listing, handle) {
247
- const view = await this.view(roomId);
248
- const now = this.now();
249
- const agent = findParticipant(view, listing.participantId);
250
- if (!agent || !(await this.listable(roomId, listing.participantId)) || !(await this.sees(view.room, handle))) {
251
- throw new ProtocolError('invalid_request', 'no such listing');
252
- }
253
- const expiresAt = now + CONTACT_INVITE_LIFETIME_MS;
254
- const { code, digest } = await this.allocateCode(roomId, expiresAt);
255
- await this.store.putInvite({
256
- digest,
257
- roomId,
258
- role: 'member',
259
- createdAt: now,
260
- expiresAt,
261
- state: 'unused',
262
- // Sharing the agent was the owner's consent; the room was checked against this member just now.
263
- issuedBy: 'owner',
264
- origin: { directory: true, handle, listingId: listing.listingId, contact: listing.contact },
265
- reusable: false,
266
- boundAttemptId: null,
267
- boundCredentialHash: null,
268
- redeemedParticipantId: null,
269
- recoverableUntil: expiresAt + CONTACT_INVITE_LIFETIME_MS
332
+ return retryRoomConflict(async () => {
333
+ const view = await this.view(roomId);
334
+ const now = this.now();
335
+ const agent = findParticipant(view, listing.participantId);
336
+ if (!agent || !(await this.listable(roomId, listing.participantId)) || !(await this.sees(view.room, handle))) {
337
+ throw new ProtocolError('invalid_request', 'no such listing');
338
+ }
339
+ const expiresAt = now + CONTACT_INVITE_LIFETIME_MS;
340
+ const { code, digest } = await this.allocateCode(roomId, expiresAt);
341
+ await this.store.putInvite({
342
+ digest,
343
+ roomId,
344
+ role: 'member',
345
+ createdAt: now,
346
+ expiresAt,
347
+ state: 'unused',
348
+ // Sharing the agent was the owner's consent; the room was checked against this member just now.
349
+ issuedBy: 'owner',
350
+ origin: { directory: true, handle, listingId: listing.listingId, contact: listing.contact },
351
+ reusable: false,
352
+ boundAttemptId: null,
353
+ boundCredentialHash: null,
354
+ redeemedParticipantId: null,
355
+ recoverableUntil: expiresAt + CONTACT_INVITE_LIFETIME_MS
356
+ }, view.room.stateRevision ?? 0);
357
+ return { code, expiresAt, roomId, agentId: agent.participantId, agentName: agent.displayName, roomName: view.room.name, owner: listing.owner, contact: listing.contact, connection: listing.connection };
270
358
  });
271
- return { code, expiresAt, roomId, agentId: agent.participantId, agentName: agent.displayName, roomName: view.room.name, owner: listing.owner, contact: listing.contact, connection: listing.connection };
272
359
  }
273
360
  /** Whether a member reaches this listing: it still stands, and they see its room. */
274
361
  async listingVisible(listing, handle) {
275
- const view = await this.store.loadRoom(listing.roomId);
276
- return Boolean(view) && (await this.listable(listing.roomId, listing.participantId)) && this.sees(view.room, handle);
362
+ const view = await this.store.loadRoom(listing.roomId, { handovers: false });
363
+ return Boolean(view && this.listableParticipant(view, listing.participantId) && await this.sees(view.room, handle));
364
+ }
365
+ async visibleListings(listings, handle) {
366
+ const rooms = new Map();
367
+ const visible = await Promise.all(listings.map(async (listing) => {
368
+ let loading = rooms.get(listing.roomId);
369
+ if (!loading) {
370
+ loading = this.store.loadRoom(listing.roomId, { handovers: false });
371
+ rooms.set(listing.roomId, loading);
372
+ }
373
+ const view = await loading;
374
+ return Boolean(view && this.listableParticipant(view, listing.participantId) && await this.sees(view.room, handle));
375
+ }));
376
+ return listings.filter((_, index) => visible[index]);
277
377
  }
278
378
  /**
279
379
  * Redemption binds the invite to one attempt before membership exists, so a
@@ -285,6 +385,9 @@ export class RoomService {
285
385
  return (await this.store.inviteByDigest(digest))?.roomId ?? null;
286
386
  }
287
387
  async redeemInvite(input) {
388
+ return retryRoomConflict(() => this.redeemInviteAttempt(input));
389
+ }
390
+ async redeemInviteAttempt(input) {
288
391
  const normalized = normalizeInviteCode(input.code);
289
392
  if (!normalized) {
290
393
  throw new ProtocolError('invite_unknown', 'that invite code is not well formed');
@@ -295,7 +398,7 @@ export class RoomService {
295
398
  throw new ProtocolError('invite_unknown', 'that invite code is not valid');
296
399
  }
297
400
  const now = this.now();
298
- const view = await this.view(invite.roomId);
401
+ let view = await this.view(invite.roomId);
299
402
  assertRoomJoinable(view, now);
300
403
  let expectedOccupantId = null;
301
404
  const credentialHash = await hashCredential(input.participantCredential);
@@ -340,15 +443,24 @@ export class RoomService {
340
443
  if (invite.issuedBy === 'member' && !(await this.sees(view.room, input.member))) {
341
444
  throw new ProtocolError('unauthorized', NOT_VISIBLE);
342
445
  }
343
- const reserved = await this.store.reserveInvite(digest, input.attemptId, credentialHash, expectedOccupantId);
446
+ const reserved = await this.store.reserveInvite(digest, input.attemptId, credentialHash, expectedOccupantId, view.room.stateRevision ?? 0);
344
447
  if (!reserved) {
345
448
  throw new ProtocolError('invite_already_redeemed', 'that invite code is being redeemed by another attempt');
346
449
  }
450
+ // Reservation is a separate durable stage. Recheck admission against the revision it left.
451
+ view = await this.view(invite.roomId);
452
+ assertRoomJoinable(view, this.now());
453
+ if (roomType(view.room.policy) === 'personal' && reserved.issuedBy !== 'owner' && reserved.issuedBy !== 'admin') {
454
+ throw new ProtocolError('unauthorized', 'this is a personal room: only codes from its owner admit anyone');
455
+ }
456
+ if (reserved.issuedBy === 'member' && !(await this.sees(view.room, input.member))) {
457
+ throw new ProtocolError('unauthorized', NOT_VISIBLE);
458
+ }
347
459
  // Recovery path: the previous attempt created membership but lost its response.
348
460
  const existing = await this.store.participantByCredential(invite.roomId, credentialHash);
349
461
  if (existing) {
350
- assertActiveMember(authenticate(view, credentialHash, now));
351
- await this.store.completeInvite(digest, existing.participantId);
462
+ assertActiveMember(authenticate(await this.view(invite.roomId), credentialHash, now));
463
+ await this.store.completeInvite(digest, existing.participantId, input.attemptId);
352
464
  return { roomId: invite.roomId, participantId: existing.participantId, role: existing.role, replayed: true, snapshot: this.snapshotOf(await this.view(invite.roomId)) };
353
465
  }
354
466
  const previousOccupant = view.participants.find((participant) => participant.participantId === invite.redeemedParticipantId);
@@ -374,11 +486,14 @@ export class RoomService {
374
486
  joined.participant.permissions = refused;
375
487
  }
376
488
  await this.store.apply(joined.mutation, null);
377
- await this.store.completeInvite(digest, joined.participant.participantId);
489
+ await this.store.completeInvite(digest, joined.participant.participantId, input.attemptId);
378
490
  return { roomId: invite.roomId, participantId: joined.participant.participantId, role: admissionRole, replayed: false, snapshot: this.snapshotOf(await this.view(invite.roomId)) };
379
491
  }
380
492
  async send(roomId, credential, request) {
381
- const view = await this.view(roomId);
493
+ return retryRoomConflict(() => this.sendAttempt(roomId, credential, request));
494
+ }
495
+ async sendAttempt(roomId, credential, request) {
496
+ const view = await this.view(roomId, request.type.startsWith('handover.'));
382
497
  // One clock reading for the whole operation: the request record and the event it
383
498
  // produces must carry the same time, or a client comparing them sees one as stale.
384
499
  const now = this.now();
@@ -576,8 +691,7 @@ export class RoomService {
576
691
  throw new ProtocolError('invalid_request', 'responseStage requires replyTo');
577
692
  }
578
693
  if (request.type === 'message' && recipients.length && !request.replyTo) {
579
- const pending = await this.store.messageRequests(roomId, 0, 1000);
580
- if (pending.requests.length + recipients.length > 1000) {
694
+ if ((await this.store.pendingRequestCount(roomId)) + recipients.length > 1000) {
581
695
  throw new ProtocolError('quota_exceeded', 'answer or resolve pending requests before creating more');
582
696
  }
583
697
  }
@@ -648,38 +762,43 @@ export class RoomService {
648
762
  return { event: mutation.appendEvent, deduplicated: false };
649
763
  }
650
764
  async acknowledgeMessage(roomId, credential, eventId) {
651
- const view = await this.view(roomId);
652
- const now = this.now();
653
- const actor = authenticate(view, await hashCredential(credential), now);
654
- const participant = assertCanAcknowledge(actor);
655
- const target = await this.turns.resolve(roomId, eventId, participant.participantId);
656
- if (target?.to === participant.participantId && target.receivedAt !== null) {
657
- return this.publicRequest(target);
658
- }
659
- if (this.receiptMode === 'watermark') {
660
- assertRoomWritable(view, now);
661
- await this.plainReceipt(view, participant, eventId, now);
662
- const received = await this.turns.resolve(roomId, eventId, participant.participantId);
663
- return received ? this.publicRequest(received) : null;
664
- }
665
- const root = target?.conversationId ?? eventId;
666
- const key = `receipt-${root}-${participant.participantId}`;
667
- const saved = await this.store.idempotencyRecord(roomId, key);
668
- const digest = stableStringify({ type: 'message.received', payload: { eventId: root }, recipientId: null, replyTo: null });
669
- if (saved && saved.requestDigest !== digest) {
670
- throw new ProtocolError('idempotency_conflict', 'the receipt key was used with different content');
671
- }
672
- if (!saved) {
673
- await this.send(roomId, credential, { type: 'message.received', payload: { eventId: root }, idempotencyKey: key });
674
- }
675
- const updated = await this.turns.resolve(roomId, eventId, participant.participantId);
676
- return updated ? this.publicRequest(updated) : null;
765
+ return retryRoomConflict(async () => {
766
+ const view = await this.view(roomId);
767
+ const now = this.now();
768
+ const actor = authenticate(view, await hashCredential(credential), now);
769
+ const participant = assertCanAcknowledge(actor);
770
+ const target = await this.turns.resolve(roomId, eventId, participant.participantId);
771
+ if (target?.to === participant.participantId && target.receivedAt !== null) {
772
+ return this.publicRequest(target);
773
+ }
774
+ if (this.receiptMode === 'watermark') {
775
+ assertRoomWritable(view, now);
776
+ await this.plainReceipt(view, participant, eventId, now);
777
+ const received = await this.turns.resolve(roomId, eventId, participant.participantId);
778
+ return received ? this.publicRequest(received) : null;
779
+ }
780
+ const root = target?.conversationId ?? eventId;
781
+ const key = `receipt-${root}-${participant.participantId}`;
782
+ const saved = await this.store.idempotencyRecord(roomId, key);
783
+ const digest = stableStringify({ type: 'message.received', payload: { eventId: root }, recipientId: null, replyTo: null });
784
+ if (saved && saved.requestDigest !== digest) {
785
+ throw new ProtocolError('idempotency_conflict', 'the receipt key was used with different content');
786
+ }
787
+ if (!saved) {
788
+ await this.send(roomId, credential, { type: 'message.received', payload: { eventId: root }, idempotencyKey: key });
789
+ }
790
+ const updated = await this.turns.resolve(roomId, eventId, participant.participantId);
791
+ return updated ? this.publicRequest(updated) : null;
792
+ });
677
793
  }
678
794
  /**
679
795
  * `POST /v1/rooms/:id/receipts`: this participant has received every event up to `throughSeq`.
680
796
  * Moves their watermark and marks their requests up to there received, with no event.
681
797
  */
682
798
  async receipt(roomId, credential, throughSeq) {
799
+ return retryRoomConflict(() => this.receiptAttempt(roomId, credential, throughSeq));
800
+ }
801
+ async receiptAttempt(roomId, credential, throughSeq) {
683
802
  if (this.receiptMode === 'events') {
684
803
  throw new ProtocolError('unsupported_capability', 'this relay keeps receipts as events; acknowledge each message instead');
685
804
  }
@@ -717,35 +836,11 @@ export class RoomService {
717
836
  if (room === view.room) {
718
837
  return room;
719
838
  }
720
- const requests = await this.receivedThrough(room.roomId, participantId, room.receipts[participantId].throughSeq, now);
721
- await this.store.updateTurns(room, requests, view.room.turnRevision ?? 0);
839
+ // The store records a compact timestamped range atomically with this mark. Request reads
840
+ // project their first receipt from it, including completed replies and arbitrarily old backlogs.
841
+ await this.store.updateTurns(room, [], view.room.turnRevision ?? 0);
722
842
  return room;
723
843
  }
724
- /** This participant's requests whose message is at or before `throughSeq` and not yet received, marked received at `now`. */
725
- async receivedThrough(roomId, participantId, throughSeq, now) {
726
- const page = (after) => this.store.unreceivedRequests ? this.store.unreceivedRequests(roomId, participantId, after, 200) : this.store.messageRequests(roomId, after, 200, participantId);
727
- const marked = [];
728
- let after = 0;
729
- while (marked.length < WATERMARK_REQUEST_LIMIT) {
730
- const { requests, hasMore } = await page(after);
731
- for (const request of requests) {
732
- if (request.receivedAt !== null) {
733
- continue;
734
- }
735
- // Requests are numbered in message order, so the first one past the watermark ends the search.
736
- if ((await this.messageSeqOf(roomId, request)) > throughSeq) {
737
- return marked;
738
- }
739
- marked.push({ ...request, receivedAt: now });
740
- }
741
- const last = requests.at(-1)?.seq;
742
- if (!hasMore || last === undefined || last <= after) {
743
- return marked;
744
- }
745
- after = last;
746
- }
747
- return marked;
748
- }
749
844
  /** The transcript seq of a request's message; 0 when that message is no longer retained, which puts it behind any watermark. */
750
845
  async messageSeqOf(roomId, request) {
751
846
  return request.messageSeq ?? (await this.store.eventById(roomId, request.conversationId ?? request.eventId))?.seq ?? 0;
@@ -757,7 +852,7 @@ export class RoomService {
757
852
  return result;
758
853
  }
759
854
  async requests(roomId, credential, after = 0, limit = 100, recipientId, supportsTurns = true) {
760
- authenticate(await this.view(roomId), await hashCredential(credential), this.now());
855
+ await this.reader(roomId, credential);
761
856
  if (supportsTurns) {
762
857
  const page = await this.store.messageRequests(roomId, after, limit, recipientId);
763
858
  return { ...page, requests: page.requests.map((request) => this.publicRequest(request)) };
@@ -766,7 +861,7 @@ export class RoomService {
766
861
  // hasMore=true that gives their cursor no way to advance.
767
862
  let cursor = after;
768
863
  const visible = [];
769
- for (let pageNumber = 0; pageNumber < 11; pageNumber++) {
864
+ for (let pageNumber = 0; pageNumber < 1101; pageNumber++) {
770
865
  const page = await this.store.messageRequests(roomId, cursor, Math.max(limit, 100), recipientId);
771
866
  visible.push(...page.requests.filter((request) => !request.turnRequired));
772
867
  if (visible.length >= limit || !page.hasMore) {
@@ -781,7 +876,7 @@ export class RoomService {
781
876
  throw new ProtocolError('server_unavailable', 'pending request limit exceeded');
782
877
  }
783
878
  async request(roomId, credential, eventId) {
784
- const actor = authenticate(await this.view(roomId), await hashCredential(credential), this.now());
879
+ const { actor } = await this.reader(roomId, credential);
785
880
  const request = await this.turns.resolve(roomId, eventId, actor.kind === 'participant' ? actor.participant.participantId : undefined);
786
881
  if (!request) {
787
882
  throw new ProtocolError('invalid_request', 'no such addressed delivery; use a recipient delivery ID for a group message');
@@ -877,34 +972,46 @@ export class RoomService {
877
972
  throw new ProtocolError('server_unavailable', 'request resolution could not be committed');
878
973
  }
879
974
  async setLocked(roomId, credential, locked) {
880
- return this.applyOne(setLocked(await this.view(roomId), await hashCredential(credential), locked, this.ctx()));
975
+ return retryRoomConflict(async () => {
976
+ return this.applyOne(setLocked(await this.view(roomId), await hashCredential(credential), locked, this.ctx()));
977
+ });
881
978
  }
882
979
  async setMuted(roomId, credential, participantId, muted) {
883
- return this.applyOne(setMuted(await this.view(roomId), await hashCredential(credential), participantId, muted, this.ctx()));
980
+ return retryRoomConflict(async () => {
981
+ return this.applyOne(setMuted(await this.view(roomId), await hashCredential(credential), participantId, muted, this.ctx()));
982
+ });
884
983
  }
885
984
  async setRole(roomId, credential, participantId, role) {
886
- const mutation = setParticipantRole(await this.view(roomId), await hashCredential(credential), participantId, role, this.ctx());
887
- if (mutation) {
888
- await this.applyOne(mutation);
889
- }
890
- return this.snapshotOf(await this.view(roomId));
985
+ return retryRoomConflict(async () => {
986
+ const mutation = setParticipantRole(await this.view(roomId), await hashCredential(credential), participantId, role, this.ctx());
987
+ if (mutation) {
988
+ await this.applyOne(mutation);
989
+ }
990
+ return this.snapshotOf(await this.view(roomId));
991
+ });
891
992
  }
892
993
  async setRoomPermissions(roomId, credential, change) {
893
- const mutation = setRoomPermissions(await this.view(roomId), await hashCredential(credential), change, this.ctx());
894
- if (mutation) {
895
- await this.applyOne(mutation);
896
- }
897
- return this.snapshotOf(await this.view(roomId));
994
+ return retryRoomConflict(async () => {
995
+ const mutation = setRoomPermissions(await this.view(roomId), await hashCredential(credential), change, this.ctx());
996
+ if (mutation) {
997
+ await this.applyOne(mutation);
998
+ }
999
+ return this.snapshotOf(await this.view(roomId));
1000
+ });
898
1001
  }
899
1002
  async setParticipantPermissions(roomId, credential, participantId, change) {
900
- const mutation = setParticipantPermissions(await this.view(roomId), await hashCredential(credential), participantId, change, this.ctx());
901
- if (mutation) {
902
- await this.applyOne(mutation);
903
- }
904
- return this.snapshotOf(await this.view(roomId));
1003
+ return retryRoomConflict(async () => {
1004
+ const mutation = setParticipantPermissions(await this.view(roomId), await hashCredential(credential), participantId, change, this.ctx());
1005
+ if (mutation) {
1006
+ await this.applyOne(mutation);
1007
+ }
1008
+ return this.snapshotOf(await this.view(roomId));
1009
+ });
905
1010
  }
906
1011
  async control(roomId, credential, targetParticipantId, paused) {
907
- return this.applyOne(requestControl(await this.view(roomId), await hashCredential(credential), targetParticipantId, paused, this.ctx()));
1012
+ return retryRoomConflict(async () => {
1013
+ return this.applyOne(requestControl(await this.view(roomId), await hashCredential(credential), targetParticipantId, paused, this.ctx()));
1014
+ });
908
1015
  }
909
1016
  /**
910
1017
  * Stops one agent: holds its later work like a pause, then fences the turn it is
@@ -912,55 +1019,75 @@ export class RoomService {
912
1019
  * actually stop. Other agents and the rest of a group round are not affected.
913
1020
  */
914
1021
  async interrupt(roomId, credential, targetParticipantId) {
915
- const event = await this.applyOne(requestControl(await this.view(roomId), await hashCredential(credential), targetParticipantId, true, this.ctx(), true));
1022
+ const event = await retryRoomConflict(async () => this.applyOne(requestControl(await this.view(roomId), await hashCredential(credential), targetParticipantId, true, this.ctx(), true)));
916
1023
  const fenced = await this.turns.fenceRunning(roomId, credential, targetParticipantId);
917
1024
  return { event, fenced };
918
1025
  }
919
1026
  async revoke(roomId, credential, targetParticipantId) {
920
- return this.applyOne(revokeParticipant(await this.view(roomId), await hashCredential(credential), targetParticipantId, this.ctx()));
1027
+ return retryRoomConflict(async () => {
1028
+ return this.applyOne(revokeParticipant(await this.view(roomId), await hashCredential(credential), targetParticipantId, this.ctx()));
1029
+ });
921
1030
  }
922
1031
  async leave(roomId, credential) {
923
- return this.applyOne(leaveRoom(await this.view(roomId), await hashCredential(credential), this.ctx()));
1032
+ return retryRoomConflict(async () => {
1033
+ return this.applyOne(leaveRoom(await this.view(roomId), await hashCredential(credential), this.ctx()));
1034
+ });
924
1035
  }
925
1036
  async rejoin(roomId, credential) {
926
- const view = await this.view(roomId);
927
- const hash = await hashCredential(credential);
928
- const actor = authenticate(view, hash, this.now());
929
- if (actor.kind !== 'participant') {
930
- throw new ProtocolError('unauthorized', 'rejoining requires the saved participant credential');
931
- }
932
- if (actor.participant.leftAt === null) {
933
- return this.snapshotOf(view);
934
- }
935
- await this.applyOne(rejoinRoom(view, hash, this.ctx()));
936
- return this.snapshot(roomId, credential);
1037
+ return retryRoomConflict(async () => {
1038
+ const view = await this.view(roomId);
1039
+ const hash = await hashCredential(credential);
1040
+ const actor = authenticate(view, hash, this.now());
1041
+ if (actor.kind !== 'participant') {
1042
+ throw new ProtocolError('unauthorized', 'rejoining requires the saved participant credential');
1043
+ }
1044
+ if (actor.participant.leftAt === null) {
1045
+ return this.snapshotOf(view);
1046
+ }
1047
+ await this.applyOne(rejoinRoom(view, hash, this.ctx()));
1048
+ return this.snapshot(roomId, credential);
1049
+ });
937
1050
  }
938
1051
  async renameSelf(roomId, credential, input) {
939
- const mutation = renameSelf(await this.view(roomId), await hashCredential(credential), input, this.ctx());
940
- if (mutation) {
941
- await this.applyOne(mutation);
942
- }
943
- return this.snapshot(roomId, credential);
1052
+ return retryRoomConflict(async () => {
1053
+ const mutation = renameSelf(await this.view(roomId), await hashCredential(credential), input, this.ctx());
1054
+ if (mutation) {
1055
+ await this.applyOne(mutation);
1056
+ }
1057
+ return this.snapshot(roomId, credential);
1058
+ });
944
1059
  }
945
1060
  async rename(roomId, credential, name) {
946
- return this.applyOne(renameRoom(await this.view(roomId), await hashCredential(credential), name, this.ctx()));
1061
+ return retryRoomConflict(async () => {
1062
+ return this.applyOne(renameRoom(await this.view(roomId), await hashCredential(credential), name, this.ctx()));
1063
+ });
947
1064
  }
948
1065
  async setJoinPolicy(roomId, credential, joinPolicy) {
949
- return this.applyOne(setJoinPolicy(await this.view(roomId), await hashCredential(credential), joinPolicy, this.ctx()));
1066
+ return retryRoomConflict(async () => {
1067
+ return this.applyOne(setJoinPolicy(await this.view(roomId), await hashCredential(credential), joinPolicy, this.ctx()));
1068
+ });
950
1069
  }
951
1070
  async setInviteRole(roomId, credential, inviteRole) {
952
- return this.applyOne(setInviteRole(await this.view(roomId), await hashCredential(credential), inviteRole, this.ctx()));
1071
+ return retryRoomConflict(async () => {
1072
+ return this.applyOne(setInviteRole(await this.view(roomId), await hashCredential(credential), inviteRole, this.ctx()));
1073
+ });
953
1074
  }
954
1075
  async setLocalJoin(roomId, credential, localJoin) {
955
- return this.applyOne(setLocalJoin(await this.view(roomId), await hashCredential(credential), localJoin, this.ctx()));
1076
+ return retryRoomConflict(async () => {
1077
+ return this.applyOne(setLocalJoin(await this.view(roomId), await hashCredential(credential), localJoin, this.ctx()));
1078
+ });
956
1079
  }
957
1080
  async setRoomType(roomId, credential, type) {
958
- return this.applyOne(setRoomType(await this.view(roomId), await hashCredential(credential), type, this.ctx()));
1081
+ return retryRoomConflict(async () => {
1082
+ return this.applyOne(setRoomType(await this.view(roomId), await hashCredential(credential), type, this.ctx()));
1083
+ });
959
1084
  }
960
1085
  /** Sets who sees the room; null when it already was. Owner or admin, on a room with an owner. */
961
1086
  async setVisibility(roomId, credential, visibility) {
962
- const mutation = setRoomVisibility(await this.view(roomId), await hashCredential(credential), visibility, this.ctx());
963
- return mutation ? this.applyOne(mutation) : null;
1087
+ return retryRoomConflict(async () => {
1088
+ const mutation = setRoomVisibility(await this.view(roomId), await hashCredential(credential), visibility, this.ctx());
1089
+ return mutation ? this.applyOne(mutation) : null;
1090
+ });
964
1091
  }
965
1092
  /** Whether the member with this handle sees the room, after its owner's default. */
966
1093
  async sees(room, handle) {
@@ -968,12 +1095,18 @@ export class RoomService {
968
1095
  }
969
1096
  /** Adds "invited @handle" or "withdrew the invitation" to the transcript. For relays that hold accounts; there is no HTTP route for it. */
970
1097
  async recordInvitation(roomId, credential, change) {
971
- return this.applyOne(recordInvitation(await this.view(roomId), await hashCredential(credential), change, this.ctx()));
1098
+ return retryRoomConflict(async () => {
1099
+ return this.applyOne(recordInvitation(await this.view(roomId), await hashCredential(credential), change, this.ctx()));
1100
+ });
972
1101
  }
973
1102
  /** Guest entry. Knowing the room id is the entire claim, so the room must allow it. */
974
1103
  async joinAsGuest(roomId, input) {
975
1104
  return this.joinWithoutInvite(roomId, input, joinAsGuest);
976
1105
  }
1106
+ /** A trusted account layer has already authorized this identity and role; ordinary room admission and retry rules still apply. */
1107
+ async joinAuthorized(roomId, input, role) {
1108
+ return this.joinWithoutInvite(roomId, input, (view, identity, context) => joinRoom(view, { ...identity, role }, context));
1109
+ }
977
1110
  /**
978
1111
  * Member entry by name from the relay's local network or tailnet. Only call this
979
1112
  * once the transport has established where the request came from; the room
@@ -988,34 +1121,40 @@ export class RoomService {
988
1121
  }
989
1122
  /** A retry with the same credential gets its original membership back instead of a second one. */
990
1123
  async joinWithoutInvite(roomId, input, admit) {
991
- const view = await this.view(roomId);
992
- assertRoomJoinable(view, this.now());
993
- const credentialHash = await hashCredential(input.participantCredential);
994
- const existing = await this.store.participantByCredential(roomId, credentialHash);
995
- if (existing) {
996
- authenticate(view, credentialHash, this.now());
997
- return { roomId, participantId: existing.participantId, role: existing.role, replayed: true, snapshot: this.snapshotOf(view) };
998
- }
999
- const joined = admit(view, {
1000
- credentialHash,
1001
- displayName: input.displayName,
1002
- kind: input.kind,
1003
- nameSource: input.nameSource,
1004
- sessionId: input.sessionId ?? null,
1005
- capabilities: input.capabilities ?? null,
1006
- ...(input.member ? { member: input.member } : {})
1007
- }, this.ctx());
1008
- await this.store.apply(joined.mutation, null);
1009
- return { roomId, participantId: joined.participant.participantId, role: joined.participant.role, replayed: false, snapshot: this.snapshotOf(await this.view(roomId)) };
1124
+ return retryRoomConflict(async () => {
1125
+ const view = await this.view(roomId);
1126
+ assertRoomJoinable(view, this.now());
1127
+ const credentialHash = await hashCredential(input.participantCredential);
1128
+ const existing = await this.store.participantByCredential(roomId, credentialHash);
1129
+ if (existing) {
1130
+ assertActiveMember(authenticate(view, credentialHash, this.now()));
1131
+ return { roomId, participantId: existing.participantId, role: existing.role, replayed: true, snapshot: this.snapshotOf(view) };
1132
+ }
1133
+ const joined = admit(view, {
1134
+ credentialHash,
1135
+ displayName: input.displayName,
1136
+ kind: input.kind,
1137
+ nameSource: input.nameSource,
1138
+ sessionId: input.sessionId ?? null,
1139
+ capabilities: input.capabilities ?? null,
1140
+ ...(input.member ? { member: input.member } : {})
1141
+ }, this.ctx());
1142
+ await this.store.apply(joined.mutation, null);
1143
+ return { roomId, participantId: joined.participant.participantId, role: joined.participant.role, replayed: false, snapshot: this.snapshotOf(await this.view(roomId)) };
1144
+ });
1010
1145
  }
1011
1146
  async setExpiry(roomId, credential, expiresAt) {
1012
- return this.applyOne(setExpiry(await this.view(roomId), await hashCredential(credential), expiresAt, this.ctx()));
1147
+ return retryRoomConflict(async () => {
1148
+ return this.applyOne(setExpiry(await this.view(roomId), await hashCredential(credential), expiresAt, this.ctx()));
1149
+ });
1013
1150
  }
1014
1151
  async setAutoClose(roomId, credential, autoClose) {
1015
- const event = await this.applyOne(setAutoClose(await this.view(roomId), await hashCredential(credential), autoClose, this.ctx()));
1016
- // A policy that is already overdue closes the room now rather than at the next request.
1017
- await this.store.loadRoom(roomId);
1018
- return event;
1152
+ return retryRoomConflict(async () => {
1153
+ const event = await this.applyOne(setAutoClose(await this.view(roomId), await hashCredential(credential), autoClose, this.ctx()));
1154
+ // A policy that is already overdue closes the room now rather than at the next request.
1155
+ await this.store.loadRoom(roomId);
1156
+ return event;
1157
+ });
1019
1158
  }
1020
1159
  /**
1021
1160
  * Closes rooms whose deadline has passed, in a bounded batch. Returns how many
@@ -1029,20 +1168,40 @@ export class RoomService {
1029
1168
  closed++;
1030
1169
  }
1031
1170
  }
1032
- return { closed, more: due.length === limit, nextAt: await this.store.nextAutoCloseAt() };
1171
+ return { closed, more: due.length === limit, nextAt: await this.store.nextAutoCloseAt(this.now()) };
1033
1172
  }
1034
1173
  async close(roomId, credential) {
1035
- return this.applyOne(closeRoom(await this.view(roomId), await hashCredential(credential), this.ctx()));
1174
+ return retryRoomConflict(async () => {
1175
+ return this.applyOne(closeRoom(await this.view(roomId), await hashCredential(credential), this.ctx()));
1176
+ });
1036
1177
  }
1037
- async read(roomId, credential, after, limit, receiptsAfter) {
1038
- const view = await this.view(roomId);
1039
- authenticate(view, await hashCredential(credential), this.now());
1178
+ async read(roomId, credential, after, limit, receiptsAfter, sync) {
1179
+ return retryRoomConflict(() => this.readAttempt(roomId, credential, after, limit, receiptsAfter, sync));
1180
+ }
1181
+ async readAttempt(roomId, credential, after, limit, receiptsAfter, sync) {
1182
+ const { head: view } = await this.reader(roomId, credential);
1040
1183
  if (after > 0 && after < view.earliestSeq - 1) {
1041
1184
  throw new ProtocolError('cursor_gap', 'history before this cursor is no longer retained', { earliestAvailableSeq: view.earliestSeq });
1042
1185
  }
1043
- const page = await this.store.readEvents(roomId, after, limit);
1186
+ const page = await this.store.readEvents(roomId, after, limit, view.room.nextSeq - 1);
1044
1187
  const events = await Promise.all(page.events.map((event) => this.restoreAudience(event)));
1045
- return { events, earliestSeq: view.earliestSeq, latestSeq: view.room.nextSeq - 1, hasMore: page.hasMore, ...(receiptsAfter !== undefined && this.receiptMode !== 'events' ? receiptsSince(view.room, receiptsAfter) : {}) };
1188
+ const metadata = {};
1189
+ if (sync) {
1190
+ metadata.stateRevision = view.room.stateRevision ?? 0;
1191
+ metadata.requestRevision = view.room.requestRevision ?? 0;
1192
+ metadata.snapshotRevision = view.room.snapshotRevision ?? 0;
1193
+ metadata.turnRevision = view.room.turnRevision ?? 0;
1194
+ metadata.autoCloseAt = view.room.autoCloseAt ?? null;
1195
+ metadata.lastMessageAt = view.room.lastMessageAt ?? null;
1196
+ if (sync.snapshotAfter === undefined || sync.snapshotAfter !== metadata.snapshotRevision) {
1197
+ metadata.snapshot = this.snapshotOf(await this.view(roomId));
1198
+ }
1199
+ const current = await this.store.roomHead(roomId);
1200
+ if (!current || (current.room.stateRevision ?? 0) !== metadata.stateRevision || current.earliestSeq !== view.earliestSeq) {
1201
+ throw new ProtocolError('turn_conflict', 'the room changed during synchronization; retry');
1202
+ }
1203
+ }
1204
+ return { events, earliestSeq: view.earliestSeq, latestSeq: view.room.nextSeq - 1, hasMore: page.hasMore, ...(receiptsAfter !== undefined && this.receiptMode !== 'events' ? receiptsSince(view.room, receiptsAfter) : {}), ...metadata };
1046
1205
  }
1047
1206
  /** Older group events kept the all flag only in their canonical idempotency request. */
1048
1207
  async restoreAudience(event) {
@@ -1067,22 +1226,41 @@ export class RoomService {
1067
1226
  authenticate(view, await hashCredential(credential), this.now());
1068
1227
  return this.snapshotOf(view);
1069
1228
  }
1229
+ async connection(roomId, credential) {
1230
+ const view = await this.view(roomId);
1231
+ const participant = assertActiveMember(authenticate(view, await hashCredential(credential), this.now()));
1232
+ assertRoomWritable(view, this.now());
1233
+ return { snapshot: this.snapshotOf(view), participantId: participant.participantId };
1234
+ }
1235
+ /** Only a transport's authenticated ticket/socket attachment may supply this participant identity. */
1236
+ async connectedSnapshot(roomId, participantId) {
1237
+ const snapshot = await this.connectionAudience(roomId);
1238
+ assertConnectedParticipant(snapshot, participantId);
1239
+ return snapshot;
1240
+ }
1241
+ /** Trusted transport projection, loaded once for a broadcast; each attachment still needs participant authorization. */
1242
+ async connectionAudience(roomId) {
1243
+ const view = await this.view(roomId);
1244
+ assertRoomReadable(view, this.now());
1245
+ return this.snapshotOf(view);
1246
+ }
1070
1247
  /** Returns the history the caller's room still retains, and says so when retention already dropped some. */
1071
1248
  async export(roomId, credential) {
1249
+ const source = await this.exportSource(roomId, credential);
1250
+ const events = [];
1251
+ for await (const event of source.events) {
1252
+ events.push(event);
1253
+ }
1254
+ return { ...source.metadata, events };
1255
+ }
1256
+ async exportStream(roomId, credential, format = 'json') {
1257
+ return exportResponse(await this.exportSource(roomId, credential), format);
1258
+ }
1259
+ async exportSource(roomId, credential) {
1072
1260
  const view = await this.view(roomId);
1073
1261
  authenticate(view, await hashCredential(credential), this.now());
1074
- const collected = [];
1075
- let after = 0;
1076
- for (;;) {
1077
- const page = await this.store.readEvents(roomId, after, 500);
1078
- collected.push(...page.events);
1079
- if (!page.hasMore || page.events.length === 0) {
1080
- break;
1081
- }
1082
- after = page.events.at(-1).seq;
1083
- }
1084
1262
  const files = this.store.files ? { files: await this.roomFiles(this.store.files, roomId) } : {};
1085
- return { room: this.snapshotOf(view), events: collected, handovers: await this.store.handovers(roomId), exportedAt: this.now(), complete: view.earliestSeq <= 1, ...files };
1263
+ return { metadata: { room: this.snapshotOf(view), handovers: await this.store.handovers(roomId), exportedAt: this.now(), complete: view.earliestSeq <= 1, ...files }, events: retainedEvents(this.store, roomId, view.earliestSeq, view.room.nextSeq - 1) };
1086
1264
  }
1087
1265
  /**
1088
1266
  * The agent a listing shares: the caller's own participant, an agent still in this open room.
@@ -1106,25 +1284,99 @@ export class RoomService {
1106
1284
  }
1107
1285
  /** Whether a listing of this participant still stands: the room open and unexpired, the agent still in it. */
1108
1286
  async listable(roomId, participantId) {
1109
- const view = await this.store.loadRoom(roomId);
1110
- const participant = view ? findParticipant(view, participantId) : undefined;
1111
- return Boolean(view && participant && roomType(view.room.policy) !== 'personal' && view.room.lifecycle === 'open' && (view.room.expiresAt === null || this.now() < view.room.expiresAt) && isActive(participant));
1287
+ const view = await this.store.loadRoom(roomId, { handovers: false });
1288
+ return Boolean(view && this.listableParticipant(view, participantId));
1289
+ }
1290
+ listableParticipant(view, participantId) {
1291
+ const participant = findParticipant(view, participantId);
1292
+ return Boolean(participant && roomType(view.room.policy) !== 'personal' && view.room.lifecycle === 'open' && (view.room.expiresAt === null || this.now() < view.room.expiresAt) && isActive(participant));
1112
1293
  }
1113
1294
  /** Makes the room inaccessible immediately; physical cleanup may lag. */
1114
1295
  async delete(roomId, credential) {
1115
- const view = await this.view(roomId);
1116
- const actor = authenticate(view, await hashCredential(credential), this.now());
1117
- if (actor.kind !== 'controller' && actor.participant.role !== 'controller') {
1118
- throw new ProtocolError('unauthorized', 'deleting a room requires the controller credential');
1119
- }
1120
- await this.store.setLifecycle(roomId, 'deleted');
1121
- await this.store.deleteRoom(roomId);
1296
+ return retryRoomConflict(async () => {
1297
+ const view = await this.view(roomId);
1298
+ const actor = authenticate(view, await hashCredential(credential), this.now());
1299
+ assertController(actor);
1300
+ await this.store.setLifecycle(roomId, 'deleted', view.room.stateRevision ?? 0);
1301
+ await this.store.deleteRoom(roomId);
1302
+ });
1122
1303
  }
1123
1304
  async applyOne(mutation) {
1124
1305
  await this.store.apply(mutation, null);
1125
1306
  return mutation.appendEvent;
1126
1307
  }
1127
1308
  }
1309
+ function hexDigest(bytes) {
1310
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
1311
+ }
1312
+ /** Portable incremental verification for storage adapters, without retaining the binary body. */
1313
+ export async function sha256Stream(body) {
1314
+ const reader = body.getReader();
1315
+ const hash = sha256.create();
1316
+ let size = 0;
1317
+ try {
1318
+ for (;;) {
1319
+ const { done, value } = await reader.read();
1320
+ if (done) {
1321
+ return { size, sha256: hexDigest(hash.digest()) };
1322
+ }
1323
+ size += value.byteLength;
1324
+ hash.update(value);
1325
+ }
1326
+ }
1327
+ finally {
1328
+ await reader.cancel().catch(() => { });
1329
+ reader.releaseLock();
1330
+ }
1331
+ }
1332
+ /** Never retain more than one storage chunk in addition to the transport's current input chunk. */
1333
+ async function consumeUpload(upload, write) {
1334
+ const reader = upload.body.getReader();
1335
+ const abort = () => { void reader.cancel(upload.signal?.reason).catch(() => { }); };
1336
+ upload.signal?.addEventListener('abort', abort, { once: true });
1337
+ let total = 0;
1338
+ let used = 0;
1339
+ let buffer = new Uint8Array(Math.min(FILE_CHUNK_BYTES, upload.size));
1340
+ try {
1341
+ for (;;) {
1342
+ upload.signal?.throwIfAborted();
1343
+ const { done, value } = await reader.read();
1344
+ upload.signal?.throwIfAborted();
1345
+ if (done) {
1346
+ break;
1347
+ }
1348
+ total += value.byteLength;
1349
+ if (total > upload.size) {
1350
+ throw new ProtocolError('payload_too_large', 'the file is longer than its declared byte length');
1351
+ }
1352
+ for (let offset = 0; offset < value.byteLength;) {
1353
+ const length = Math.min(buffer.byteLength - used, value.byteLength - offset);
1354
+ buffer.set(value.subarray(offset, offset + length), used);
1355
+ offset += length;
1356
+ used += length;
1357
+ if (used === buffer.byteLength) {
1358
+ await write(buffer);
1359
+ buffer = new Uint8Array(FILE_CHUNK_BYTES);
1360
+ used = 0;
1361
+ }
1362
+ }
1363
+ }
1364
+ if (total !== upload.size) {
1365
+ throw new ProtocolError('invalid_request', 'the file is shorter than its declared byte length');
1366
+ }
1367
+ if (used > 0) {
1368
+ await write(buffer.subarray(0, used));
1369
+ }
1370
+ }
1371
+ catch (error) {
1372
+ await reader.cancel(error).catch(() => { });
1373
+ throw error;
1374
+ }
1375
+ finally {
1376
+ upload.signal?.removeEventListener('abort', abort);
1377
+ reader.releaseLock();
1378
+ }
1379
+ }
1128
1380
  function toAttachment(file) {
1129
1381
  return { fileId: file.fileId, name: file.name, size: file.size, mediaType: file.mediaType, sha256: file.sha256 };
1130
1382
  }