@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.
- package/dist-cjs/index.d.ts +81 -0
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +1 -0
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +133 -5
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-esm/index.d.mts +81 -0
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +1 -0
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +133 -5
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/package.json +6 -6
- package/src/index.ts +2 -0
- package/src/lib/TLSocketRoom.ts +8 -1
- package/src/lib/TLSyncRoom.ts +240 -6
- package/src/test/objectStore.test.ts +293 -1
- package/src/test/upgradeDowngrade.test.ts +97 -2
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
})
|