@tldraw/sync-core 5.3.0-internal.d205573d66cb → 5.3.0-internal.fba91ed55f6c

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,67 @@ 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, 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
+
124
185
  /**
125
186
  * A collaborative workspace that manages multiple client sessions and synchronizes
126
187
  * document changes between them. The room serves as the authoritative source for
@@ -248,8 +309,33 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
248
309
  public readonly schema: StoreSchema<R, any>
249
310
  private onPresenceChange?(): void
250
311
  private onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
312
+ private readonly authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
251
313
  private readonly sessionIdleTimeout: number
252
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
+
253
339
  constructor(opts: {
254
340
  log?: TLSyncLog
255
341
  schema: StoreSchema<R, any>
@@ -267,6 +353,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
267
353
  * gated per session by `objectAccess` rather than `isReadonly`.
268
354
  */
269
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>
270
361
  storage: TLSyncStorage<R>
271
362
  clientTimeout?: number
272
363
  }) {
@@ -274,6 +365,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
274
365
  this.log = opts.log
275
366
  this.onPresenceChange = opts.onPresenceChange
276
367
  this.onCommittedChanges = opts.onCommittedChanges
368
+ this.authorizeRecord = opts.authorizeRecord
277
369
  this.storage = opts.storage
278
370
  this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
279
371
 
@@ -315,6 +407,15 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
315
407
 
316
408
  this.presenceType = presenceTypes.values().next()?.value ?? null
317
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
+
318
419
  const { documentClock } = this.storage.transaction((txn) => {
319
420
  this.schema.migrateStorage(txn)
320
421
  })
@@ -1079,7 +1180,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1079
1180
  storage: MinimalDocStore<R>,
1080
1181
  changes: ActualChanges,
1081
1182
  id: string,
1082
- _state: R
1183
+ _state: R,
1184
+ authorize?: (prev: R | null, next: R) => R | null
1083
1185
  ): Result<void, void> => {
1084
1186
  const res = session
1085
1187
  ? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
@@ -1087,11 +1189,19 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1087
1189
  if (res.type === 'error') {
1088
1190
  throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
1089
1191
  }
1090
- const { value: state } = res
1192
+ let { value: state } = res
1091
1193
 
1092
1194
  // Get the existing document, if any
1093
1195
  const doc = storage.get(id) as R | undefined
1094
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
+
1095
1205
  if (doc) {
1096
1206
  // If there's an existing document, replace it with the new state
1097
1207
  // but propagate a diff rather than the entire value
@@ -1118,7 +1228,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1118
1228
  storage: MinimalDocStore<R>,
1119
1229
  changes: ActualChanges,
1120
1230
  id: string,
1121
- patch: ObjectDiff
1231
+ patch: ObjectDiff,
1232
+ authorize?: (prev: R, next: R) => R | null
1122
1233
  ) => {
1123
1234
  // if it was already deleted, there's no need to apply the patch
1124
1235
  const doc = storage.get(id) as R | undefined
@@ -1138,6 +1249,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1138
1249
  // If the versions are compatible, apply the patch and propagate the patch op
1139
1250
  const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode)
1140
1251
  if (diff) {
1252
+ // Authorize on the committed candidate — the record that will actually be stored.
1253
+ if (authorize && !authorize(doc, diff[1])) return
1141
1254
  storage.set(id, diff[1])
1142
1255
  propagateOp(changes, id, [RecordOpType.Patch, diff[0]], doc, diff[1])
1143
1256
  }
@@ -1157,6 +1270,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1157
1270
  // replace the state with the upgraded version and propagate the patch op
1158
1271
  const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode)
1159
1272
  if (diff) {
1273
+ // Authorize on the committed candidate — the upgraded record, not a raw preview.
1274
+ if (authorize && !authorize(doc, upgraded.value)) return
1160
1275
  storage.set(id, upgraded.value)
1161
1276
  propagateOp(changes, id, [RecordOpType.Patch, diff], doc, upgraded.value)
1162
1277
  }
@@ -1227,7 +1342,60 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1227
1342
  TLSyncErrorCloseEventReason.INVALID_RECORD
1228
1343
  )
1229
1344
  }
1230
- 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)
1231
1399
  break
1232
1400
  }
1233
1401
  case RecordOpType.Patch: {
@@ -1235,17 +1403,61 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1235
1403
  // if it was already deleted, there's no need to apply the patch
1236
1404
  if (!doc) continue
1237
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
1238
1429
  // Try to patch the document. If it fails, stop here.
1239
- patchDocument(txn, docChanges, id, op[1])
1430
+ patchDocument(txn, docChanges, id, op[1], authorize)
1240
1431
  break
1241
1432
  }
1242
1433
  case RecordOpType.Remove: {
1243
- const doc = txn.get(id)
1434
+ const doc = txn.get(id) as R | undefined
1244
1435
  if (!doc) {
1245
1436
  // If the doc was already deleted, don't do anything, no need to propagate a delete op
1246
1437
  continue
1247
1438
  }
1248
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
+ }
1249
1461
 
1250
1462
  // Delete the document and propagate the delete op
1251
1463
  // 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; 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,235 @@ 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('stamps a created record from the session, overriding the client value', () => {
440
+ const { room, storage } = withNote(authorizeNote)
441
+ const socket = connectSession(room, 'alice', { meta: { userId: 'user-alice' } })
442
+
443
+ const note = Note.create({ text: 'forged' })
444
+ push(room, 'alice', { [note.id]: [RecordOpType.Put, note] })
445
+
446
+ // the server rewrote the record, so it rebases the client onto the stamped value
447
+ expect(lastPushResult(socket)).toMatchObject({
448
+ action: {
449
+ rebaseWithDiff: { [note.id]: [RecordOpType.Put, { ...note, text: 'by:user-alice' }] },
450
+ },
451
+ })
452
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
453
+ })
454
+
455
+ it('rejects a create the authorizer denies (returns null)', () => {
456
+ const { room, storage } = withNote(authorizeNote)
457
+ const socket = connectSession(room, 'anon', { meta: { userId: null } })
458
+
459
+ const note = Note.create({ text: 'nope' })
460
+ push(room, 'anon', { [note.id]: [RecordOpType.Put, note] })
461
+
462
+ expect(socket.close).not.toHaveBeenCalled()
463
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
464
+ expect(storedIds(storage)).toEqual([])
465
+ })
466
+
467
+ it('only runs for registered types — other records write through untouched', () => {
468
+ const seen: string[] = []
469
+ const { room, storage } = withNote(({ next }: any) => {
470
+ seen.push('note')
471
+ return next
472
+ })
473
+ connectSession(room, 'writer', { meta: { userId: 'u' } })
474
+
475
+ const doc = Doc.create({ title: 'hi' })
476
+ push(room, 'writer', { [doc.id]: [RecordOpType.Put, doc] })
477
+
478
+ expect(seen).toEqual([])
479
+ expect(storedIds(storage)).toEqual([doc.id])
480
+ })
481
+
482
+ it('authorizes document-lane record types', () => {
483
+ // authorize `doc`, a document-lane record
484
+ const { room, storage } = makeRoom({
485
+ objectTypes: ['note'],
486
+ authorizeRecord: {
487
+ doc: ({ type, next }: any) => (type === 'create' ? { ...next, title: 'stamped' } : next),
488
+ },
489
+ })
490
+ connectSession(room, 'writer', { meta: { userId: 'u' } })
491
+
492
+ const doc = Doc.create({ title: 'client' })
493
+ push(room, 'writer', { [doc.id]: [RecordOpType.Put, doc] })
494
+
495
+ const stored = storage.getSnapshot().documents.find((d) => d.state.id === doc.id)
496
+ ?.state as any
497
+ expect(stored?.title).toBe('stamped')
498
+ })
499
+
500
+ it('vetoes an update that changes an immutable field (patch)', () => {
501
+ const { room, storage, note } = seededWithNote(authorizeNote)
502
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
503
+
504
+ push(room, 'mallory', {
505
+ [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'tampered'] }],
506
+ })
507
+
508
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
509
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
510
+ })
511
+
512
+ it('allows an update the authorizer permits (patch)', () => {
513
+ const { room, storage, note } = seededWithNote(({ type, prev, next }: any) =>
514
+ type === 'delete' ? prev : next
515
+ )
516
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
517
+
518
+ push(room, 'alice', {
519
+ [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'edited'] }],
520
+ })
521
+
522
+ expect(storedNote(storage, note.id)?.text).toBe('edited')
523
+ })
524
+
525
+ it('does not consult the authorizer for a no-op patch', () => {
526
+ const authorize = vi.fn(({ type, prev, next }: any) => (type === 'delete' ? prev : next))
527
+ const { room, note } = seededWithNote(authorize)
528
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
529
+ authorize.mockClear()
530
+
531
+ push(room, 'alice', {
532
+ [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, note.text] }],
533
+ })
534
+
535
+ expect(authorize).not.toHaveBeenCalled()
536
+ })
537
+
538
+ it('vetoes a delete the authorizer rejects', () => {
539
+ const { room, storage, note } = seededWithNote(({ type, next }: any) =>
540
+ type === 'delete' ? null : next
541
+ )
542
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'm' } })
543
+
544
+ push(room, 'mallory', { [note.id]: [RecordOpType.Remove] })
545
+
546
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
547
+ expect(storedIds(storage)).toEqual([note.id])
548
+ })
549
+
550
+ it('vetoes a put that changes the typeName of an existing record', () => {
551
+ const { room, storage, note } = seededWithNote(authorizeNote)
552
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
553
+
554
+ // a `doc`-shaped record reusing the note's id: the authorizer lookup keys off the incoming
555
+ // typeName, so the swap would consult the doc authorizer (here: none) instead of note's
556
+ push(room, 'mallory', {
557
+ [note.id]: [RecordOpType.Put, { id: note.id, typeName: 'doc', title: 'swapped' } as any],
558
+ })
559
+
560
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
561
+ expect(storedNote(storage, note.id)?.typeName).toBe('note')
562
+ })
563
+
564
+ it('vetoes a put over an existing record that changes an immutable field', () => {
565
+ const { room, storage, note } = seededWithNote(authorizeNote)
566
+ const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
567
+
568
+ push(room, 'mallory', { [note.id]: [RecordOpType.Put, { ...note, text: 'tampered' }] })
569
+
570
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
571
+ expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
572
+ })
573
+
574
+ it('ignores the record returned by an update authorizer (allow/veto only)', () => {
575
+ const { room, storage, note } = seededWithNote(({ type, prev, next }: any) => {
576
+ if (type === 'update') return { ...next, text: 'stamped-on-update' }
577
+ return type === 'delete' ? prev : next
578
+ })
579
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
580
+
581
+ push(room, 'alice', { [note.id]: [RecordOpType.Put, { ...note, text: 'edited' }] })
582
+
583
+ expect(storedNote(storage, note.id)?.text).toBe('edited')
584
+ })
585
+
586
+ it('rejects the write (fail closed) when the authorizer throws, without crashing the push', () => {
587
+ const { room, storage } = withNote(() => {
588
+ throw new Error('boom')
589
+ })
590
+ const socket = connectSession(room, 'alice', { meta: { userId: 'u' } })
591
+ const note = Note.create({ text: 'x' })
592
+
593
+ expect(() => push(room, 'alice', { [note.id]: [RecordOpType.Put, note] })).not.toThrow()
594
+ expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
595
+ expect(storedIds(storage)).toEqual([])
596
+ })
597
+
598
+ it('passes the change type for create, update, and delete', () => {
599
+ const types: string[] = []
600
+ const note = Note.create({ text: 'x' })
601
+ const { room } = withNote(({ type, prev, next }: any) => {
602
+ types.push(type)
603
+ return type === 'delete' ? prev : next
604
+ })
605
+ connectSession(room, 'alice', { meta: { userId: 'u' } })
606
+
607
+ push(room, 'alice', { [note.id]: [RecordOpType.Put, note] }, 1)
608
+ push(room, 'alice', { [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'y'] }] }, 2)
609
+ push(room, 'alice', { [note.id]: [RecordOpType.Remove] }, 3)
610
+
611
+ expect(types).toEqual(['create', 'update', 'delete'])
612
+ })
613
+
614
+ it('logs a warning when a write is vetoed', () => {
615
+ const warn = vi.fn()
616
+ const note = Note.create({ text: 'by:user-alice' })
617
+ const { room } = makeRoom({
618
+ objectTypes: ['note'],
619
+ authorizeRecord: { note: () => null },
620
+ log: { warn },
621
+ })
622
+ connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
623
+
624
+ push(room, 'mallory', { [note.id]: [RecordOpType.Put, note] })
625
+
626
+ expect(warn).toHaveBeenCalledTimes(1)
627
+ expect(warn.mock.calls[0].join(' ')).toContain(note.id)
628
+ })
629
+ })
387
630
  })