@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.
- package/README.md +1 -1
- package/dist/admission.d.ts +11 -0
- package/dist/admission.d.ts.map +1 -1
- package/dist/admission.js +18 -12
- package/dist/admission.js.map +1 -1
- package/dist/auto-close-store.d.ts +10 -8
- package/dist/auto-close-store.d.ts.map +1 -1
- package/dist/auto-close-store.js +35 -22
- package/dist/auto-close-store.js.map +1 -1
- package/dist/changes.d.ts +25 -11
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +81 -22
- package/dist/changes.js.map +1 -1
- package/dist/commit.d.ts +12 -0
- package/dist/commit.d.ts.map +1 -0
- package/dist/commit.js +33 -0
- package/dist/commit.js.map +1 -0
- package/dist/export.d.ts +12 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +72 -0
- package/dist/export.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/paging.d.ts +12 -0
- package/dist/paging.d.ts.map +1 -0
- package/dist/paging.js +32 -0
- package/dist/paging.js.map +1 -0
- package/dist/ports.d.ts +12 -5
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +12 -9
- package/dist/ports.js.map +1 -1
- package/dist/relay.d.ts +14 -4
- package/dist/relay.d.ts.map +1 -1
- package/dist/relay.js +79 -39
- package/dist/relay.js.map +1 -1
- package/dist/service.d.ts +53 -3
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +459 -207
- package/dist/service.js.map +1 -1
- package/dist/settings.d.ts +33 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +66 -0
- package/dist/settings.js.map +1 -0
- package/dist/store.d.ts +32 -13
- package/dist/store.d.ts.map +1 -1
- package/dist/turns.d.ts +1 -0
- package/dist/turns.d.ts.map +1 -1
- package/dist/turns.js +150 -117
- package/dist/turns.js.map +1 -1
- package/package.json +4 -3
- package/dist/request-backfill.d.ts +0 -3
- package/dist/request-backfill.d.ts.map +0 -1
- package/dist/request-backfill.js +0 -34
- 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 {
|
|
4
|
-
import {
|
|
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,
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
139
|
-
if (
|
|
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,
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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 <
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
963
|
-
|
|
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
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
|
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
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
}
|