@tldraw/sync-core 5.3.0-canary.fceaae5e9feb → 5.3.0-internal.41291fb579ed
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 +154 -3
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +55 -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 +115 -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 +160 -10
- 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 +154 -3
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +55 -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 +115 -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 +160 -10
- 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 +74 -21
- package/src/lib/RoomSession.ts +7 -2
- package/src/lib/SQLiteSyncStorage.test.ts +29 -2
- package/src/lib/SQLiteSyncStorage.ts +158 -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 +294 -10
- package/src/lib/TLSyncStorage.ts +8 -0
- package/src/lib/protocol.ts +17 -0
- package/src/test/TLSocketRoom.test.ts +37 -2
- package/src/test/TLSyncClientRebase.test.ts +285 -0
- package/src/test/TLSyncRoom.test.ts +25 -0
- package/src/test/TestServer.ts +14 -4
- package/src/test/objectStore.test.ts +630 -0
- package/src/test/storageContractSuite.ts +79 -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,67 @@ 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, including its host-provided `meta` (e.g. the authenticated user id). */
|
|
159
|
+
session: { sessionId: string; meta: SessionMeta }
|
|
160
|
+
} & (
|
|
161
|
+
| { type: 'create'; prev: null; next: Rec }
|
|
162
|
+
| { type: 'update'; prev: Rec; next: Rec }
|
|
163
|
+
| { type: 'delete'; prev: Rec; next: null }
|
|
164
|
+
)
|
|
165
|
+
) => Rec | null
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A map from record `typeName` to a {@link TLRecordAuthorizer} for that record type. Only listed
|
|
169
|
+
* types are authorized; every other record writes through untouched, so this stays off the hot path
|
|
170
|
+
* for the vast majority of writes (shape drags etc.).
|
|
171
|
+
*
|
|
172
|
+
* Each authorizer is typed to its record — e.g. `next` in the `comment` entry is a `TLComment` — so
|
|
173
|
+
* renaming a field on the record makes the authorizer that reads it fail to compile, rather than
|
|
174
|
+
* silently stamp or guard the wrong field.
|
|
175
|
+
*
|
|
176
|
+
* Presence records are never authorized (presence is per-session and ephemeral); registering the
|
|
177
|
+
* presence typeName is a construction-time error.
|
|
178
|
+
*
|
|
179
|
+
* @public
|
|
180
|
+
*/
|
|
181
|
+
export type TLRecordAuthorizers<R extends UnknownRecord, SessionMeta> = {
|
|
182
|
+
[K in R['typeName']]?: TLRecordAuthorizer<Extract<R, { typeName: K }>, SessionMeta>
|
|
183
|
+
}
|
|
184
|
+
|
|
123
185
|
/**
|
|
124
186
|
* A collaborative workspace that manages multiple client sessions and synchronizes
|
|
125
187
|
* document changes between them. The room serves as the authoritative source for
|
|
@@ -236,22 +298,74 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
236
298
|
readonly serializedSchema: SerializedSchema
|
|
237
299
|
|
|
238
300
|
readonly documentTypes: Set<string>
|
|
301
|
+
/**
|
|
302
|
+
* Record types served by the object-store lane. Object records ride the same wire messages
|
|
303
|
+
* as document records but are gated by the session's `objectAccess` instead of `isReadonly`,
|
|
304
|
+
* and are excluded from `documentTypes` so hosts can persist them in a separate lane.
|
|
305
|
+
*/
|
|
306
|
+
readonly objectTypes: Set<string>
|
|
239
307
|
readonly presenceType: RecordType<R, any> | null
|
|
240
308
|
private log?: TLSyncLog
|
|
241
309
|
public readonly schema: StoreSchema<R, any>
|
|
242
310
|
private onPresenceChange?(): void
|
|
311
|
+
private onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
|
|
312
|
+
private readonly authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
|
|
243
313
|
private readonly sessionIdleTimeout: number
|
|
244
314
|
|
|
315
|
+
/**
|
|
316
|
+
* The authorizer registered for a record type, widened to the room's record union. Each entry in
|
|
317
|
+
* `authorizeRecord` is typed to its specific record; the cast here is the one place we can't
|
|
318
|
+
* statically correlate a runtime `typeName` with its record type, so it lives in the library
|
|
319
|
+
* rather than in every consumer.
|
|
320
|
+
*/
|
|
321
|
+
private authorizerFor(typeName: R['typeName']): TLRecordAuthorizer<R, SessionMeta> | undefined {
|
|
322
|
+
const authorize = this.authorizeRecord?.[typeName] as
|
|
323
|
+
| TLRecordAuthorizer<R, SessionMeta>
|
|
324
|
+
| undefined
|
|
325
|
+
if (!authorize) return undefined
|
|
326
|
+
// Fail closed: an authorizer that throws rejects the write (and is logged) rather than
|
|
327
|
+
// aborting the whole push. Authorizers are security-sensitive, so a bug must never let a
|
|
328
|
+
// write through.
|
|
329
|
+
return (args) => {
|
|
330
|
+
try {
|
|
331
|
+
return authorize(args)
|
|
332
|
+
} catch (e) {
|
|
333
|
+
this.log?.error?.('record authorizer threw; rejecting the write', e)
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
245
339
|
constructor(opts: {
|
|
246
340
|
log?: TLSyncLog
|
|
247
341
|
schema: StoreSchema<R, any>
|
|
248
342
|
onPresenceChange?(): void
|
|
343
|
+
/**
|
|
344
|
+
* Called once after a client push commits, with the committed document diff. Fires for
|
|
345
|
+
* local and remote pushes. Use this to react to document changes (e.g. persist certain
|
|
346
|
+
* record types to a separate lane, or project them to an external store) as soon as they
|
|
347
|
+
* commit. Best-effort — do not throw; do not block.
|
|
348
|
+
*/
|
|
349
|
+
onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
|
|
350
|
+
/**
|
|
351
|
+
* Record type names to serve through the object-store lane instead of the document lane.
|
|
352
|
+
* Each must be a document-scoped type registered in the schema. Object-lane writes are
|
|
353
|
+
* gated per session by `objectAccess` rather than `isReadonly`.
|
|
354
|
+
*/
|
|
355
|
+
objectTypes?: readonly string[]
|
|
356
|
+
/**
|
|
357
|
+
* Per-type authorizers for client record writes (create, update, delete): veto or, on
|
|
358
|
+
* create, rewrite. See {@link TLRecordAuthorizers}.
|
|
359
|
+
*/
|
|
360
|
+
authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
|
|
249
361
|
storage: TLSyncStorage<R>
|
|
250
362
|
clientTimeout?: number
|
|
251
363
|
}) {
|
|
252
364
|
this.schema = opts.schema
|
|
253
365
|
this.log = opts.log
|
|
254
366
|
this.onPresenceChange = opts.onPresenceChange
|
|
367
|
+
this.onCommittedChanges = opts.onCommittedChanges
|
|
368
|
+
this.authorizeRecord = opts.authorizeRecord
|
|
255
369
|
this.storage = opts.storage
|
|
256
370
|
this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
|
|
257
371
|
|
|
@@ -264,9 +378,20 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
264
378
|
// do a json serialization cycle to make sure the schema has no 'undefined' values
|
|
265
379
|
this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize()))
|
|
266
380
|
|
|
381
|
+
this.objectTypes = new Set(opts.objectTypes ?? [])
|
|
382
|
+
for (const typeName of this.objectTypes) {
|
|
383
|
+
const type = getOwnProperty(this.schema.types, typeName)
|
|
384
|
+
assert(type, `TLSyncRoom: object type '${typeName}' is not registered in the schema`)
|
|
385
|
+
assert(
|
|
386
|
+
type.scope === 'document',
|
|
387
|
+
`TLSyncRoom: object type '${typeName}' must have scope 'document', got '${type.scope}'`
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// object-lane types are partitioned out of the document lane
|
|
267
392
|
this.documentTypes = new Set(
|
|
268
393
|
Object.values<RecordType<R, any>>(this.schema.types)
|
|
269
|
-
.filter((t) => t.scope === 'document')
|
|
394
|
+
.filter((t) => t.scope === 'document' && !this.objectTypes.has(t.typeName))
|
|
270
395
|
.map((t) => t.typeName)
|
|
271
396
|
)
|
|
272
397
|
|
|
@@ -282,6 +407,15 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
282
407
|
|
|
283
408
|
this.presenceType = presenceTypes.values().next()?.value ?? null
|
|
284
409
|
|
|
410
|
+
// The presence lane never consults authorizers, so a presence key in `authorizeRecord`
|
|
411
|
+
// would be a silent no-op — fail loudly at construction instead.
|
|
412
|
+
if (this.presenceType && this.authorizeRecord) {
|
|
413
|
+
assert(
|
|
414
|
+
!getOwnProperty(this.authorizeRecord, this.presenceType.typeName),
|
|
415
|
+
`TLSyncRoom: authorizeRecord['${this.presenceType.typeName}'] is a presence type; presence records are not authorized`
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
285
419
|
const { documentClock } = this.storage.transaction((txn) => {
|
|
286
420
|
this.schema.migrateStorage(txn)
|
|
287
421
|
})
|
|
@@ -442,6 +576,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
442
576
|
cancellationTime: Date.now(),
|
|
443
577
|
meta: session.meta,
|
|
444
578
|
isReadonly: session.isReadonly,
|
|
579
|
+
objectAccess: session.objectAccess,
|
|
445
580
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
446
581
|
supportsStringAppend: session.supportsStringAppend,
|
|
447
582
|
})
|
|
@@ -552,8 +687,9 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
552
687
|
socket: TLRoomSocket<R>
|
|
553
688
|
meta: SessionMeta
|
|
554
689
|
isReadonly: boolean
|
|
690
|
+
objectAccess?: TLObjectStoreAccess
|
|
555
691
|
}) {
|
|
556
|
-
const { sessionId, socket, meta, isReadonly } = opts
|
|
692
|
+
const { sessionId, socket, meta, isReadonly, objectAccess } = opts
|
|
557
693
|
const existing = this.sessions.get(sessionId)
|
|
558
694
|
this.sessions.set(sessionId, {
|
|
559
695
|
state: RoomSessionState.AwaitingConnectMessage,
|
|
@@ -563,6 +699,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
563
699
|
sessionStartTime: Date.now(),
|
|
564
700
|
meta,
|
|
565
701
|
isReadonly: isReadonly ?? false,
|
|
702
|
+
objectAccess: objectAccess ?? 'write',
|
|
566
703
|
// this gets set later during handleConnectMessage
|
|
567
704
|
requiresLegacyRejection: false,
|
|
568
705
|
supportsStringAppend: true,
|
|
@@ -582,6 +719,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
582
719
|
socket: TLRoomSocket<R>
|
|
583
720
|
meta: SessionMeta
|
|
584
721
|
isReadonly: boolean
|
|
722
|
+
objectAccess?: TLObjectStoreAccess
|
|
585
723
|
serializedSchema: SerializedSchema
|
|
586
724
|
presenceId: string | null
|
|
587
725
|
presenceRecord: UnknownRecord | null
|
|
@@ -593,6 +731,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
593
731
|
socket,
|
|
594
732
|
meta,
|
|
595
733
|
isReadonly,
|
|
734
|
+
objectAccess,
|
|
596
735
|
serializedSchema,
|
|
597
736
|
presenceId,
|
|
598
737
|
presenceRecord,
|
|
@@ -615,6 +754,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
615
754
|
outstandingDataMessages: [],
|
|
616
755
|
meta,
|
|
617
756
|
isReadonly,
|
|
757
|
+
objectAccess: objectAccess ?? 'write',
|
|
618
758
|
requiresLegacyRejection,
|
|
619
759
|
supportsStringAppend,
|
|
620
760
|
})
|
|
@@ -927,6 +1067,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
927
1067
|
supportsStringAppend: session.supportsStringAppend,
|
|
928
1068
|
meta: session.meta,
|
|
929
1069
|
isReadonly: session.isReadonly,
|
|
1070
|
+
objectAccess: session.objectAccess,
|
|
930
1071
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
931
1072
|
})
|
|
932
1073
|
this._unsafe_sendMessage(session.sessionId, msg)
|
|
@@ -977,6 +1118,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
977
1118
|
serverClock: txn.getClock(),
|
|
978
1119
|
diff: { ...presenceDiff.value, ...docDiff },
|
|
979
1120
|
isReadonly: session.isReadonly,
|
|
1121
|
+
objectAccess: session.objectAccess,
|
|
980
1122
|
} satisfies Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>
|
|
981
1123
|
}) // no id needed because this only reads, no writes.
|
|
982
1124
|
|
|
@@ -1038,7 +1180,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1038
1180
|
storage: MinimalDocStore<R>,
|
|
1039
1181
|
changes: ActualChanges,
|
|
1040
1182
|
id: string,
|
|
1041
|
-
_state: R
|
|
1183
|
+
_state: R,
|
|
1184
|
+
authorize?: (prev: R | null, next: R) => R | null
|
|
1042
1185
|
): Result<void, void> => {
|
|
1043
1186
|
const res = session
|
|
1044
1187
|
? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
|
|
@@ -1046,11 +1189,19 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1046
1189
|
if (res.type === 'error') {
|
|
1047
1190
|
throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
1048
1191
|
}
|
|
1049
|
-
|
|
1192
|
+
let { value: state } = res
|
|
1050
1193
|
|
|
1051
1194
|
// Get the existing document, if any
|
|
1052
1195
|
const doc = storage.get(id) as R | undefined
|
|
1053
1196
|
|
|
1197
|
+
// Authorize on the up-migrated record; on create the authorizer's return is stored as-is,
|
|
1198
|
+
// so no later migration can clobber stamped fields.
|
|
1199
|
+
if (authorize) {
|
|
1200
|
+
const result = authorize(doc ?? null, state)
|
|
1201
|
+
if (!result) return Result.ok(undefined) // vetoed: skip the op, the client self-corrects
|
|
1202
|
+
if (!doc) state = result // create: store the authorizer's (stamped) record
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1054
1205
|
if (doc) {
|
|
1055
1206
|
// If there's an existing document, replace it with the new state
|
|
1056
1207
|
// but propagate a diff rather than the entire value
|
|
@@ -1077,7 +1228,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1077
1228
|
storage: MinimalDocStore<R>,
|
|
1078
1229
|
changes: ActualChanges,
|
|
1079
1230
|
id: string,
|
|
1080
|
-
patch: ObjectDiff
|
|
1231
|
+
patch: ObjectDiff,
|
|
1232
|
+
authorize?: (prev: R, next: R) => R | null
|
|
1081
1233
|
) => {
|
|
1082
1234
|
// if it was already deleted, there's no need to apply the patch
|
|
1083
1235
|
const doc = storage.get(id) as R | undefined
|
|
@@ -1097,6 +1249,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1097
1249
|
// If the versions are compatible, apply the patch and propagate the patch op
|
|
1098
1250
|
const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode)
|
|
1099
1251
|
if (diff) {
|
|
1252
|
+
// Authorize on the committed candidate — the record that will actually be stored.
|
|
1253
|
+
if (authorize && !authorize(doc, diff[1])) return
|
|
1100
1254
|
storage.set(id, diff[1])
|
|
1101
1255
|
propagateOp(changes, id, [RecordOpType.Patch, diff[0]], doc, diff[1])
|
|
1102
1256
|
}
|
|
@@ -1116,6 +1270,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1116
1270
|
// replace the state with the upgraded version and propagate the patch op
|
|
1117
1271
|
const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode)
|
|
1118
1272
|
if (diff) {
|
|
1273
|
+
// Authorize on the committed candidate — the upgraded record, not a raw preview.
|
|
1274
|
+
if (authorize && !authorize(doc, upgraded.value)) return
|
|
1119
1275
|
storage.set(id, upgraded.value)
|
|
1120
1276
|
propagateOp(changes, id, [RecordOpType.Patch, diff], doc, upgraded.value)
|
|
1121
1277
|
}
|
|
@@ -1158,33 +1314,150 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1158
1314
|
}
|
|
1159
1315
|
}
|
|
1160
1316
|
}
|
|
1161
|
-
|
|
1317
|
+
// Per-op write gate: document-lane records are gated by `isReadonly`, object-lane
|
|
1318
|
+
// records by `objectAccess`. Denied ops are skipped (the client is corrected by the
|
|
1319
|
+
// resulting discard/rebase push_result, exactly as whole-diff readonly skips were).
|
|
1320
|
+
// Server-initiated pushes (no session) are always allowed.
|
|
1321
|
+
const canWrite = (typeName: string) =>
|
|
1322
|
+
!session ||
|
|
1323
|
+
(this.objectTypes.has(typeName) ? session.objectAccess !== 'read' : !session.isReadonly)
|
|
1324
|
+
|
|
1325
|
+
if (message.diff) {
|
|
1162
1326
|
// The push request was for the document scope.
|
|
1163
1327
|
for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
|
|
1164
1328
|
switch (op[0]) {
|
|
1165
1329
|
case RecordOpType.Put: {
|
|
1330
|
+
// The write gate is checked before type validation so that a denied
|
|
1331
|
+
// session's ops are skipped without rejection, matching the previous
|
|
1332
|
+
// whole-diff readonly behavior.
|
|
1333
|
+
if (!canWrite(op[1].typeName)) continue
|
|
1166
1334
|
// Try to add the document.
|
|
1167
1335
|
// If we're putting a record with a type that we don't recognize, fail
|
|
1168
|
-
if (
|
|
1336
|
+
if (
|
|
1337
|
+
!this.documentTypes.has(op[1].typeName) &&
|
|
1338
|
+
!this.objectTypes.has(op[1].typeName)
|
|
1339
|
+
) {
|
|
1169
1340
|
throw new TLSyncError(
|
|
1170
1341
|
'invalid record',
|
|
1171
1342
|
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
1172
1343
|
)
|
|
1173
1344
|
}
|
|
1174
|
-
|
|
1345
|
+
const record = op[1]
|
|
1346
|
+
// Per-type authorizer: stamp/veto the write from the session's identity.
|
|
1347
|
+
// Client pushes only; runs inside `addDocument`, on the up-migrated record.
|
|
1348
|
+
let authorize: ((prev: R | null, next: R) => R | null) | undefined
|
|
1349
|
+
if (session && this.authorizeRecord) {
|
|
1350
|
+
const prev = (txn.get(id) as R | undefined) ?? null
|
|
1351
|
+
// A put must not change the record's typeName: the authorizer lookup keys
|
|
1352
|
+
// off the incoming typeName while the replace path validates against the
|
|
1353
|
+
// stored one, so a swap would consult the wrong authorizer (or none).
|
|
1354
|
+
// Skip it like a veto; the client self-corrects.
|
|
1355
|
+
if (prev && prev.typeName !== record.typeName) {
|
|
1356
|
+
this.log?.warn?.(
|
|
1357
|
+
'skipping put that changes typeName',
|
|
1358
|
+
`${prev.typeName} -> ${record.typeName}`,
|
|
1359
|
+
id,
|
|
1360
|
+
'session:',
|
|
1361
|
+
session.sessionId
|
|
1362
|
+
)
|
|
1363
|
+
continue
|
|
1364
|
+
}
|
|
1365
|
+
const authorizePut = this.authorizerFor(record.typeName)
|
|
1366
|
+
if (authorizePut) {
|
|
1367
|
+
authorize = (prevRec, next) => {
|
|
1368
|
+
const result = authorizePut(
|
|
1369
|
+
prevRec
|
|
1370
|
+
? {
|
|
1371
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
1372
|
+
type: 'update',
|
|
1373
|
+
prev: prevRec,
|
|
1374
|
+
next,
|
|
1375
|
+
}
|
|
1376
|
+
: {
|
|
1377
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
1378
|
+
type: 'create',
|
|
1379
|
+
prev: null,
|
|
1380
|
+
next,
|
|
1381
|
+
}
|
|
1382
|
+
)
|
|
1383
|
+
if (!result) {
|
|
1384
|
+
this.log?.warn?.(
|
|
1385
|
+
'authorizer vetoed put',
|
|
1386
|
+
record.typeName,
|
|
1387
|
+
id,
|
|
1388
|
+
'session:',
|
|
1389
|
+
session.sessionId
|
|
1390
|
+
)
|
|
1391
|
+
return null
|
|
1392
|
+
}
|
|
1393
|
+
// create: store the stamped record; update: allow/veto only
|
|
1394
|
+
return prevRec ? next : result
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
addDocument(txn, docChanges, id, record, authorize)
|
|
1175
1399
|
break
|
|
1176
1400
|
}
|
|
1177
1401
|
case RecordOpType.Patch: {
|
|
1402
|
+
const doc = txn.get(id) as R | undefined
|
|
1403
|
+
// if it was already deleted, there's no need to apply the patch
|
|
1404
|
+
if (!doc) continue
|
|
1405
|
+
if (!canWrite(doc.typeName)) continue
|
|
1406
|
+
// Per-type authorizer (update, allow/veto only): runs inside `patchDocument`
|
|
1407
|
+
// on the committed candidate, never on a raw client-version preview.
|
|
1408
|
+
const authorizePatch = session && this.authorizerFor(doc.typeName)
|
|
1409
|
+
const authorize = authorizePatch
|
|
1410
|
+
? (prev: R, next: R) => {
|
|
1411
|
+
const result = authorizePatch({
|
|
1412
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
1413
|
+
type: 'update',
|
|
1414
|
+
prev,
|
|
1415
|
+
next,
|
|
1416
|
+
})
|
|
1417
|
+
if (!result) {
|
|
1418
|
+
this.log?.warn?.(
|
|
1419
|
+
'authorizer vetoed patch',
|
|
1420
|
+
doc.typeName,
|
|
1421
|
+
id,
|
|
1422
|
+
'session:',
|
|
1423
|
+
session.sessionId
|
|
1424
|
+
)
|
|
1425
|
+
}
|
|
1426
|
+
return result
|
|
1427
|
+
}
|
|
1428
|
+
: undefined
|
|
1178
1429
|
// Try to patch the document. If it fails, stop here.
|
|
1179
|
-
patchDocument(txn, docChanges, id, op[1])
|
|
1430
|
+
patchDocument(txn, docChanges, id, op[1], authorize)
|
|
1180
1431
|
break
|
|
1181
1432
|
}
|
|
1182
1433
|
case RecordOpType.Remove: {
|
|
1183
|
-
const doc = txn.get(id)
|
|
1434
|
+
const doc = txn.get(id) as R | undefined
|
|
1184
1435
|
if (!doc) {
|
|
1185
1436
|
// If the doc was already deleted, don't do anything, no need to propagate a delete op
|
|
1186
1437
|
continue
|
|
1187
1438
|
}
|
|
1439
|
+
if (!canWrite(doc.typeName)) continue
|
|
1440
|
+
// Per-type authorizer (delete): veto deletes the session isn't allowed to make,
|
|
1441
|
+
// e.g. deleting someone else's comment. Allow/veto only.
|
|
1442
|
+
const authorizeRemove = session && this.authorizerFor(doc.typeName)
|
|
1443
|
+
if (
|
|
1444
|
+
authorizeRemove &&
|
|
1445
|
+
!authorizeRemove({
|
|
1446
|
+
session: { sessionId: session.sessionId, meta: session.meta },
|
|
1447
|
+
type: 'delete',
|
|
1448
|
+
prev: doc,
|
|
1449
|
+
next: null,
|
|
1450
|
+
})
|
|
1451
|
+
) {
|
|
1452
|
+
this.log?.warn?.(
|
|
1453
|
+
'authorizer vetoed delete',
|
|
1454
|
+
doc.typeName,
|
|
1455
|
+
id,
|
|
1456
|
+
'session:',
|
|
1457
|
+
session.sessionId
|
|
1458
|
+
)
|
|
1459
|
+
continue
|
|
1460
|
+
}
|
|
1188
1461
|
|
|
1189
1462
|
// Delete the document and propagate the delete op
|
|
1190
1463
|
// delete automatically creates tombstones
|
|
@@ -1272,6 +1545,17 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1272
1545
|
this.onPresenceChange?.()
|
|
1273
1546
|
})
|
|
1274
1547
|
}
|
|
1548
|
+
|
|
1549
|
+
if (result.docChanges.diffs && this.onCommittedChanges) {
|
|
1550
|
+
const diff = result.docChanges.diffs.diff
|
|
1551
|
+
queueMicrotask(() => {
|
|
1552
|
+
try {
|
|
1553
|
+
this.onCommittedChanges?.({ diff, documentClock })
|
|
1554
|
+
} catch (e) {
|
|
1555
|
+
this.log?.error?.('onCommittedChanges threw', e)
|
|
1556
|
+
}
|
|
1557
|
+
})
|
|
1558
|
+
}
|
|
1275
1559
|
}
|
|
1276
1560
|
|
|
1277
1561
|
/**
|
package/src/lib/TLSyncStorage.ts
CHANGED
|
@@ -83,7 +83,15 @@ 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']
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
/**
|
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()
|