@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.
- package/dist-cjs/index.d.ts +78 -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 +117 -5
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-esm/index.d.mts +78 -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 +117 -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 +218 -6
- package/src/test/objectStore.test.ts +244 -1
- package/src/test/upgradeDowngrade.test.ts +97 -2
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
})
|