@tldraw/sync-core 5.3.0-internal.fba91ed55f6c → 5.3.0-next.030bf6014f04

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.
@@ -155,8 +155,10 @@ export interface RoomSnapshot {
155
155
  */
156
156
  export type TLRecordAuthorizer<Rec extends UnknownRecord, SessionMeta> = (
157
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 }
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 }
160
162
  } & (
161
163
  | { type: 'create'; prev: null; next: Rec }
162
164
  | { type: 'update'; prev: Rec; next: Rec }
@@ -1181,7 +1183,10 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1181
1183
  changes: ActualChanges,
1182
1184
  id: string,
1183
1185
  _state: R,
1184
- authorize?: (prev: R | null, next: R) => R | null
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
1185
1190
  ): Result<void, void> => {
1186
1191
  const res = session
1187
1192
  ? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
@@ -1192,7 +1197,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1192
1197
  let { value: state } = res
1193
1198
 
1194
1199
  // Get the existing document, if any
1195
- const doc = storage.get(id) as R | undefined
1200
+ const doc =
1201
+ prevDoc !== undefined ? (prevDoc ?? undefined) : (storage.get(id) as R | undefined)
1196
1202
 
1197
1203
  // Authorize on the up-migrated record; on create the authorizer's return is stored as-is,
1198
1204
  // so no later migration can clobber stamped fields.
@@ -1229,10 +1235,12 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1229
1235
  changes: ActualChanges,
1230
1236
  id: string,
1231
1237
  patch: ObjectDiff,
1232
- authorize?: (prev: R, next: R) => R | null
1238
+ authorize?: (prev: R, next: R) => R | null,
1239
+ // The existing document if the caller already fetched it (see `addDocument`).
1240
+ prevDoc?: R
1233
1241
  ) => {
1234
1242
  // if it was already deleted, there's no need to apply the patch
1235
- const doc = storage.get(id) as R | undefined
1243
+ const doc = prevDoc ?? (storage.get(id) as R | undefined)
1236
1244
  if (!doc) return
1237
1245
 
1238
1246
  const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName))
@@ -1343,38 +1351,51 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1343
1351
  )
1344
1352
  }
1345
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
+ }
1346
1374
  // Per-type authorizer: stamp/veto the write from the session's identity.
1347
1375
  // Client pushes only; runs inside `addDocument`, on the up-migrated record.
1348
1376
  let authorize: ((prev: R | null, next: R) => R | null) | undefined
1349
1377
  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
1378
  const authorizePut = this.authorizerFor(record.typeName)
1366
1379
  if (authorizePut) {
1367
1380
  authorize = (prevRec, next) => {
1368
1381
  const result = authorizePut(
1369
1382
  prevRec
1370
1383
  ? {
1371
- session: { sessionId: session.sessionId, meta: session.meta },
1384
+ session: {
1385
+ sessionId: session.sessionId,
1386
+ isReadonly: session.isReadonly,
1387
+ meta: session.meta,
1388
+ },
1372
1389
  type: 'update',
1373
1390
  prev: prevRec,
1374
1391
  next,
1375
1392
  }
1376
1393
  : {
1377
- session: { sessionId: session.sessionId, meta: session.meta },
1394
+ session: {
1395
+ sessionId: session.sessionId,
1396
+ isReadonly: session.isReadonly,
1397
+ meta: session.meta,
1398
+ },
1378
1399
  type: 'create',
1379
1400
  prev: null,
1380
1401
  next,
@@ -1395,7 +1416,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1395
1416
  }
1396
1417
  }
1397
1418
  }
1398
- addDocument(txn, docChanges, id, record, authorize)
1419
+ // The guard above already fetched this, so don't hit storage again.
1420
+ addDocument(txn, docChanges, id, record, authorize, prevRecord)
1399
1421
  break
1400
1422
  }
1401
1423
  case RecordOpType.Patch: {
@@ -1409,7 +1431,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1409
1431
  const authorize = authorizePatch
1410
1432
  ? (prev: R, next: R) => {
1411
1433
  const result = authorizePatch({
1412
- session: { sessionId: session.sessionId, meta: session.meta },
1434
+ session: {
1435
+ sessionId: session.sessionId,
1436
+ isReadonly: session.isReadonly,
1437
+ meta: session.meta,
1438
+ },
1413
1439
  type: 'update',
1414
1440
  prev,
1415
1441
  next,
@@ -1426,8 +1452,9 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1426
1452
  return result
1427
1453
  }
1428
1454
  : undefined
1429
- // Try to patch the document. If it fails, stop here.
1430
- patchDocument(txn, docChanges, id, op[1], authorize)
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)
1431
1458
  break
1432
1459
  }
1433
1460
  case RecordOpType.Remove: {
@@ -1443,7 +1470,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1443
1470
  if (
1444
1471
  authorizeRemove &&
1445
1472
  !authorizeRemove({
1446
- session: { sessionId: session.sessionId, meta: session.meta },
1473
+ session: {
1474
+ sessionId: session.sessionId,
1475
+ isReadonly: session.isReadonly,
1476
+ meta: session.meta,
1477
+ },
1447
1478
  type: 'delete',
1448
1479
  prev: doc,
1449
1480
  next: null,
@@ -92,6 +92,13 @@ export interface TLSyncStorage<R extends UnknownRecord> {
92
92
  * `RoomSnapshot['documents']` so a lane can be persisted and re-seeded via a merged snapshot.
93
93
  */
94
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']
95
102
  }
96
103
 
97
104
  /**
@@ -85,7 +85,7 @@ function makeRoom(
85
85
  onCommittedChanges?(args: { diff: any; documentClock: number }): void
86
86
  authorizeRecord?: {
87
87
  [typeName: string]: (args: {
88
- session: { sessionId: string; meta: any }
88
+ session: { sessionId: string; isReadonly: boolean; meta: any }
89
89
  type: 'create' | 'update' | 'delete'
90
90
  prev: any
91
91
  next: any
@@ -436,6 +436,26 @@ describe('object store lane', () => {
436
436
  const storedNote = (storage: InMemorySyncStorage<R>, id: string) =>
437
437
  storage.getObjectsSnapshot().find((d) => d.state.id === id)?.state as any
438
438
 
439
+ it('exposes the session isReadonly flag to the authorizer', () => {
440
+ const authorize = vi.fn(({ next }: any) => next)
441
+ const { room } = withNote(authorize)
442
+ // a canvas-readonly session can still write the object lane (objectAccess defaults to
443
+ // 'write'), which is exactly why authorizers need to see isReadonly independently
444
+ connectSession(room, 'reader', { isReadonly: true, meta: { userId: 'u' } })
445
+
446
+ const note = Note.create({ text: 'x' })
447
+ push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
448
+
449
+ expect(authorize.mock.calls[0][0].session.isReadonly).toBe(true)
450
+
451
+ authorize.mockClear()
452
+ connectSession(room, 'writer', { isReadonly: false, meta: { userId: 'u' } })
453
+ const note2 = Note.create({ text: 'y' })
454
+ push(room, 'writer', { [note2.id]: [RecordOpType.Put, note2] })
455
+
456
+ expect(authorize.mock.calls[0][0].session.isReadonly).toBe(false)
457
+ })
458
+
439
459
  it('stamps a created record from the session, overriding the client value', () => {
440
460
  const { room, storage } = withNote(authorizeNote)
441
461
  const socket = connectSession(room, 'alice', { meta: { userId: 'user-alice' } })
@@ -561,6 +581,35 @@ describe('object store lane', () => {
561
581
  expect(storedNote(storage, note.id)?.typeName).toBe('note')
562
582
  })
563
583
 
584
+ // The typeName guard must not depend on authorizers being configured: the write gate keys
585
+ // off the *incoming* typeName, so relabeling an existing object-lane record as a document
586
+ // type would otherwise let an objectAccess:'read' session write through the document lane.
587
+ it('vetoes a typeName swap even with no authorizeRecord configured', () => {
588
+ const note = Note.create({ text: 'by:user-alice' })
589
+ const { room, storage } = makeRoom({
590
+ objectTypes: ['note'],
591
+ snapshot: {
592
+ documents: [{ state: note, lastChangedClock: 0 }],
593
+ clock: 0,
594
+ documentClock: 0,
595
+ schema: schema.serialize(),
596
+ },
597
+ })
598
+ // denied on the object lane, but allowed on the document lane
599
+ const socket = connectSession(room, 'mallory', {
600
+ objectAccess: 'read',
601
+ meta: { userId: 'user-mallory' },
602
+ })
603
+
604
+ push(room, 'mallory', {
605
+ [note.id]: [RecordOpType.Put, { id: note.id, typeName: 'doc', title: 'swapped' } as any],
606
+ })
607
+
608
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
609
+ expect(storedNote(storage, note.id)?.typeName).toBe('note')
610
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
611
+ })
612
+
564
613
  it('vetoes a put over an existing record that changes an immutable field', () => {
565
614
  const { room, storage, note } = seededWithNote(authorizeNote)
566
615
  const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
@@ -128,6 +128,28 @@ export function registerStorageContractTests(factory: StorageContractFactory) {
128
128
  })
129
129
  })
130
130
 
131
+ it('returns targeted object-lane entries by id from getObjectsByIds', () => {
132
+ const storage = createPartitioned(makeContractSnapshot(contractRecords))
133
+ const newPage = makePage('lane')
134
+ storage.transaction((txn) => txn.set(newPage.id, newPage))
135
+
136
+ const pageId = contractRecords[1].id
137
+ const entries = storage.getObjectsByIds!([newPage.id, pageId])
138
+ expect(entries.map((d) => d.state.id).sort()).toEqual([newPage.id, pageId].sort())
139
+ // same entry shape as getObjectsSnapshot: state + lastChangedClock
140
+ const laneEntry = entries.find((d) => d.state.id === newPage.id)!
141
+ expect(laneEntry.state).toEqual(newPage)
142
+ expect(laneEntry.lastChangedClock).toBe(storage.getClock())
143
+
144
+ // unknown ids and document-lane ids are omitted; duplicates yield one entry
145
+ expect(storage.getObjectsByIds!(['page:nope', TLDOCUMENT_ID])).toEqual([])
146
+ expect(storage.getObjectsByIds!([newPage.id, newPage.id]).length).toBe(1)
147
+
148
+ // a deleted record stops being returned
149
+ storage.transaction((txn) => txn.delete(newPage.id))
150
+ expect(storage.getObjectsByIds!([newPage.id])).toEqual([])
151
+ })
152
+
131
153
  it('round-trips through a merged snapshot (persist lanes separately, re-seed together)', () => {
132
154
  const storage = createPartitioned(makeContractSnapshot(contractRecords))
133
155
  const merged = {