@tldraw/sync-core 5.3.0-next.fc37bd96825b → 5.3.1
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/dist-cjs/index.d.ts +165 -3
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +66 -20
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +130 -52
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +27 -2
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +6 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +178 -12
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-esm/index.d.mts +165 -3
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +66 -20
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +130 -52
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +27 -2
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +6 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +178 -12
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/index.ts +3 -0
- package/src/lib/InMemorySyncStorage.ts +86 -21
- package/src/lib/RoomSession.ts +7 -2
- package/src/lib/SQLiteSyncStorage.test.ts +29 -2
- package/src/lib/SQLiteSyncStorage.ts +174 -59
- package/src/lib/TLSocketRoom.ts +61 -3
- package/src/lib/TLSyncClient.test.ts +9 -2
- package/src/lib/TLSyncClient.ts +15 -3
- package/src/lib/TLSyncRoom.ts +328 -13
- package/src/lib/TLSyncStorage.ts +15 -0
- package/src/lib/protocol.ts +17 -0
- package/src/test/TLSocketRoom.test.ts +37 -2
- package/src/test/TLSyncRoom.test.ts +25 -0
- package/src/test/TestServer.ts +14 -4
- package/src/test/objectStore.test.ts +679 -0
- package/src/test/storageContractSuite.ts +101 -0
- package/src/test/upgradeDowngrade.test.ts +144 -2
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { interval } from './interval'
|
|
|
31
31
|
import {
|
|
32
32
|
getTlsyncProtocolVersion,
|
|
33
33
|
TLIncompatibilityReason,
|
|
34
|
+
TLObjectStoreAccess,
|
|
34
35
|
TLSocketClientSentEvent,
|
|
35
36
|
TLSocketServerSentDataEvent,
|
|
36
37
|
TLSocketServerSentEvent,
|
|
@@ -120,6 +121,69 @@ export interface RoomSnapshot {
|
|
|
120
121
|
schema?: SerializedSchema
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Authorizes a single record write from a client: any per-record, per-session rule the host wants
|
|
126
|
+
* to enforce server-side — veto writes the session isn't allowed to make, or rewrite the record on
|
|
127
|
+
* create. The session's `meta` carries whatever the rule needs (identity, roles, …); for example,
|
|
128
|
+
* force a comment's `authorId` to the signed-in user so nobody can post in someone else's name.
|
|
129
|
+
*
|
|
130
|
+
* Called on **create**, **update**, and **delete** of records whose `typeName` it's registered for
|
|
131
|
+
* (see {@link TLRecordAuthorizers}), and only for client pushes — never for server-initiated writes.
|
|
132
|
+
*
|
|
133
|
+
* `prev` and `next` are always at the **server's** schema version: client writes are migrated
|
|
134
|
+
* before the authorizer runs, so guarding or stamping a field never requires knowing what older
|
|
135
|
+
* clients call it. On create, the record you return is what gets stored (after validation) — no
|
|
136
|
+
* migration runs afterwards, so stamped fields can't be clobbered.
|
|
137
|
+
*
|
|
138
|
+
* Return `null` to reject the write — it's skipped and the client self-corrects, exactly like the
|
|
139
|
+
* `objectAccess` gate. Otherwise the write is allowed, and:
|
|
140
|
+
*
|
|
141
|
+
* - on **create**, the record you return is what gets stored, so stamp identity fields here (e.g.
|
|
142
|
+
* set `authorId` from `session.meta`);
|
|
143
|
+
* - on **update** and **delete**, only allow-vs-reject is used (the returned record's contents are
|
|
144
|
+
* ignored), so use them to veto changes to immutable fields or unauthorized deletes — return
|
|
145
|
+
* `next`/`prev` to allow, `null` to reject.
|
|
146
|
+
*
|
|
147
|
+
* ⚠︎ Runs synchronously inside the commit transaction, on the same path as every document edit — it
|
|
148
|
+
* must be fast and do **no** I/O. `next`/`prev` are client-controlled records, so treat their
|
|
149
|
+
* contents as untrusted; prefer returning `null` to reject over throwing, though a throw is
|
|
150
|
+
* caught, logged, and treated as a rejection (fail closed) rather than crashing the push. For
|
|
151
|
+
* expensive, async checks (e.g. resolving mentions against who can access a file), react after the
|
|
152
|
+
* fact via `onCommittedChanges`.
|
|
153
|
+
*
|
|
154
|
+
* @public
|
|
155
|
+
*/
|
|
156
|
+
export type TLRecordAuthorizer<Rec extends UnknownRecord, SessionMeta> = (
|
|
157
|
+
args: {
|
|
158
|
+
/** The session performing the write: its host-provided `meta` (e.g. the authenticated user
|
|
159
|
+
* id) and the canvas-lane `isReadonly` state, for hosts whose object-lane policy follows
|
|
160
|
+
* canvas access. */
|
|
161
|
+
session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }
|
|
162
|
+
} & (
|
|
163
|
+
| { type: 'create'; prev: null; next: Rec }
|
|
164
|
+
| { type: 'update'; prev: Rec; next: Rec }
|
|
165
|
+
| { type: 'delete'; prev: Rec; next: null }
|
|
166
|
+
)
|
|
167
|
+
) => Rec | null
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A map from record `typeName` to a {@link TLRecordAuthorizer} for that record type. Only listed
|
|
171
|
+
* types are authorized; every other record writes through untouched, so this stays off the hot path
|
|
172
|
+
* for the vast majority of writes (shape drags etc.).
|
|
173
|
+
*
|
|
174
|
+
* Each authorizer is typed to its record — e.g. `next` in the `comment` entry is a `TLComment` — so
|
|
175
|
+
* renaming a field on the record makes the authorizer that reads it fail to compile, rather than
|
|
176
|
+
* silently stamp or guard the wrong field.
|
|
177
|
+
*
|
|
178
|
+
* Presence records are never authorized (presence is per-session and ephemeral); registering the
|
|
179
|
+
* presence typeName is a construction-time error.
|
|
180
|
+
*
|
|
181
|
+
* @public
|
|
182
|
+
*/
|
|
183
|
+
export type TLRecordAuthorizers<R extends UnknownRecord, SessionMeta> = {
|
|
184
|
+
[K in R['typeName']]?: TLRecordAuthorizer<Extract<R, { typeName: K }>, SessionMeta>
|
|
185
|
+
}
|
|
186
|
+
|
|
123
187
|
/**
|
|
124
188
|
* A collaborative workspace that manages multiple client sessions and synchronizes
|
|
125
189
|
* document changes between them. The room serves as the authoritative source for
|
|
@@ -236,22 +300,74 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
236
300
|
readonly serializedSchema: SerializedSchema
|
|
237
301
|
|
|
238
302
|
readonly documentTypes: Set<string>
|
|
303
|
+
/**
|
|
304
|
+
* Record types served by the object-store lane. Object records ride the same wire messages
|
|
305
|
+
* as document records but are gated by the session's `objectAccess` instead of `isReadonly`,
|
|
306
|
+
* and are excluded from `documentTypes` so hosts can persist them in a separate lane.
|
|
307
|
+
*/
|
|
308
|
+
readonly objectTypes: Set<string>
|
|
239
309
|
readonly presenceType: RecordType<R, any> | null
|
|
240
310
|
private log?: TLSyncLog
|
|
241
311
|
public readonly schema: StoreSchema<R, any>
|
|
242
312
|
private onPresenceChange?(): void
|
|
313
|
+
private onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
|
|
314
|
+
private readonly authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
|
|
243
315
|
private readonly sessionIdleTimeout: number
|
|
244
316
|
|
|
317
|
+
/**
|
|
318
|
+
* The authorizer registered for a record type, widened to the room's record union. Each entry in
|
|
319
|
+
* `authorizeRecord` is typed to its specific record; the cast here is the one place we can't
|
|
320
|
+
* statically correlate a runtime `typeName` with its record type, so it lives in the library
|
|
321
|
+
* rather than in every consumer.
|
|
322
|
+
*/
|
|
323
|
+
private authorizerFor(typeName: R['typeName']): TLRecordAuthorizer<R, SessionMeta> | undefined {
|
|
324
|
+
const authorize = this.authorizeRecord?.[typeName] as
|
|
325
|
+
| TLRecordAuthorizer<R, SessionMeta>
|
|
326
|
+
| undefined
|
|
327
|
+
if (!authorize) return undefined
|
|
328
|
+
// Fail closed: an authorizer that throws rejects the write (and is logged) rather than
|
|
329
|
+
// aborting the whole push. Authorizers are security-sensitive, so a bug must never let a
|
|
330
|
+
// write through.
|
|
331
|
+
return (args) => {
|
|
332
|
+
try {
|
|
333
|
+
return authorize(args)
|
|
334
|
+
} catch (e) {
|
|
335
|
+
this.log?.error?.('record authorizer threw; rejecting the write', e)
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
245
341
|
constructor(opts: {
|
|
246
342
|
log?: TLSyncLog
|
|
247
343
|
schema: StoreSchema<R, any>
|
|
248
344
|
onPresenceChange?(): void
|
|
345
|
+
/**
|
|
346
|
+
* Called once after a client push commits, with the committed document diff. Fires for
|
|
347
|
+
* local and remote pushes. Use this to react to document changes (e.g. persist certain
|
|
348
|
+
* record types to a separate lane, or project them to an external store) as soon as they
|
|
349
|
+
* commit. Best-effort — do not throw; do not block.
|
|
350
|
+
*/
|
|
351
|
+
onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
|
|
352
|
+
/**
|
|
353
|
+
* Record type names to serve through the object-store lane instead of the document lane.
|
|
354
|
+
* Each must be a document-scoped type registered in the schema. Object-lane writes are
|
|
355
|
+
* gated per session by `objectAccess` rather than `isReadonly`.
|
|
356
|
+
*/
|
|
357
|
+
objectTypes?: readonly string[]
|
|
358
|
+
/**
|
|
359
|
+
* Per-type authorizers for client record writes (create, update, delete): veto or, on
|
|
360
|
+
* create, rewrite. See {@link TLRecordAuthorizers}.
|
|
361
|
+
*/
|
|
362
|
+
authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
|
|
249
363
|
storage: TLSyncStorage<R>
|
|
250
364
|
clientTimeout?: number
|
|
251
365
|
}) {
|
|
252
366
|
this.schema = opts.schema
|
|
253
367
|
this.log = opts.log
|
|
254
368
|
this.onPresenceChange = opts.onPresenceChange
|
|
369
|
+
this.onCommittedChanges = opts.onCommittedChanges
|
|
370
|
+
this.authorizeRecord = opts.authorizeRecord
|
|
255
371
|
this.storage = opts.storage
|
|
256
372
|
this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
|
|
257
373
|
|
|
@@ -264,9 +380,20 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
264
380
|
// do a json serialization cycle to make sure the schema has no 'undefined' values
|
|
265
381
|
this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize()))
|
|
266
382
|
|
|
383
|
+
this.objectTypes = new Set(opts.objectTypes ?? [])
|
|
384
|
+
for (const typeName of this.objectTypes) {
|
|
385
|
+
const type = getOwnProperty(this.schema.types, typeName)
|
|
386
|
+
assert(type, `TLSyncRoom: object type '${typeName}' is not registered in the schema`)
|
|
387
|
+
assert(
|
|
388
|
+
type.scope === 'document',
|
|
389
|
+
`TLSyncRoom: object type '${typeName}' must have scope 'document', got '${type.scope}'`
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// object-lane types are partitioned out of the document lane
|
|
267
394
|
this.documentTypes = new Set(
|
|
268
395
|
Object.values<RecordType<R, any>>(this.schema.types)
|
|
269
|
-
.filter((t) => t.scope === 'document')
|
|
396
|
+
.filter((t) => t.scope === 'document' && !this.objectTypes.has(t.typeName))
|
|
270
397
|
.map((t) => t.typeName)
|
|
271
398
|
)
|
|
272
399
|
|
|
@@ -282,6 +409,15 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
282
409
|
|
|
283
410
|
this.presenceType = presenceTypes.values().next()?.value ?? null
|
|
284
411
|
|
|
412
|
+
// The presence lane never consults authorizers, so a presence key in `authorizeRecord`
|
|
413
|
+
// would be a silent no-op — fail loudly at construction instead.
|
|
414
|
+
if (this.presenceType && this.authorizeRecord) {
|
|
415
|
+
assert(
|
|
416
|
+
!getOwnProperty(this.authorizeRecord, this.presenceType.typeName),
|
|
417
|
+
`TLSyncRoom: authorizeRecord['${this.presenceType.typeName}'] is a presence type; presence records are not authorized`
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
285
421
|
const { documentClock } = this.storage.transaction((txn) => {
|
|
286
422
|
this.schema.migrateStorage(txn)
|
|
287
423
|
})
|
|
@@ -442,6 +578,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
442
578
|
cancellationTime: Date.now(),
|
|
443
579
|
meta: session.meta,
|
|
444
580
|
isReadonly: session.isReadonly,
|
|
581
|
+
objectAccess: session.objectAccess,
|
|
445
582
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
446
583
|
supportsStringAppend: session.supportsStringAppend,
|
|
447
584
|
})
|
|
@@ -552,8 +689,9 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
552
689
|
socket: TLRoomSocket<R>
|
|
553
690
|
meta: SessionMeta
|
|
554
691
|
isReadonly: boolean
|
|
692
|
+
objectAccess?: TLObjectStoreAccess
|
|
555
693
|
}) {
|
|
556
|
-
const { sessionId, socket, meta, isReadonly } = opts
|
|
694
|
+
const { sessionId, socket, meta, isReadonly, objectAccess } = opts
|
|
557
695
|
const existing = this.sessions.get(sessionId)
|
|
558
696
|
this.sessions.set(sessionId, {
|
|
559
697
|
state: RoomSessionState.AwaitingConnectMessage,
|
|
@@ -563,6 +701,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
563
701
|
sessionStartTime: Date.now(),
|
|
564
702
|
meta,
|
|
565
703
|
isReadonly: isReadonly ?? false,
|
|
704
|
+
objectAccess: objectAccess ?? 'write',
|
|
566
705
|
// this gets set later during handleConnectMessage
|
|
567
706
|
requiresLegacyRejection: false,
|
|
568
707
|
supportsStringAppend: true,
|
|
@@ -582,6 +721,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
582
721
|
socket: TLRoomSocket<R>
|
|
583
722
|
meta: SessionMeta
|
|
584
723
|
isReadonly: boolean
|
|
724
|
+
objectAccess?: TLObjectStoreAccess
|
|
585
725
|
serializedSchema: SerializedSchema
|
|
586
726
|
presenceId: string | null
|
|
587
727
|
presenceRecord: UnknownRecord | null
|
|
@@ -593,6 +733,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
593
733
|
socket,
|
|
594
734
|
meta,
|
|
595
735
|
isReadonly,
|
|
736
|
+
objectAccess,
|
|
596
737
|
serializedSchema,
|
|
597
738
|
presenceId,
|
|
598
739
|
presenceRecord,
|
|
@@ -615,6 +756,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
615
756
|
outstandingDataMessages: [],
|
|
616
757
|
meta,
|
|
617
758
|
isReadonly,
|
|
759
|
+
objectAccess: objectAccess ?? 'write',
|
|
618
760
|
requiresLegacyRejection,
|
|
619
761
|
supportsStringAppend,
|
|
620
762
|
})
|
|
@@ -927,6 +1069,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
927
1069
|
supportsStringAppend: session.supportsStringAppend,
|
|
928
1070
|
meta: session.meta,
|
|
929
1071
|
isReadonly: session.isReadonly,
|
|
1072
|
+
objectAccess: session.objectAccess,
|
|
930
1073
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
931
1074
|
})
|
|
932
1075
|
this._unsafe_sendMessage(session.sessionId, msg)
|
|
@@ -977,6 +1120,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
977
1120
|
serverClock: txn.getClock(),
|
|
978
1121
|
diff: { ...presenceDiff.value, ...docDiff },
|
|
979
1122
|
isReadonly: session.isReadonly,
|
|
1123
|
+
objectAccess: session.objectAccess,
|
|
980
1124
|
} satisfies Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>
|
|
981
1125
|
}) // no id needed because this only reads, no writes.
|
|
982
1126
|
|
|
@@ -1038,7 +1182,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1038
1182
|
storage: MinimalDocStore<R>,
|
|
1039
1183
|
changes: ActualChanges,
|
|
1040
1184
|
id: string,
|
|
1041
|
-
_state: R
|
|
1185
|
+
_state: R,
|
|
1186
|
+
authorize?: (prev: R | null, next: R) => R | null,
|
|
1187
|
+
// The existing document if the caller already fetched it; `null` for fetched-and-absent,
|
|
1188
|
+
// `undefined` for not fetched. Saves a second SELECT + JSON.parse on the push hot path.
|
|
1189
|
+
prevDoc?: R | null
|
|
1042
1190
|
): Result<void, void> => {
|
|
1043
1191
|
const res = session
|
|
1044
1192
|
? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
|
|
@@ -1046,10 +1194,19 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1046
1194
|
if (res.type === 'error') {
|
|
1047
1195
|
throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
1048
1196
|
}
|
|
1049
|
-
|
|
1197
|
+
let { value: state } = res
|
|
1050
1198
|
|
|
1051
1199
|
// Get the existing document, if any
|
|
1052
|
-
const doc =
|
|
1200
|
+
const doc =
|
|
1201
|
+
prevDoc !== undefined ? (prevDoc ?? undefined) : (storage.get(id) as R | undefined)
|
|
1202
|
+
|
|
1203
|
+
// Authorize on the up-migrated record; on create the authorizer's return is stored as-is,
|
|
1204
|
+
// so no later migration can clobber stamped fields.
|
|
1205
|
+
if (authorize) {
|
|
1206
|
+
const result = authorize(doc ?? null, state)
|
|
1207
|
+
if (!result) return Result.ok(undefined) // vetoed: skip the op, the client self-corrects
|
|
1208
|
+
if (!doc) state = result // create: store the authorizer's (stamped) record
|
|
1209
|
+
}
|
|
1053
1210
|
|
|
1054
1211
|
if (doc) {
|
|
1055
1212
|
// If there's an existing document, replace it with the new state
|
|
@@ -1077,10 +1234,13 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1077
1234
|
storage: MinimalDocStore<R>,
|
|
1078
1235
|
changes: ActualChanges,
|
|
1079
1236
|
id: string,
|
|
1080
|
-
patch: ObjectDiff
|
|
1237
|
+
patch: ObjectDiff,
|
|
1238
|
+
authorize?: (prev: R, next: R) => R | null,
|
|
1239
|
+
// The existing document if the caller already fetched it (see `addDocument`).
|
|
1240
|
+
prevDoc?: R
|
|
1081
1241
|
) => {
|
|
1082
1242
|
// if it was already deleted, there's no need to apply the patch
|
|
1083
|
-
const doc = storage.get(id) as R | undefined
|
|
1243
|
+
const doc = prevDoc ?? (storage.get(id) as R | undefined)
|
|
1084
1244
|
if (!doc) return
|
|
1085
1245
|
|
|
1086
1246
|
const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName))
|
|
@@ -1097,6 +1257,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1097
1257
|
// If the versions are compatible, apply the patch and propagate the patch op
|
|
1098
1258
|
const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode)
|
|
1099
1259
|
if (diff) {
|
|
1260
|
+
// Authorize on the committed candidate — the record that will actually be stored.
|
|
1261
|
+
if (authorize && !authorize(doc, diff[1])) return
|
|
1100
1262
|
storage.set(id, diff[1])
|
|
1101
1263
|
propagateOp(changes, id, [RecordOpType.Patch, diff[0]], doc, diff[1])
|
|
1102
1264
|
}
|
|
@@ -1116,6 +1278,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1116
1278
|
// replace the state with the upgraded version and propagate the patch op
|
|
1117
1279
|
const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode)
|
|
1118
1280
|
if (diff) {
|
|
1281
|
+
// Authorize on the committed candidate — the upgraded record, not a raw preview.
|
|
1282
|
+
if (authorize && !authorize(doc, upgraded.value)) return
|
|
1119
1283
|
storage.set(id, upgraded.value)
|
|
1120
1284
|
propagateOp(changes, id, [RecordOpType.Patch, diff], doc, upgraded.value)
|
|
1121
1285
|
}
|
|
@@ -1158,33 +1322,173 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1158
1322
|
}
|
|
1159
1323
|
}
|
|
1160
1324
|
}
|
|
1161
|
-
|
|
1325
|
+
// Per-op write gate: document-lane records are gated by `isReadonly`, object-lane
|
|
1326
|
+
// records by `objectAccess`. Denied ops are skipped (the client is corrected by the
|
|
1327
|
+
// resulting discard/rebase push_result, exactly as whole-diff readonly skips were).
|
|
1328
|
+
// Server-initiated pushes (no session) are always allowed.
|
|
1329
|
+
const canWrite = (typeName: string) =>
|
|
1330
|
+
!session ||
|
|
1331
|
+
(this.objectTypes.has(typeName) ? session.objectAccess !== 'read' : !session.isReadonly)
|
|
1332
|
+
|
|
1333
|
+
if (message.diff) {
|
|
1162
1334
|
// The push request was for the document scope.
|
|
1163
1335
|
for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
|
|
1164
1336
|
switch (op[0]) {
|
|
1165
1337
|
case RecordOpType.Put: {
|
|
1338
|
+
// The write gate is checked before type validation so that a denied
|
|
1339
|
+
// session's ops are skipped without rejection, matching the previous
|
|
1340
|
+
// whole-diff readonly behavior.
|
|
1341
|
+
if (!canWrite(op[1].typeName)) continue
|
|
1166
1342
|
// Try to add the document.
|
|
1167
1343
|
// If we're putting a record with a type that we don't recognize, fail
|
|
1168
|
-
if (
|
|
1344
|
+
if (
|
|
1345
|
+
!this.documentTypes.has(op[1].typeName) &&
|
|
1346
|
+
!this.objectTypes.has(op[1].typeName)
|
|
1347
|
+
) {
|
|
1169
1348
|
throw new TLSyncError(
|
|
1170
1349
|
'invalid record',
|
|
1171
1350
|
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
1172
1351
|
)
|
|
1173
1352
|
}
|
|
1174
|
-
|
|
1353
|
+
const record = op[1]
|
|
1354
|
+
// A put must not change the record's typeName. Two reasons, and the first
|
|
1355
|
+
// applies whether or not authorizers are configured: the write gate above
|
|
1356
|
+
// keyed off the *incoming* typeName, so a swap between lanes (document <->
|
|
1357
|
+
// object) would let a session denied on one lane write through the other by
|
|
1358
|
+
// relabeling an existing record. The second is that the authorizer lookup
|
|
1359
|
+
// also keys off the incoming typeName while the replace path validates
|
|
1360
|
+
// against the stored one, so a swap would consult the wrong authorizer (or
|
|
1361
|
+
// none). Skip it like a veto; the client self-corrects.
|
|
1362
|
+
// `undefined` (no session) means the guard didn't fetch, so addDocument will.
|
|
1363
|
+
const prevRecord = session ? ((txn.get(id) as R | undefined) ?? null) : undefined
|
|
1364
|
+
if (session && prevRecord && prevRecord.typeName !== record.typeName) {
|
|
1365
|
+
this.log?.warn?.(
|
|
1366
|
+
'skipping put that changes typeName',
|
|
1367
|
+
`${prevRecord.typeName} -> ${record.typeName}`,
|
|
1368
|
+
id,
|
|
1369
|
+
'session:',
|
|
1370
|
+
session.sessionId
|
|
1371
|
+
)
|
|
1372
|
+
continue
|
|
1373
|
+
}
|
|
1374
|
+
// Per-type authorizer: stamp/veto the write from the session's identity.
|
|
1375
|
+
// Client pushes only; runs inside `addDocument`, on the up-migrated record.
|
|
1376
|
+
let authorize: ((prev: R | null, next: R) => R | null) | undefined
|
|
1377
|
+
if (session && this.authorizeRecord) {
|
|
1378
|
+
const authorizePut = this.authorizerFor(record.typeName)
|
|
1379
|
+
if (authorizePut) {
|
|
1380
|
+
authorize = (prevRec, next) => {
|
|
1381
|
+
const result = authorizePut(
|
|
1382
|
+
prevRec
|
|
1383
|
+
? {
|
|
1384
|
+
session: {
|
|
1385
|
+
sessionId: session.sessionId,
|
|
1386
|
+
isReadonly: session.isReadonly,
|
|
1387
|
+
meta: session.meta,
|
|
1388
|
+
},
|
|
1389
|
+
type: 'update',
|
|
1390
|
+
prev: prevRec,
|
|
1391
|
+
next,
|
|
1392
|
+
}
|
|
1393
|
+
: {
|
|
1394
|
+
session: {
|
|
1395
|
+
sessionId: session.sessionId,
|
|
1396
|
+
isReadonly: session.isReadonly,
|
|
1397
|
+
meta: session.meta,
|
|
1398
|
+
},
|
|
1399
|
+
type: 'create',
|
|
1400
|
+
prev: null,
|
|
1401
|
+
next,
|
|
1402
|
+
}
|
|
1403
|
+
)
|
|
1404
|
+
if (!result) {
|
|
1405
|
+
this.log?.warn?.(
|
|
1406
|
+
'authorizer vetoed put',
|
|
1407
|
+
record.typeName,
|
|
1408
|
+
id,
|
|
1409
|
+
'session:',
|
|
1410
|
+
session.sessionId
|
|
1411
|
+
)
|
|
1412
|
+
return null
|
|
1413
|
+
}
|
|
1414
|
+
// create: store the stamped record; update: allow/veto only
|
|
1415
|
+
return prevRec ? next : result
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
// The guard above already fetched this, so don't hit storage again.
|
|
1420
|
+
addDocument(txn, docChanges, id, record, authorize, prevRecord)
|
|
1175
1421
|
break
|
|
1176
1422
|
}
|
|
1177
1423
|
case RecordOpType.Patch: {
|
|
1178
|
-
|
|
1179
|
-
|
|
1424
|
+
const doc = txn.get(id) as R | undefined
|
|
1425
|
+
// if it was already deleted, there's no need to apply the patch
|
|
1426
|
+
if (!doc) continue
|
|
1427
|
+
if (!canWrite(doc.typeName)) continue
|
|
1428
|
+
// Per-type authorizer (update, allow/veto only): runs inside `patchDocument`
|
|
1429
|
+
// on the committed candidate, never on a raw client-version preview.
|
|
1430
|
+
const authorizePatch = session && this.authorizerFor(doc.typeName)
|
|
1431
|
+
const authorize = authorizePatch
|
|
1432
|
+
? (prev: R, next: R) => {
|
|
1433
|
+
const result = authorizePatch({
|
|
1434
|
+
session: {
|
|
1435
|
+
sessionId: session.sessionId,
|
|
1436
|
+
isReadonly: session.isReadonly,
|
|
1437
|
+
meta: session.meta,
|
|
1438
|
+
},
|
|
1439
|
+
type: 'update',
|
|
1440
|
+
prev,
|
|
1441
|
+
next,
|
|
1442
|
+
})
|
|
1443
|
+
if (!result) {
|
|
1444
|
+
this.log?.warn?.(
|
|
1445
|
+
'authorizer vetoed patch',
|
|
1446
|
+
doc.typeName,
|
|
1447
|
+
id,
|
|
1448
|
+
'session:',
|
|
1449
|
+
session.sessionId
|
|
1450
|
+
)
|
|
1451
|
+
}
|
|
1452
|
+
return result
|
|
1453
|
+
}
|
|
1454
|
+
: undefined
|
|
1455
|
+
// Try to patch the document. If it fails, stop here. The write gate above
|
|
1456
|
+
// already fetched the record, so pass it through.
|
|
1457
|
+
patchDocument(txn, docChanges, id, op[1], authorize, doc)
|
|
1180
1458
|
break
|
|
1181
1459
|
}
|
|
1182
1460
|
case RecordOpType.Remove: {
|
|
1183
|
-
const doc = txn.get(id)
|
|
1461
|
+
const doc = txn.get(id) as R | undefined
|
|
1184
1462
|
if (!doc) {
|
|
1185
1463
|
// If the doc was already deleted, don't do anything, no need to propagate a delete op
|
|
1186
1464
|
continue
|
|
1187
1465
|
}
|
|
1466
|
+
if (!canWrite(doc.typeName)) continue
|
|
1467
|
+
// Per-type authorizer (delete): veto deletes the session isn't allowed to make,
|
|
1468
|
+
// e.g. deleting someone else's comment. Allow/veto only.
|
|
1469
|
+
const authorizeRemove = session && this.authorizerFor(doc.typeName)
|
|
1470
|
+
if (
|
|
1471
|
+
authorizeRemove &&
|
|
1472
|
+
!authorizeRemove({
|
|
1473
|
+
session: {
|
|
1474
|
+
sessionId: session.sessionId,
|
|
1475
|
+
isReadonly: session.isReadonly,
|
|
1476
|
+
meta: session.meta,
|
|
1477
|
+
},
|
|
1478
|
+
type: 'delete',
|
|
1479
|
+
prev: doc,
|
|
1480
|
+
next: null,
|
|
1481
|
+
})
|
|
1482
|
+
) {
|
|
1483
|
+
this.log?.warn?.(
|
|
1484
|
+
'authorizer vetoed delete',
|
|
1485
|
+
doc.typeName,
|
|
1486
|
+
id,
|
|
1487
|
+
'session:',
|
|
1488
|
+
session.sessionId
|
|
1489
|
+
)
|
|
1490
|
+
continue
|
|
1491
|
+
}
|
|
1188
1492
|
|
|
1189
1493
|
// Delete the document and propagate the delete op
|
|
1190
1494
|
// delete automatically creates tombstones
|
|
@@ -1272,6 +1576,17 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1272
1576
|
this.onPresenceChange?.()
|
|
1273
1577
|
})
|
|
1274
1578
|
}
|
|
1579
|
+
|
|
1580
|
+
if (result.docChanges.diffs && this.onCommittedChanges) {
|
|
1581
|
+
const diff = result.docChanges.diffs.diff
|
|
1582
|
+
queueMicrotask(() => {
|
|
1583
|
+
try {
|
|
1584
|
+
this.onCommittedChanges?.({ diff, documentClock })
|
|
1585
|
+
} catch (e) {
|
|
1586
|
+
this.log?.error?.('onCommittedChanges threw', e)
|
|
1587
|
+
}
|
|
1588
|
+
})
|
|
1589
|
+
}
|
|
1275
1590
|
}
|
|
1276
1591
|
|
|
1277
1592
|
/**
|
package/src/lib/TLSyncStorage.ts
CHANGED
|
@@ -83,7 +83,22 @@ export interface TLSyncStorage<R extends UnknownRecord> {
|
|
|
83
83
|
|
|
84
84
|
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void
|
|
85
85
|
|
|
86
|
+
/** A snapshot of the document lane only — never contains object-store lane records. */
|
|
86
87
|
getSnapshot?(): RoomSnapshot
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A snapshot of the object-store lane (see `objectTypes` on the storage), for hosts that
|
|
91
|
+
* persist object-lane records separately from the document. Same entry shape as
|
|
92
|
+
* `RoomSnapshot['documents']` so a lane can be persisted and re-seeded via a merged snapshot.
|
|
93
|
+
*/
|
|
94
|
+
getObjectsSnapshot?(): RoomSnapshot['documents']
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The same entries for the given ids, for callers that need a handful of records rather than the
|
|
98
|
+
* whole (parsed) lane. Ids with no object-lane record are omitted — document-lane ids included —
|
|
99
|
+
* and duplicates yield one entry.
|
|
100
|
+
*/
|
|
101
|
+
getObjectsByIds?(ids: Iterable<string>): RoomSnapshot['documents']
|
|
87
102
|
}
|
|
88
103
|
|
|
89
104
|
/**
|
package/src/lib/protocol.ts
CHANGED
|
@@ -24,6 +24,17 @@ export function getTlsyncProtocolVersion() {
|
|
|
24
24
|
return TLSYNC_PROTOCOL_VERSION
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Access level for object-store lane record types (see `objectTypes` on the room).
|
|
29
|
+
*
|
|
30
|
+
* Object-lane records (e.g. comments) are gated by this per-session value instead of the
|
|
31
|
+
* document-lane `isReadonly` flag, so a session can be allowed to write objects without
|
|
32
|
+
* being allowed to edit the document ("can comment but not edit"), or vice versa.
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
export type TLObjectStoreAccess = 'read' | 'write'
|
|
37
|
+
|
|
27
38
|
/**
|
|
28
39
|
* Constants defining the different types of protocol incompatibility reasons.
|
|
29
40
|
*
|
|
@@ -108,6 +119,12 @@ export type TLSocketServerSentEvent<R extends UnknownRecord> =
|
|
|
108
119
|
diff: NetworkDiff<R>
|
|
109
120
|
serverClock: number
|
|
110
121
|
isReadonly: boolean
|
|
122
|
+
/**
|
|
123
|
+
* Write access for object-store lane record types, when the room has an object lane.
|
|
124
|
+
* Optional and additive: servers without an object lane (or older servers) omit it, and
|
|
125
|
+
* older clients ignore it — no protocol version bump needed.
|
|
126
|
+
*/
|
|
127
|
+
objectAccess?: TLObjectStoreAccess
|
|
111
128
|
}
|
|
112
129
|
| {
|
|
113
130
|
type: 'incompatibility_error'
|
|
@@ -48,8 +48,13 @@ function createMockSocket(overrides: Partial<WebSocketMinimal> = {}): WebSocketM
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// Connect a session and complete the connect handshake
|
|
51
|
-
function connectSession(
|
|
52
|
-
room
|
|
51
|
+
function connectSession(
|
|
52
|
+
room: TLSocketRoom<any, any>,
|
|
53
|
+
sessionId: string,
|
|
54
|
+
socket: WebSocketMinimal,
|
|
55
|
+
opts: { objectAccess?: 'read' | 'write' } = {}
|
|
56
|
+
) {
|
|
57
|
+
room.handleSocketConnect({ sessionId, socket, ...opts })
|
|
53
58
|
const connectRequest = {
|
|
54
59
|
type: 'connect' as const,
|
|
55
60
|
connectRequestId: `connect-${sessionId}`,
|
|
@@ -997,11 +1002,41 @@ describe('28. TLSocketRoom (SR)', () => {
|
|
|
997
1002
|
expect(snapshot).not.toBeNull()
|
|
998
1003
|
expect(snapshot!.serializedSchema).toBeDefined()
|
|
999
1004
|
expect(snapshot!.isReadonly).toBe(false)
|
|
1005
|
+
expect(snapshot!.objectAccess).toBe('write')
|
|
1000
1006
|
expect(snapshot!.presenceId).toBeDefined()
|
|
1001
1007
|
expect(snapshot!.requiresLegacyRejection).toBe(false)
|
|
1002
1008
|
expect(snapshot!.supportsStringAppend).toBe(true)
|
|
1003
1009
|
})
|
|
1004
1010
|
|
|
1011
|
+
it('[SR13] round-trips objectAccess through snapshot and resume', () => {
|
|
1012
|
+
const room = new TLSocketRoom({})
|
|
1013
|
+
const socket = createMockSocket()
|
|
1014
|
+
connectSession(room, 'test', socket, { objectAccess: 'read' })
|
|
1015
|
+
|
|
1016
|
+
const snapshot = room.getSessionSnapshot('test')!
|
|
1017
|
+
expect(snapshot.objectAccess).toBe('read')
|
|
1018
|
+
|
|
1019
|
+
// Simulate hibernation: resume in a new room
|
|
1020
|
+
const room2 = new TLSocketRoom({})
|
|
1021
|
+
room2.handleSocketResume({ sessionId: 'test', socket: createMockSocket(), snapshot })
|
|
1022
|
+
expect(room2.getSessions()[0].objectAccess).toBe('read')
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
it('[SR13] resumes legacy snapshots without objectAccess as read (fail closed)', () => {
|
|
1026
|
+
const room = new TLSocketRoom({})
|
|
1027
|
+
const socket = createMockSocket()
|
|
1028
|
+
connectSession(room, 'test', socket)
|
|
1029
|
+
|
|
1030
|
+
const snapshot = room.getSessionSnapshot('test')!
|
|
1031
|
+
// simulate a snapshot persisted before the objectAccess field existed
|
|
1032
|
+
delete snapshot.objectAccess
|
|
1033
|
+
|
|
1034
|
+
const room2 = new TLSocketRoom({})
|
|
1035
|
+
room2.handleSocketResume({ sessionId: 'test', socket: createMockSocket(), snapshot })
|
|
1036
|
+
// such sessions predate the record types gated by objectAccess, so they have nothing to write anyway
|
|
1037
|
+
expect(room2.getSessions()[0].objectAccess).toBe('read')
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1005
1040
|
it('[SR13] includes presence record when present', () => {
|
|
1006
1041
|
const store = getStore()
|
|
1007
1042
|
store.ensureStoreIsUsable()
|
|
@@ -188,6 +188,7 @@ function makeRoom(
|
|
|
188
188
|
clientTimeout?: number
|
|
189
189
|
log?: { warn?: Mock; error?: Mock }
|
|
190
190
|
onPresenceChange?(): void
|
|
191
|
+
onCommittedChanges?(args: { diff: any; documentClock: number }): void
|
|
191
192
|
} = {}
|
|
192
193
|
) {
|
|
193
194
|
const storage = new InMemorySyncStorage<TLRecord>({
|
|
@@ -199,6 +200,7 @@ function makeRoom(
|
|
|
199
200
|
clientTimeout: opts.clientTimeout,
|
|
200
201
|
log: opts.log,
|
|
201
202
|
onPresenceChange: opts.onPresenceChange,
|
|
203
|
+
onCommittedChanges: opts.onCommittedChanges,
|
|
202
204
|
})
|
|
203
205
|
disposables.push(() => room.close())
|
|
204
206
|
return { storage, room }
|
|
@@ -634,6 +636,29 @@ describe('22. Room construction (RC)', () => {
|
|
|
634
636
|
expect(clientBMessages[0].data[0].type).toBe('patch')
|
|
635
637
|
})
|
|
636
638
|
|
|
639
|
+
it('fires onCommittedChanges once with the committed document diff after a push', async () => {
|
|
640
|
+
const onCommittedChanges = vi.fn()
|
|
641
|
+
const { room } = makeRoom({ onCommittedChanges })
|
|
642
|
+
connectSession(room, 'session-a')
|
|
643
|
+
|
|
644
|
+
const newPage = makePage('committed_page', 'Committed Page')
|
|
645
|
+
room.handleMessage('session-a', {
|
|
646
|
+
type: 'push',
|
|
647
|
+
clientClock: 1,
|
|
648
|
+
diff: {
|
|
649
|
+
[newPage.id]: ['put', newPage],
|
|
650
|
+
},
|
|
651
|
+
} as TLPushRequest<TLRecord>)
|
|
652
|
+
|
|
653
|
+
// the tap fires in a microtask, like onPresenceChange
|
|
654
|
+
await Promise.resolve()
|
|
655
|
+
|
|
656
|
+
expect(onCommittedChanges).toHaveBeenCalledTimes(1)
|
|
657
|
+
const arg = onCommittedChanges.mock.calls[0][0]
|
|
658
|
+
expect(arg.diff.puts[newPage.id]).toBeTruthy()
|
|
659
|
+
expect(typeof arg.documentClock).toBe('number')
|
|
660
|
+
})
|
|
661
|
+
|
|
637
662
|
it('[RC4] handles multiple rapid external changes', async () => {
|
|
638
663
|
const { room, storage } = makeRoom()
|
|
639
664
|
const socket = connectSession(room, 'test-session')
|