@tldraw/sync-core 5.3.0-internal.d205573d66cb → 5.3.0-next.0362a8b4a246

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.
@@ -121,6 +121,69 @@ export interface RoomSnapshot {
121
121
  schema?: SerializedSchema
122
122
  }
123
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
+
124
187
  /**
125
188
  * A collaborative workspace that manages multiple client sessions and synchronizes
126
189
  * document changes between them. The room serves as the authoritative source for
@@ -248,8 +311,33 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
248
311
  public readonly schema: StoreSchema<R, any>
249
312
  private onPresenceChange?(): void
250
313
  private onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
314
+ private readonly authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
251
315
  private readonly sessionIdleTimeout: number
252
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
+
253
341
  constructor(opts: {
254
342
  log?: TLSyncLog
255
343
  schema: StoreSchema<R, any>
@@ -267,6 +355,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
267
355
  * gated per session by `objectAccess` rather than `isReadonly`.
268
356
  */
269
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>
270
363
  storage: TLSyncStorage<R>
271
364
  clientTimeout?: number
272
365
  }) {
@@ -274,6 +367,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
274
367
  this.log = opts.log
275
368
  this.onPresenceChange = opts.onPresenceChange
276
369
  this.onCommittedChanges = opts.onCommittedChanges
370
+ this.authorizeRecord = opts.authorizeRecord
277
371
  this.storage = opts.storage
278
372
  this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
279
373
 
@@ -315,6 +409,15 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
315
409
 
316
410
  this.presenceType = presenceTypes.values().next()?.value ?? null
317
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
+
318
421
  const { documentClock } = this.storage.transaction((txn) => {
319
422
  this.schema.migrateStorage(txn)
320
423
  })
@@ -1079,7 +1182,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1079
1182
  storage: MinimalDocStore<R>,
1080
1183
  changes: ActualChanges,
1081
1184
  id: string,
1082
- _state: R
1185
+ _state: R,
1186
+ authorize?: (prev: R | null, next: R) => R | null
1083
1187
  ): Result<void, void> => {
1084
1188
  const res = session
1085
1189
  ? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
@@ -1087,11 +1191,19 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1087
1191
  if (res.type === 'error') {
1088
1192
  throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
1089
1193
  }
1090
- const { value: state } = res
1194
+ let { value: state } = res
1091
1195
 
1092
1196
  // Get the existing document, if any
1093
1197
  const doc = storage.get(id) as R | undefined
1094
1198
 
1199
+ // Authorize on the up-migrated record; on create the authorizer's return is stored as-is,
1200
+ // so no later migration can clobber stamped fields.
1201
+ if (authorize) {
1202
+ const result = authorize(doc ?? null, state)
1203
+ if (!result) return Result.ok(undefined) // vetoed: skip the op, the client self-corrects
1204
+ if (!doc) state = result // create: store the authorizer's (stamped) record
1205
+ }
1206
+
1095
1207
  if (doc) {
1096
1208
  // If there's an existing document, replace it with the new state
1097
1209
  // but propagate a diff rather than the entire value
@@ -1118,7 +1230,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1118
1230
  storage: MinimalDocStore<R>,
1119
1231
  changes: ActualChanges,
1120
1232
  id: string,
1121
- patch: ObjectDiff
1233
+ patch: ObjectDiff,
1234
+ authorize?: (prev: R, next: R) => R | null
1122
1235
  ) => {
1123
1236
  // if it was already deleted, there's no need to apply the patch
1124
1237
  const doc = storage.get(id) as R | undefined
@@ -1138,6 +1251,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1138
1251
  // If the versions are compatible, apply the patch and propagate the patch op
1139
1252
  const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode)
1140
1253
  if (diff) {
1254
+ // Authorize on the committed candidate — the record that will actually be stored.
1255
+ if (authorize && !authorize(doc, diff[1])) return
1141
1256
  storage.set(id, diff[1])
1142
1257
  propagateOp(changes, id, [RecordOpType.Patch, diff[0]], doc, diff[1])
1143
1258
  }
@@ -1157,6 +1272,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1157
1272
  // replace the state with the upgraded version and propagate the patch op
1158
1273
  const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode)
1159
1274
  if (diff) {
1275
+ // Authorize on the committed candidate — the upgraded record, not a raw preview.
1276
+ if (authorize && !authorize(doc, upgraded.value)) return
1160
1277
  storage.set(id, upgraded.value)
1161
1278
  propagateOp(changes, id, [RecordOpType.Patch, diff], doc, upgraded.value)
1162
1279
  }
@@ -1227,7 +1344,72 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1227
1344
  TLSyncErrorCloseEventReason.INVALID_RECORD
1228
1345
  )
1229
1346
  }
1230
- addDocument(txn, docChanges, id, op[1])
1347
+ const record = op[1]
1348
+ // A put must not change the record's typeName. Two reasons, and the first
1349
+ // applies whether or not authorizers are configured: the write gate above
1350
+ // keyed off the *incoming* typeName, so a swap between lanes (document <->
1351
+ // object) would let a session denied on one lane write through the other by
1352
+ // relabeling an existing record. The second is that the authorizer lookup
1353
+ // also keys off the incoming typeName while the replace path validates
1354
+ // against the stored one, so a swap would consult the wrong authorizer (or
1355
+ // none). Skip it like a veto; the client self-corrects.
1356
+ const prevRecord = session ? ((txn.get(id) as R | undefined) ?? null) : null
1357
+ if (session && prevRecord && prevRecord.typeName !== record.typeName) {
1358
+ this.log?.warn?.(
1359
+ 'skipping put that changes typeName',
1360
+ `${prevRecord.typeName} -> ${record.typeName}`,
1361
+ id,
1362
+ 'session:',
1363
+ session.sessionId
1364
+ )
1365
+ continue
1366
+ }
1367
+ // Per-type authorizer: stamp/veto the write from the session's identity.
1368
+ // Client pushes only; runs inside `addDocument`, on the up-migrated record.
1369
+ let authorize: ((prev: R | null, next: R) => R | null) | undefined
1370
+ if (session && this.authorizeRecord) {
1371
+ const authorizePut = this.authorizerFor(record.typeName)
1372
+ if (authorizePut) {
1373
+ authorize = (prevRec, next) => {
1374
+ const result = authorizePut(
1375
+ prevRec
1376
+ ? {
1377
+ session: {
1378
+ sessionId: session.sessionId,
1379
+ isReadonly: session.isReadonly,
1380
+ meta: session.meta,
1381
+ },
1382
+ type: 'update',
1383
+ prev: prevRec,
1384
+ next,
1385
+ }
1386
+ : {
1387
+ session: {
1388
+ sessionId: session.sessionId,
1389
+ isReadonly: session.isReadonly,
1390
+ meta: session.meta,
1391
+ },
1392
+ type: 'create',
1393
+ prev: null,
1394
+ next,
1395
+ }
1396
+ )
1397
+ if (!result) {
1398
+ this.log?.warn?.(
1399
+ 'authorizer vetoed put',
1400
+ record.typeName,
1401
+ id,
1402
+ 'session:',
1403
+ session.sessionId
1404
+ )
1405
+ return null
1406
+ }
1407
+ // create: store the stamped record; update: allow/veto only
1408
+ return prevRec ? next : result
1409
+ }
1410
+ }
1411
+ }
1412
+ addDocument(txn, docChanges, id, record, authorize)
1231
1413
  break
1232
1414
  }
1233
1415
  case RecordOpType.Patch: {
@@ -1235,17 +1417,69 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1235
1417
  // if it was already deleted, there's no need to apply the patch
1236
1418
  if (!doc) continue
1237
1419
  if (!canWrite(doc.typeName)) continue
1420
+ // Per-type authorizer (update, allow/veto only): runs inside `patchDocument`
1421
+ // on the committed candidate, never on a raw client-version preview.
1422
+ const authorizePatch = session && this.authorizerFor(doc.typeName)
1423
+ const authorize = authorizePatch
1424
+ ? (prev: R, next: R) => {
1425
+ const result = authorizePatch({
1426
+ session: {
1427
+ sessionId: session.sessionId,
1428
+ isReadonly: session.isReadonly,
1429
+ meta: session.meta,
1430
+ },
1431
+ type: 'update',
1432
+ prev,
1433
+ next,
1434
+ })
1435
+ if (!result) {
1436
+ this.log?.warn?.(
1437
+ 'authorizer vetoed patch',
1438
+ doc.typeName,
1439
+ id,
1440
+ 'session:',
1441
+ session.sessionId
1442
+ )
1443
+ }
1444
+ return result
1445
+ }
1446
+ : undefined
1238
1447
  // Try to patch the document. If it fails, stop here.
1239
- patchDocument(txn, docChanges, id, op[1])
1448
+ patchDocument(txn, docChanges, id, op[1], authorize)
1240
1449
  break
1241
1450
  }
1242
1451
  case RecordOpType.Remove: {
1243
- const doc = txn.get(id)
1452
+ const doc = txn.get(id) as R | undefined
1244
1453
  if (!doc) {
1245
1454
  // If the doc was already deleted, don't do anything, no need to propagate a delete op
1246
1455
  continue
1247
1456
  }
1248
1457
  if (!canWrite(doc.typeName)) continue
1458
+ // Per-type authorizer (delete): veto deletes the session isn't allowed to make,
1459
+ // e.g. deleting someone else's comment. Allow/veto only.
1460
+ const authorizeRemove = session && this.authorizerFor(doc.typeName)
1461
+ if (
1462
+ authorizeRemove &&
1463
+ !authorizeRemove({
1464
+ session: {
1465
+ sessionId: session.sessionId,
1466
+ isReadonly: session.isReadonly,
1467
+ meta: session.meta,
1468
+ },
1469
+ type: 'delete',
1470
+ prev: doc,
1471
+ next: null,
1472
+ })
1473
+ ) {
1474
+ this.log?.warn?.(
1475
+ 'authorizer vetoed delete',
1476
+ doc.typeName,
1477
+ id,
1478
+ 'session:',
1479
+ session.sessionId
1480
+ )
1481
+ continue
1482
+ }
1249
1483
 
1250
1484
  // Delete the document and propagate the delete op
1251
1485
  // delete automatically creates tombstones
@@ -83,6 +83,15 @@ function makeRoom(
83
83
  snapshot?: RoomSnapshot
84
84
  objectTypes?: readonly string[]
85
85
  onCommittedChanges?(args: { diff: any; documentClock: number }): void
86
+ authorizeRecord?: {
87
+ [typeName: string]: (args: {
88
+ session: { sessionId: string; isReadonly: boolean; meta: any }
89
+ type: 'create' | 'update' | 'delete'
90
+ prev: any
91
+ next: any
92
+ }) => any
93
+ }
94
+ log?: any
86
95
  } = {}
87
96
  ) {
88
97
  const storage = new InMemorySyncStorage<R>({
@@ -100,6 +109,8 @@ function makeRoom(
100
109
  storage,
101
110
  objectTypes: opts.objectTypes,
102
111
  onCommittedChanges: opts.onCommittedChanges,
112
+ authorizeRecord: opts.authorizeRecord,
113
+ log: opts.log,
103
114
  })
104
115
  disposables.push(() => room.close())
105
116
  return { storage, room }
@@ -112,13 +123,14 @@ function connectSession(
112
123
  isReadonly?: boolean
113
124
  objectAccess?: 'read' | 'write'
114
125
  clear?: boolean
126
+ meta?: any
115
127
  } = {}
116
128
  ): MockSocket {
117
129
  const socket = makeSocket()
118
130
  room.handleNewSession({
119
131
  sessionId,
120
132
  socket,
121
- meta: undefined,
133
+ meta: opts.meta,
122
134
  isReadonly: opts.isReadonly ?? false,
123
135
  objectAccess: opts.objectAccess,
124
136
  })
@@ -384,4 +396,284 @@ describe('object store lane', () => {
384
396
  expect(onCommittedChanges).not.toHaveBeenCalled()
385
397
  })
386
398
  })
399
+
400
+ describe('authorizeRecord', () => {
401
+ it('rejects a presence typeName key at construction', () => {
402
+ expect(() => makeRoom({ authorizeRecord: { presence: ({ next }: any) => next } })).toThrow(
403
+ /presence/
404
+ )
405
+ })
406
+
407
+ // A per-type authorizer like dotcom's: stamp the note's text from the session's user id on
408
+ // create, keep it immutable on update, allow deletes.
409
+ const authorizeNote = ({ session, type, prev, next }: any) => {
410
+ if (type === 'create') {
411
+ const userId = session.meta?.userId
412
+ if (!userId) return null
413
+ return { ...next, text: `by:${userId}` }
414
+ }
415
+ if (type === 'update') return next.text === prev.text ? next : null
416
+ return prev // delete allowed
417
+ }
418
+
419
+ function withNote(authorize: any, extra: any = {}) {
420
+ return makeRoom({ objectTypes: ['note'], authorizeRecord: { note: authorize }, ...extra })
421
+ }
422
+
423
+ function seededWithNote(authorize: any) {
424
+ const note = Note.create({ text: 'by:user-alice' })
425
+ const { room, storage } = withNote(authorize, {
426
+ snapshot: {
427
+ documents: [{ state: note, lastChangedClock: 0 }],
428
+ clock: 0,
429
+ documentClock: 0,
430
+ schema: schema.serialize(),
431
+ },
432
+ })
433
+ return { room, storage, note }
434
+ }
435
+
436
+ const storedNote = (storage: InMemorySyncStorage<R>, id: string) =>
437
+ storage.getObjectsSnapshot().find((d) => d.state.id === id)?.state as any
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
+
459
+ it('stamps a created record from the session, overriding the client value', () => {
460
+ const { room, storage } = withNote(authorizeNote)
461
+ const socket = connectSession(room, 'alice', { meta: { userId: 'user-alice' } })
462
+
463
+ const note = Note.create({ text: 'forged' })
464
+ push(room, 'alice', { [note.id]: [RecordOpType.Put, note] })
465
+
466
+ // the server rewrote the record, so it rebases the client onto the stamped value
467
+ expect(lastPushResult(socket)).toMatchObject({
468
+ action: {
469
+ rebaseWithDiff: { [note.id]: [RecordOpType.Put, { ...note, text: 'by:user-alice' }] },
470
+ },
471
+ })
472
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
473
+ })
474
+
475
+ it('rejects a create the authorizer denies (returns null)', () => {
476
+ const { room, storage } = withNote(authorizeNote)
477
+ const socket = connectSession(room, 'anon', { meta: { userId: null } })
478
+
479
+ const note = Note.create({ text: 'nope' })
480
+ push(room, 'anon', { [note.id]: [RecordOpType.Put, note] })
481
+
482
+ expect(socket.close).not.toHaveBeenCalled()
483
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
484
+ expect(storedIds(storage)).toEqual([])
485
+ })
486
+
487
+ it('only runs for registered types — other records write through untouched', () => {
488
+ const seen: string[] = []
489
+ const { room, storage } = withNote(({ next }: any) => {
490
+ seen.push('note')
491
+ return next
492
+ })
493
+ connectSession(room, 'writer', { meta: { userId: 'u' } })
494
+
495
+ const doc = Doc.create({ title: 'hi' })
496
+ push(room, 'writer', { [doc.id]: [RecordOpType.Put, doc] })
497
+
498
+ expect(seen).toEqual([])
499
+ expect(storedIds(storage)).toEqual([doc.id])
500
+ })
501
+
502
+ it('authorizes document-lane record types', () => {
503
+ // authorize `doc`, a document-lane record
504
+ const { room, storage } = makeRoom({
505
+ objectTypes: ['note'],
506
+ authorizeRecord: {
507
+ doc: ({ type, next }: any) => (type === 'create' ? { ...next, title: 'stamped' } : next),
508
+ },
509
+ })
510
+ connectSession(room, 'writer', { meta: { userId: 'u' } })
511
+
512
+ const doc = Doc.create({ title: 'client' })
513
+ push(room, 'writer', { [doc.id]: [RecordOpType.Put, doc] })
514
+
515
+ const stored = storage.getSnapshot().documents.find((d) => d.state.id === doc.id)
516
+ ?.state as any
517
+ expect(stored?.title).toBe('stamped')
518
+ })
519
+
520
+ it('vetoes an update that changes an immutable field (patch)', () => {
521
+ const { room, storage, note } = seededWithNote(authorizeNote)
522
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
523
+
524
+ push(room, 'mallory', {
525
+ [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'tampered'] }],
526
+ })
527
+
528
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
529
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
530
+ })
531
+
532
+ it('allows an update the authorizer permits (patch)', () => {
533
+ const { room, storage, note } = seededWithNote(({ type, prev, next }: any) =>
534
+ type === 'delete' ? prev : next
535
+ )
536
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
537
+
538
+ push(room, 'alice', {
539
+ [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'edited'] }],
540
+ })
541
+
542
+ expect(storedNote(storage, note.id)?.text).toBe('edited')
543
+ })
544
+
545
+ it('does not consult the authorizer for a no-op patch', () => {
546
+ const authorize = vi.fn(({ type, prev, next }: any) => (type === 'delete' ? prev : next))
547
+ const { room, note } = seededWithNote(authorize)
548
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
549
+ authorize.mockClear()
550
+
551
+ push(room, 'alice', {
552
+ [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, note.text] }],
553
+ })
554
+
555
+ expect(authorize).not.toHaveBeenCalled()
556
+ })
557
+
558
+ it('vetoes a delete the authorizer rejects', () => {
559
+ const { room, storage, note } = seededWithNote(({ type, next }: any) =>
560
+ type === 'delete' ? null : next
561
+ )
562
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'm' } })
563
+
564
+ push(room, 'mallory', { [note.id]: [RecordOpType.Remove] })
565
+
566
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
567
+ expect(storedIds(storage)).toEqual([note.id])
568
+ })
569
+
570
+ it('vetoes a put that changes the typeName of an existing record', () => {
571
+ const { room, storage, note } = seededWithNote(authorizeNote)
572
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
573
+
574
+ // a `doc`-shaped record reusing the note's id: the authorizer lookup keys off the incoming
575
+ // typeName, so the swap would consult the doc authorizer (here: none) instead of note's
576
+ push(room, 'mallory', {
577
+ [note.id]: [RecordOpType.Put, { id: note.id, typeName: 'doc', title: 'swapped' } as any],
578
+ })
579
+
580
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
581
+ expect(storedNote(storage, note.id)?.typeName).toBe('note')
582
+ })
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
+
613
+ it('vetoes a put over an existing record that changes an immutable field', () => {
614
+ const { room, storage, note } = seededWithNote(authorizeNote)
615
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
616
+
617
+ push(room, 'mallory', { [note.id]: [RecordOpType.Put, { ...note, text: 'tampered' }] })
618
+
619
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
620
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
621
+ })
622
+
623
+ it('ignores the record returned by an update authorizer (allow/veto only)', () => {
624
+ const { room, storage, note } = seededWithNote(({ type, prev, next }: any) => {
625
+ if (type === 'update') return { ...next, text: 'stamped-on-update' }
626
+ return type === 'delete' ? prev : next
627
+ })
628
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
629
+
630
+ push(room, 'alice', { [note.id]: [RecordOpType.Put, { ...note, text: 'edited' }] })
631
+
632
+ expect(storedNote(storage, note.id)?.text).toBe('edited')
633
+ })
634
+
635
+ it('rejects the write (fail closed) when the authorizer throws, without crashing the push', () => {
636
+ const { room, storage } = withNote(() => {
637
+ throw new Error('boom')
638
+ })
639
+ const socket = connectSession(room, 'alice', { meta: { userId: 'u' } })
640
+ const note = Note.create({ text: 'x' })
641
+
642
+ expect(() => push(room, 'alice', { [note.id]: [RecordOpType.Put, note] })).not.toThrow()
643
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
644
+ expect(storedIds(storage)).toEqual([])
645
+ })
646
+
647
+ it('passes the change type for create, update, and delete', () => {
648
+ const types: string[] = []
649
+ const note = Note.create({ text: 'x' })
650
+ const { room } = withNote(({ type, prev, next }: any) => {
651
+ types.push(type)
652
+ return type === 'delete' ? prev : next
653
+ })
654
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
655
+
656
+ push(room, 'alice', { [note.id]: [RecordOpType.Put, note] }, 1)
657
+ push(room, 'alice', { [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'y'] }] }, 2)
658
+ push(room, 'alice', { [note.id]: [RecordOpType.Remove] }, 3)
659
+
660
+ expect(types).toEqual(['create', 'update', 'delete'])
661
+ })
662
+
663
+ it('logs a warning when a write is vetoed', () => {
664
+ const warn = vi.fn()
665
+ const note = Note.create({ text: 'by:user-alice' })
666
+ const { room } = makeRoom({
667
+ objectTypes: ['note'],
668
+ authorizeRecord: { note: () => null },
669
+ log: { warn },
670
+ })
671
+ connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
672
+
673
+ push(room, 'mallory', { [note.id]: [RecordOpType.Put, note] })
674
+
675
+ expect(warn).toHaveBeenCalledTimes(1)
676
+ expect(warn.mock.calls[0].join(' ')).toContain(note.id)
677
+ })
678
+ })
387
679
  })