cojson 0.8.39 → 0.8.44

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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +12 -0
  2. package/CHANGELOG.md +14 -0
  3. package/dist/native/coValueCore.js +22 -5
  4. package/dist/native/coValueCore.js.map +1 -1
  5. package/dist/native/coValues/coMap.js +98 -103
  6. package/dist/native/coValues/coMap.js.map +1 -1
  7. package/dist/native/coValues/coStream.js +17 -6
  8. package/dist/native/coValues/coStream.js.map +1 -1
  9. package/dist/native/coValues/group.js +127 -39
  10. package/dist/native/coValues/group.js.map +1 -1
  11. package/dist/native/exports.js +2 -0
  12. package/dist/native/exports.js.map +1 -1
  13. package/dist/native/localNode.js +5 -2
  14. package/dist/native/localNode.js.map +1 -1
  15. package/dist/native/permissions.js +77 -19
  16. package/dist/native/permissions.js.map +1 -1
  17. package/dist/web/coValueCore.js +22 -5
  18. package/dist/web/coValueCore.js.map +1 -1
  19. package/dist/web/coValues/coMap.js +98 -103
  20. package/dist/web/coValues/coMap.js.map +1 -1
  21. package/dist/web/coValues/coStream.js +17 -6
  22. package/dist/web/coValues/coStream.js.map +1 -1
  23. package/dist/web/coValues/group.js +127 -39
  24. package/dist/web/coValues/group.js.map +1 -1
  25. package/dist/web/exports.js +2 -0
  26. package/dist/web/exports.js.map +1 -1
  27. package/dist/web/localNode.js +5 -2
  28. package/dist/web/localNode.js.map +1 -1
  29. package/dist/web/permissions.js +77 -19
  30. package/dist/web/permissions.js.map +1 -1
  31. package/package.json +3 -5
  32. package/src/coValueCore.ts +37 -9
  33. package/src/coValues/coMap.ts +126 -127
  34. package/src/coValues/coStream.ts +27 -10
  35. package/src/coValues/group.ts +218 -50
  36. package/src/exports.ts +2 -0
  37. package/src/localNode.ts +5 -2
  38. package/src/permissions.ts +105 -24
  39. package/src/tests/coMap.test.ts +2 -2
  40. package/src/tests/group.test.ts +332 -38
  41. package/src/tests/permissions.test.ts +324 -0
  42. package/src/tests/testUtils.ts +18 -13
@@ -13,7 +13,7 @@ import {
13
13
  isParentGroupReference,
14
14
  } from "../ids.js";
15
15
  import { JsonObject } from "../jsonValue.js";
16
- import { Role } from "../permissions.js";
16
+ import { AccountRole, Role } from "../permissions.js";
17
17
  import { expectGroup } from "../typeUtils/expectGroup.js";
18
18
  import {
19
19
  ControlledAccountOrAgent,
@@ -33,6 +33,7 @@ export type GroupShape = {
33
33
  [key: RawAccountID | AgentID]: Role;
34
34
  [EVERYONE]?: Role;
35
35
  readKey?: KeyID;
36
+ [writeKeyFor: `writeKeyFor_${RawAccountID | AgentID}`]: KeyID;
36
37
  [revelationFor: `${KeyID}_for_${RawAccountID | AgentID}`]: Sealed<KeySecret>;
37
38
  [revelationFor: `${KeyID}_for_${Everyone}`]: KeySecret;
38
39
  [oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
@@ -93,7 +94,7 @@ export class RawGroup<
93
94
  }
94
95
  | undefined = roleHere && { role: roleHere, via: undefined };
95
96
 
96
- const parentGroups = this.getParentGroups(this.options?.atTime);
97
+ const parentGroups = this.getParentGroups(this.atTimeFilter);
97
98
 
98
99
  for (const parentGroup of parentGroups) {
99
100
  const roleInParent = parentGroup.roleOfInternal(accountID);
@@ -213,18 +214,19 @@ export class RawGroup<
213
214
  account: RawAccount | ControlledAccountOrAgent | AgentID | Everyone,
214
215
  role: Role,
215
216
  ) {
216
- const currentReadKey = this.core.getCurrentReadKey();
217
-
218
- if (!currentReadKey.secret) {
219
- throw new Error("Can't add member without read key secret");
220
- }
221
-
222
217
  if (account === EVERYONE) {
223
218
  if (!(role === "reader" || role === "writer")) {
224
219
  throw new Error(
225
220
  "Can't make everyone something other than reader or writer",
226
221
  );
227
222
  }
223
+
224
+ const currentReadKey = this.core.getCurrentReadKey();
225
+
226
+ if (!currentReadKey.secret) {
227
+ throw new Error("Can't add member without read key secret");
228
+ }
229
+
228
230
  this.set(account, role, "trusting");
229
231
 
230
232
  if (this.get(account) !== role) {
@@ -236,44 +238,168 @@ export class RawGroup<
236
238
  currentReadKey.secret,
237
239
  "trusting",
238
240
  );
241
+
242
+ return;
243
+ }
244
+
245
+ const memberKey = typeof account === "string" ? account : account.id;
246
+ const agent =
247
+ typeof account === "string"
248
+ ? account
249
+ : account.currentAgentID()._unsafeUnwrap({ withStackTrace: true });
250
+
251
+ /**
252
+ * WriteOnly members can only see their own changes.
253
+ *
254
+ * We don't want to reveal the readKey to them so we create a new one specifically for them and also reveal it to everyone else with a reader or higher-capability role (but crucially not to other writer-only members)
255
+ * to everyone else.
256
+ *
257
+ * To never reveal the readKey to writeOnly members we also create a dedicated writeKey for the
258
+ * invite.
259
+ */
260
+ if (role === "writeOnly" || role === "writeOnlyInvite") {
261
+ const writeKeyForNewMember = this.core.crypto.newRandomKeySecret();
262
+
263
+ this.set(memberKey, role, "trusting");
264
+ this.set(`writeKeyFor_${memberKey}`, writeKeyForNewMember.id, "trusting");
265
+
266
+ this.storeKeyRevelationForMember(
267
+ memberKey,
268
+ agent,
269
+ writeKeyForNewMember.id,
270
+ writeKeyForNewMember.secret,
271
+ );
272
+
273
+ for (const otherMemberKey of this.getMemberKeys()) {
274
+ const memberRole = this.get(otherMemberKey);
275
+
276
+ if (
277
+ memberRole === "reader" ||
278
+ memberRole === "writer" ||
279
+ memberRole === "admin" ||
280
+ memberRole === "readerInvite" ||
281
+ memberRole === "writerInvite" ||
282
+ memberRole === "adminInvite"
283
+ ) {
284
+ const otherMemberAgent = this.core.node
285
+ .resolveAccountAgent(
286
+ otherMemberKey,
287
+ "Expected member agent to be loaded",
288
+ )
289
+ ._unsafeUnwrap({ withStackTrace: true });
290
+
291
+ this.storeKeyRevelationForMember(
292
+ otherMemberKey,
293
+ otherMemberAgent,
294
+ writeKeyForNewMember.id,
295
+ writeKeyForNewMember.secret,
296
+ );
297
+ }
298
+ }
239
299
  } else {
240
- const memberKey = typeof account === "string" ? account : account.id;
241
- const agent =
242
- typeof account === "string"
243
- ? account
244
- : account.currentAgentID()._unsafeUnwrap({ withStackTrace: true });
300
+ const currentReadKey = this.core.getCurrentReadKey();
301
+
302
+ if (!currentReadKey.secret) {
303
+ throw new Error("Can't add member without read key secret");
304
+ }
305
+
245
306
  this.set(memberKey, role, "trusting");
246
307
 
247
308
  if (this.get(memberKey) !== role) {
248
309
  throw new Error("Failed to set role");
249
310
  }
250
311
 
251
- this.set(
252
- `${currentReadKey.id}_for_${memberKey}`,
253
- this.core.crypto.seal({
254
- message: currentReadKey.secret,
255
- from: this.core.node.account.currentSealerSecret(),
256
- to: this.core.crypto.getAgentSealerID(agent),
257
- nOnceMaterial: {
258
- in: this.id,
259
- tx: this.core.nextTransactionID(),
260
- },
261
- }),
262
- "trusting",
312
+ this.storeKeyRevelationForMember(
313
+ memberKey,
314
+ agent,
315
+ currentReadKey.id,
316
+ currentReadKey.secret,
263
317
  );
318
+
319
+ for (const keyID of this.getWriteOnlyKeys()) {
320
+ const secret = this.core.getReadKey(keyID);
321
+
322
+ if (!secret) {
323
+ console.error("Can't find key", keyID);
324
+ continue;
325
+ }
326
+
327
+ this.storeKeyRevelationForMember(memberKey, agent, keyID, secret);
328
+ }
264
329
  }
265
330
  }
266
331
 
332
+ private storeKeyRevelationForMember(
333
+ memberKey: RawAccountID | AgentID,
334
+ agent: AgentID,
335
+ keyID: KeyID,
336
+ secret: KeySecret,
337
+ ) {
338
+ this.set(
339
+ `${keyID}_for_${memberKey}`,
340
+ this.core.crypto.seal({
341
+ message: secret,
342
+ from: this.core.node.account.currentSealerSecret(),
343
+ to: this.core.crypto.getAgentSealerID(agent),
344
+ nOnceMaterial: {
345
+ in: this.id,
346
+ tx: this.core.nextTransactionID(),
347
+ },
348
+ }),
349
+ "trusting",
350
+ );
351
+ }
352
+
353
+ private getWriteOnlyKeys() {
354
+ const keys: KeyID[] = [];
355
+
356
+ for (const key of this.keys()) {
357
+ if (key.startsWith("writeKeyFor_")) {
358
+ keys.push(
359
+ this.get(key as `writeKeyFor_${RawAccountID | AgentID}`) as KeyID,
360
+ );
361
+ }
362
+ }
363
+
364
+ return keys;
365
+ }
366
+
367
+ getCurrentReadKeyId() {
368
+ if (this.myRole() === "writeOnly") {
369
+ const accountId = this.core.node.account.id;
370
+
371
+ return this.get(`writeKeyFor_${accountId}`) as KeyID;
372
+ }
373
+
374
+ return this.get("readKey");
375
+ }
376
+
377
+ getMemberKeys(): (RawAccountID | AgentID)[] {
378
+ return this.keys().filter((key): key is RawAccountID | AgentID => {
379
+ return key.startsWith("co_") || isAgentID(key);
380
+ });
381
+ }
382
+
267
383
  /** @internal */
268
384
  rotateReadKey() {
269
- const currentlyPermittedReaders = this.keys().filter((key) => {
270
- if (key.startsWith("co_") || isAgentID(key)) {
271
- const role = this.get(key);
272
- return role === "admin" || role === "writer" || role === "reader";
273
- } else {
274
- return false;
275
- }
276
- }) as (RawAccountID | AgentID)[];
385
+ const memberKeys = this.getMemberKeys();
386
+
387
+ const currentlyPermittedReaders = memberKeys.filter((key) => {
388
+ const role = this.get(key);
389
+ return (
390
+ role === "admin" ||
391
+ role === "writer" ||
392
+ role === "reader" ||
393
+ role === "adminInvite" ||
394
+ role === "writerInvite" ||
395
+ role === "readerInvite"
396
+ );
397
+ });
398
+
399
+ const writeOnlyMembers = memberKeys.filter((key) => {
400
+ const role = this.get(key);
401
+ return role === "writeOnly" || role === "writeOnlyInvite";
402
+ });
277
403
 
278
404
  // Get these early, so we fail fast if they are unavailable
279
405
  const parentGroups = this.getParentGroups();
@@ -293,28 +419,60 @@ export class RawGroup<
293
419
  const newReadKey = this.core.crypto.newRandomKeySecret();
294
420
 
295
421
  for (const readerID of currentlyPermittedReaders) {
296
- const reader = this.core.node
422
+ const agent = this.core.node
297
423
  .resolveAccountAgent(
298
424
  readerID,
299
425
  "Expected to know currently permitted reader",
300
426
  )
301
427
  ._unsafeUnwrap({ withStackTrace: true });
302
428
 
303
- this.set(
304
- `${newReadKey.id}_for_${readerID}`,
305
- this.core.crypto.seal({
306
- message: newReadKey.secret,
307
- from: this.core.node.account.currentSealerSecret(),
308
- to: this.core.crypto.getAgentSealerID(reader),
309
- nOnceMaterial: {
310
- in: this.id,
311
- tx: this.core.nextTransactionID(),
312
- },
313
- }),
314
- "trusting",
429
+ this.storeKeyRevelationForMember(
430
+ readerID,
431
+ agent,
432
+ newReadKey.id,
433
+ newReadKey.secret,
315
434
  );
316
435
  }
317
436
 
437
+ /**
438
+ * If there are some writeOnly members we need to rotate their keys
439
+ * and reveal them to the other non-writeOnly members
440
+ */
441
+ for (const writeOnlyMemberID of writeOnlyMembers) {
442
+ const agent = this.core.node
443
+ .resolveAccountAgent(
444
+ writeOnlyMemberID,
445
+ "Expected to know writeOnly member",
446
+ )
447
+ ._unsafeUnwrap({ withStackTrace: true });
448
+
449
+ const writeOnlyKey = this.core.crypto.newRandomKeySecret();
450
+
451
+ this.storeKeyRevelationForMember(
452
+ writeOnlyMemberID,
453
+ agent,
454
+ writeOnlyKey.id,
455
+ writeOnlyKey.secret,
456
+ );
457
+ this.set(`writeKeyFor_${writeOnlyMemberID}`, writeOnlyKey.id, "trusting");
458
+
459
+ for (const readerID of currentlyPermittedReaders) {
460
+ const agent = this.core.node
461
+ .resolveAccountAgent(
462
+ readerID,
463
+ "Expected to know currently permitted reader",
464
+ )
465
+ ._unsafeUnwrap({ withStackTrace: true });
466
+
467
+ this.storeKeyRevelationForMember(
468
+ readerID,
469
+ agent,
470
+ writeOnlyKey.id,
471
+ writeOnlyKey.secret,
472
+ );
473
+ }
474
+ }
475
+
318
476
  this.set(
319
477
  `${currentReadKey.id}_for_${newReadKey.id}`,
320
478
  this.core.crypto.encryptKeySecret({
@@ -326,8 +484,11 @@ export class RawGroup<
326
484
 
327
485
  this.set("readKey", newReadKey.id, "trusting");
328
486
 
329
- // when we rotate our readKey (because someone got kicked out), we also need to (recursively)
330
- // rotate the readKeys of all child groups (so they are kicked out there as well)
487
+ /**
488
+ * The new read key needs to be revealed to the parent groups
489
+ *
490
+ * This way the members from the parent groups can still have access to this group
491
+ */
331
492
  for (const parent of parentGroups) {
332
493
  const { id: parentReadKeyID, secret: parentReadKeySecret } =
333
494
  parent.core.getCurrentReadKey();
@@ -426,7 +587,7 @@ export class RawGroup<
426
587
  *
427
588
  * @category 2. Role changing
428
589
  */
429
- createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
590
+ createInvite(role: AccountRole): InviteSecret {
430
591
  const secretSeed = this.core.crypto.newRandomSecretSeed();
431
592
 
432
593
  const inviteSecret = this.core.crypto.agentSecretFromSecretSeed(secretSeed);
@@ -558,13 +719,20 @@ function isMorePermissiveAndShouldInherit(
558
719
  }
559
720
 
560
721
  if (roleInParent === "writer") {
561
- return !roleInChild || roleInChild === "reader";
722
+ return (
723
+ !roleInChild || roleInChild === "reader" || roleInChild === "writeOnly"
724
+ );
562
725
  }
563
726
 
564
727
  if (roleInParent === "reader") {
565
728
  return !roleInChild;
566
729
  }
567
730
 
731
+ // writeOnly can't be inherited
732
+ if (roleInParent === "writeOnly") {
733
+ return false;
734
+ }
735
+
568
736
  return false;
569
737
  }
570
738
 
package/src/exports.ts CHANGED
@@ -53,6 +53,7 @@ import type { AgentSecret } from "./crypto/crypto.js";
53
53
  import type { AgentID, RawCoID, SessionID } from "./ids.js";
54
54
  import type { JsonValue } from "./jsonValue.js";
55
55
  import type * as Media from "./media.js";
56
+ import { disablePermissionErrors } from "./permissions.js";
56
57
  import type {
57
58
  IncomingSyncStream,
58
59
  OutgoingSyncQueue,
@@ -91,6 +92,7 @@ export const cojsonInternals = {
91
92
  getPriorityFromHeader,
92
93
  getGroupDependentKeyList,
93
94
  getGroupDependentKey,
95
+ disablePermissionErrors,
94
96
  };
95
97
 
96
98
  export {
package/src/localNode.ts CHANGED
@@ -387,7 +387,8 @@ export class LocalNode {
387
387
  existingRole === "admin" ||
388
388
  (existingRole === "writer" && inviteRole === "writerInvite") ||
389
389
  (existingRole === "writer" && inviteRole === "reader") ||
390
- (existingRole === "reader" && inviteRole === "readerInvite")
390
+ (existingRole === "reader" && inviteRole === "readerInvite") ||
391
+ (existingRole && inviteRole === "writeOnlyInvite")
391
392
  ) {
392
393
  console.debug(
393
394
  "Not accepting invite that would replace or downgrade role",
@@ -410,7 +411,9 @@ export class LocalNode {
410
411
  ? "admin"
411
412
  : inviteRole === "writerInvite"
412
413
  ? "writer"
413
- : "reader",
414
+ : inviteRole === "writeOnlyInvite"
415
+ ? "writeOnly"
416
+ : "reader",
414
417
  );
415
418
 
416
419
  group.core._sessionLogs = groupAsInvite.core.sessionLogs;
@@ -22,18 +22,33 @@ export type PermissionsDef =
22
22
  | { type: "ownedByGroup"; group: RawCoID }
23
23
  | { type: "unsafeAllowAll" };
24
24
 
25
+ export type AccountRole = "reader" | "writer" | "admin" | "writeOnly";
26
+
25
27
  export type Role =
26
- | "reader"
27
- | "writer"
28
- | "admin"
28
+ | AccountRole
29
29
  | "revoked"
30
30
  | "adminInvite"
31
31
  | "writerInvite"
32
- | "readerInvite";
32
+ | "readerInvite"
33
+ | "writeOnlyInvite";
33
34
 
34
35
  type ValidTransactionsResult = { txID: TransactionID; tx: Transaction };
35
36
  type MemberState = { [agent: RawAccountID | AgentID]: Role; [EVERYONE]?: Role };
36
37
 
38
+ let logPermissionErrors = true;
39
+
40
+ export function disablePermissionErrors() {
41
+ logPermissionErrors = false;
42
+ }
43
+
44
+ function logPermissionError(...args: unknown[]) {
45
+ if (logPermissionErrors === false) {
46
+ return;
47
+ }
48
+
49
+ console.warn(...args);
50
+ }
51
+
37
52
  export function determineValidTransactions(
38
53
  coValue: CoValueCore,
39
54
  ): { txID: TransactionID; tx: Transaction }[] {
@@ -81,7 +96,8 @@ export function determineValidTransactions(
81
96
 
82
97
  if (
83
98
  transactorRoleAtTxTime !== "admin" &&
84
- transactorRoleAtTxTime !== "writer"
99
+ transactorRoleAtTxTime !== "writer" &&
100
+ transactorRoleAtTxTime !== "writeOnly"
85
101
  ) {
86
102
  return;
87
103
  }
@@ -177,6 +193,9 @@ function determineValidTransactionsForGroup(
177
193
  const memberState: MemberState = {};
178
194
  const validTransactions: ValidTransactionsResult[] = [];
179
195
 
196
+ const keyRevelations = new Set<string>();
197
+ const writeKeys = new Set<string>();
198
+
180
199
  for (const { sessionID, txIndex, tx } of allTransactionsSorted) {
181
200
  // console.log("before", { memberState, validTransactions });
182
201
  const transactor = accountOrAgentIDfromSessionID(sessionID);
@@ -189,7 +208,9 @@ function determineValidTransactionsForGroup(
189
208
  });
190
209
  continue;
191
210
  } else {
192
- console.warn("Only admins can make private transactions in groups");
211
+ logPermissionError(
212
+ "Only admins can make private transactions in groups",
213
+ );
193
214
  continue;
194
215
  }
195
216
  }
@@ -199,7 +220,7 @@ function determineValidTransactionsForGroup(
199
220
  try {
200
221
  changes = parseJSON(tx.changes);
201
222
  } catch (e) {
202
- console.warn(
223
+ logPermissionError(
203
224
  coValue.id,
204
225
  "Invalid JSON in transaction",
205
226
  e,
@@ -221,18 +242,18 @@ function determineValidTransactionsForGroup(
221
242
  | MapOpPayload<`child_${CoID<RawGroup>}`, CoID<RawGroup>>;
222
243
 
223
244
  if (changes.length !== 1) {
224
- console.warn("Group transaction must have exactly one change");
245
+ logPermissionError("Group transaction must have exactly one change");
225
246
  continue;
226
247
  }
227
248
 
228
249
  if (change.op !== "set") {
229
- console.warn("Group transaction must set a role or readKey");
250
+ logPermissionError("Group transaction must set a role or readKey");
230
251
  continue;
231
252
  }
232
253
 
233
254
  if (change.key === "readKey") {
234
255
  if (memberState[transactor] !== "admin") {
235
- console.warn("Only admins can set readKeys");
256
+ logPermissionError("Only admins can set readKeys");
236
257
  continue;
237
258
  }
238
259
 
@@ -240,7 +261,7 @@ function determineValidTransactionsForGroup(
240
261
  continue;
241
262
  } else if (change.key === "profile") {
242
263
  if (memberState[transactor] !== "admin") {
243
- console.warn("Only admins can set profile");
264
+ logPermissionError("Only admins can set profile");
244
265
  continue;
245
266
  }
246
267
 
@@ -254,19 +275,36 @@ function determineValidTransactionsForGroup(
254
275
  memberState[transactor] !== "admin" &&
255
276
  memberState[transactor] !== "adminInvite" &&
256
277
  memberState[transactor] !== "writerInvite" &&
257
- memberState[transactor] !== "readerInvite"
278
+ memberState[transactor] !== "readerInvite" &&
279
+ memberState[transactor] !== "writeOnlyInvite"
258
280
  ) {
259
- console.warn("Only admins can reveal keys");
281
+ logPermissionError("Only admins can reveal keys");
260
282
  continue;
261
283
  }
262
284
 
263
- // TODO: check validity of agents who the key is revealed to?
285
+ /**
286
+ * We don't want to give the ability to invite members to override
287
+ * key revelations, otherwise they could hide a key revelation to any user
288
+ * blocking them from accessing the group.
289
+ */
290
+ if (
291
+ keyRevelations.has(change.key) &&
292
+ memberState[transactor] !== "admin"
293
+ ) {
294
+ logPermissionError(
295
+ "Key revelation already exists and can't be overridden by invite",
296
+ );
297
+ continue;
298
+ }
264
299
 
300
+ keyRevelations.add(change.key);
301
+
302
+ // TODO: check validity of agents who the key is revealed to?
265
303
  validTransactions.push({ txID: { sessionID, txIndex }, tx });
266
304
  continue;
267
305
  } else if (isParentExtension(change.key)) {
268
306
  if (memberState[transactor] !== "admin") {
269
- console.warn("Only admins can set parent extensions");
307
+ logPermissionError("Only admins can set parent extensions");
270
308
  continue;
271
309
  }
272
310
  resolveMemberStateFromParentReference(coValue, memberState, change.key);
@@ -274,9 +312,37 @@ function determineValidTransactionsForGroup(
274
312
  continue;
275
313
  } else if (isChildExtension(change.key)) {
276
314
  if (memberState[transactor] !== "admin") {
277
- console.warn("Only admins can set child extensions");
315
+ logPermissionError("Only admins can set child extensions");
278
316
  continue;
279
317
  }
318
+ validTransactions.push({ txID: { sessionID, txIndex }, tx });
319
+ continue;
320
+ } else if (isWriteKeyForMember(change.key)) {
321
+ if (
322
+ memberState[transactor] !== "admin" &&
323
+ memberState[transactor] !== "writeOnlyInvite"
324
+ ) {
325
+ logPermissionError("Only admins can set writeKeys");
326
+ continue;
327
+ }
328
+
329
+ /**
330
+ * writeOnlyInvite need to be able to set writeKeys because every new writeOnly
331
+ * member comes with their own write key.
332
+ *
333
+ * We don't want to give the ability to invite members to override
334
+ * write keys, otherwise they could hide a write key to other writeOnly users
335
+ * blocking them from accessing the group.ß
336
+ */
337
+ if (writeKeys.has(change.key) && memberState[transactor] !== "admin") {
338
+ logPermissionError(
339
+ "Write key already exists and can't be overridden by invite",
340
+ );
341
+ continue;
342
+ }
343
+
344
+ writeKeys.add(change.key);
345
+
280
346
  validTransactions.push({ txID: { sessionID, txIndex }, tx });
281
347
  continue;
282
348
  }
@@ -288,12 +354,14 @@ function determineValidTransactionsForGroup(
288
354
  change.value !== "admin" &&
289
355
  change.value !== "writer" &&
290
356
  change.value !== "reader" &&
357
+ change.value !== "writeOnly" &&
291
358
  change.value !== "revoked" &&
292
359
  change.value !== "adminInvite" &&
293
360
  change.value !== "writerInvite" &&
294
- change.value !== "readerInvite"
361
+ change.value !== "readerInvite" &&
362
+ change.value !== "writeOnlyInvite"
295
363
  ) {
296
- console.warn("Group transaction must set a valid role");
364
+ logPermissionError("Group transaction must set a valid role");
297
365
  continue;
298
366
  }
299
367
 
@@ -305,7 +373,9 @@ function determineValidTransactionsForGroup(
305
373
  change.value === "revoked"
306
374
  )
307
375
  ) {
308
- console.warn("Everyone can only be set to reader, writer or revoked");
376
+ logPermissionError(
377
+ "Everyone can only be set to reader, writer or revoked",
378
+ );
309
379
  continue;
310
380
  }
311
381
 
@@ -323,26 +393,31 @@ function determineValidTransactionsForGroup(
323
393
  affectedMember !== transactor &&
324
394
  assignedRole !== "admin"
325
395
  ) {
326
- console.warn("Admins can only demote themselves.");
396
+ logPermissionError("Admins can only demote themselves.");
327
397
  continue;
328
398
  }
329
399
  } else if (memberState[transactor] === "adminInvite") {
330
400
  if (change.value !== "admin") {
331
- console.warn("AdminInvites can only create admins.");
401
+ logPermissionError("AdminInvites can only create admins.");
332
402
  continue;
333
403
  }
334
404
  } else if (memberState[transactor] === "writerInvite") {
335
405
  if (change.value !== "writer") {
336
- console.warn("WriterInvites can only create writers.");
406
+ logPermissionError("WriterInvites can only create writers.");
337
407
  continue;
338
408
  }
339
409
  } else if (memberState[transactor] === "readerInvite") {
340
410
  if (change.value !== "reader") {
341
- console.warn("ReaderInvites can only create reader.");
411
+ logPermissionError("ReaderInvites can only create reader.");
412
+ continue;
413
+ }
414
+ } else if (memberState[transactor] === "writeOnlyInvite") {
415
+ if (change.value !== "writeOnly") {
416
+ logPermissionError("WriteOnlyInvites can only create writeOnly.");
342
417
  continue;
343
418
  }
344
419
  } else {
345
- console.warn(
420
+ logPermissionError(
346
421
  "Group transaction must be made by current admin or invite",
347
422
  );
348
423
  continue;
@@ -377,6 +452,12 @@ function agentInAccountOrMemberInGroup(
377
452
  return transactor;
378
453
  }
379
454
 
455
+ export function isWriteKeyForMember(
456
+ co: string,
457
+ ): co is `writeKeyFor_${RawAccountID | AgentID}` {
458
+ return co.startsWith("writeKeyFor_");
459
+ }
460
+
380
461
  export function isKeyForKeyField(co: string): co is `${KeyID}_for_${KeyID}` {
381
462
  return co.startsWith("key_") && co.includes("_for_key");
382
463
  }
@@ -75,10 +75,10 @@ test("Can get CoMap entry values at different points in time", () => {
75
75
  expect(content.atTime(beforeB).get("hello")).toEqual("A");
76
76
  expect(content.atTime(beforeC).get("hello")).toEqual("B");
77
77
 
78
- const ops = content.timeFilteredOps("hello");
78
+ const ops = content.ops["hello"]!;
79
79
 
80
80
  expect(content.atTime(beforeC).lastEditAt("hello")).toEqual(
81
- operationToEditEntry(ops![1]!),
81
+ operationToEditEntry(ops[1]!),
82
82
  );
83
83
  expect(content.atTime(beforeC).nthEditAt("hello", 0)).toEqual(
84
84
  operationToEditEntry(ops![0]!),