@tldraw/sync-core 5.3.0-canary.fceaae5e9feb → 5.3.0-internal.c5c7f1d817d0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist-cjs/index.d.ts +154 -3
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +55 -20
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/SQLiteSyncStorage.js +115 -52
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +27 -2
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +6 -1
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +160 -10
  14. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  15. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  16. package/dist-cjs/lib/protocol.js.map +2 -2
  17. package/dist-esm/index.d.mts +154 -3
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +55 -20
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/SQLiteSyncStorage.mjs +115 -52
  24. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  25. package/dist-esm/lib/TLSocketRoom.mjs +27 -2
  26. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncClient.mjs +6 -1
  28. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  29. package/dist-esm/lib/TLSyncRoom.mjs +160 -10
  30. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  31. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  32. package/dist-esm/lib/protocol.mjs.map +2 -2
  33. package/package.json +6 -6
  34. package/src/index.ts +3 -0
  35. package/src/lib/InMemorySyncStorage.ts +74 -21
  36. package/src/lib/RoomSession.ts +7 -2
  37. package/src/lib/SQLiteSyncStorage.test.ts +29 -2
  38. package/src/lib/SQLiteSyncStorage.ts +158 -59
  39. package/src/lib/TLSocketRoom.ts +61 -3
  40. package/src/lib/TLSyncClient.test.ts +9 -2
  41. package/src/lib/TLSyncClient.ts +15 -3
  42. package/src/lib/TLSyncRoom.ts +294 -10
  43. package/src/lib/TLSyncStorage.ts +8 -0
  44. package/src/lib/protocol.ts +17 -0
  45. package/src/test/TLSocketRoom.test.ts +37 -2
  46. package/src/test/TLSyncClientRebase.test.ts +285 -0
  47. package/src/test/TLSyncRoom.test.ts +25 -0
  48. package/src/test/TestServer.ts +14 -4
  49. package/src/test/objectStore.test.ts +630 -0
  50. package/src/test/storageContractSuite.ts +79 -0
  51. package/src/test/upgradeDowngrade.test.ts +144 -2
@@ -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
- const { value: state } = res
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
- if (message.diff && !session?.isReadonly) {
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 (!this.documentTypes.has(op[1].typeName)) {
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
- addDocument(txn, docChanges, id, op[1])
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
  /**
@@ -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
  /**
@@ -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(room: TLSocketRoom<any, any>, sessionId: string, socket: WebSocketMinimal) {
52
- room.handleSocketConnect({ sessionId, socket })
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()