cojson 0.8.39 → 0.8.41

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 (36) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/native/coValueCore.js +22 -5
  3. package/dist/native/coValueCore.js.map +1 -1
  4. package/dist/native/coValues/coMap.js +98 -103
  5. package/dist/native/coValues/coMap.js.map +1 -1
  6. package/dist/native/coValues/coStream.js +17 -6
  7. package/dist/native/coValues/coStream.js.map +1 -1
  8. package/dist/native/coValues/group.js +127 -39
  9. package/dist/native/coValues/group.js.map +1 -1
  10. package/dist/native/localNode.js +5 -2
  11. package/dist/native/localNode.js.map +1 -1
  12. package/dist/native/permissions.js +51 -3
  13. package/dist/native/permissions.js.map +1 -1
  14. package/dist/web/coValueCore.js +22 -5
  15. package/dist/web/coValueCore.js.map +1 -1
  16. package/dist/web/coValues/coMap.js +98 -103
  17. package/dist/web/coValues/coMap.js.map +1 -1
  18. package/dist/web/coValues/coStream.js +17 -6
  19. package/dist/web/coValues/coStream.js.map +1 -1
  20. package/dist/web/coValues/group.js +127 -39
  21. package/dist/web/coValues/group.js.map +1 -1
  22. package/dist/web/localNode.js +5 -2
  23. package/dist/web/localNode.js.map +1 -1
  24. package/dist/web/permissions.js +51 -3
  25. package/dist/web/permissions.js.map +1 -1
  26. package/package.json +3 -5
  27. package/src/coValueCore.ts +37 -9
  28. package/src/coValues/coMap.ts +126 -127
  29. package/src/coValues/coStream.ts +27 -10
  30. package/src/coValues/group.ts +218 -50
  31. package/src/localNode.ts +5 -2
  32. package/src/permissions.ts +71 -8
  33. package/src/tests/coMap.test.ts +2 -2
  34. package/src/tests/group.test.ts +332 -38
  35. package/src/tests/permissions.test.ts +324 -0
  36. 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/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,14 +22,15 @@ 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 };
@@ -81,7 +82,8 @@ export function determineValidTransactions(
81
82
 
82
83
  if (
83
84
  transactorRoleAtTxTime !== "admin" &&
84
- transactorRoleAtTxTime !== "writer"
85
+ transactorRoleAtTxTime !== "writer" &&
86
+ transactorRoleAtTxTime !== "writeOnly"
85
87
  ) {
86
88
  return;
87
89
  }
@@ -177,6 +179,9 @@ function determineValidTransactionsForGroup(
177
179
  const memberState: MemberState = {};
178
180
  const validTransactions: ValidTransactionsResult[] = [];
179
181
 
182
+ const keyRevelations = new Set<string>();
183
+ const writeKeys = new Set<string>();
184
+
180
185
  for (const { sessionID, txIndex, tx } of allTransactionsSorted) {
181
186
  // console.log("before", { memberState, validTransactions });
182
187
  const transactor = accountOrAgentIDfromSessionID(sessionID);
@@ -254,14 +259,31 @@ function determineValidTransactionsForGroup(
254
259
  memberState[transactor] !== "admin" &&
255
260
  memberState[transactor] !== "adminInvite" &&
256
261
  memberState[transactor] !== "writerInvite" &&
257
- memberState[transactor] !== "readerInvite"
262
+ memberState[transactor] !== "readerInvite" &&
263
+ memberState[transactor] !== "writeOnlyInvite"
258
264
  ) {
259
265
  console.warn("Only admins can reveal keys");
260
266
  continue;
261
267
  }
262
268
 
263
- // TODO: check validity of agents who the key is revealed to?
269
+ /**
270
+ * We don't want to give the ability to invite members to override
271
+ * key revelations, otherwise they could hide a key revelation to any user
272
+ * blocking them from accessing the group.
273
+ */
274
+ if (
275
+ keyRevelations.has(change.key) &&
276
+ memberState[transactor] !== "admin"
277
+ ) {
278
+ console.warn(
279
+ "Key revelation already exists and can't be overridden by invite",
280
+ );
281
+ continue;
282
+ }
283
+
284
+ keyRevelations.add(change.key);
264
285
 
286
+ // TODO: check validity of agents who the key is revealed to?
265
287
  validTransactions.push({ txID: { sessionID, txIndex }, tx });
266
288
  continue;
267
289
  } else if (isParentExtension(change.key)) {
@@ -277,6 +299,34 @@ function determineValidTransactionsForGroup(
277
299
  console.warn("Only admins can set child extensions");
278
300
  continue;
279
301
  }
302
+ validTransactions.push({ txID: { sessionID, txIndex }, tx });
303
+ continue;
304
+ } else if (isWriteKeyForMember(change.key)) {
305
+ if (
306
+ memberState[transactor] !== "admin" &&
307
+ memberState[transactor] !== "writeOnlyInvite"
308
+ ) {
309
+ console.warn("Only admins can set writeKeys");
310
+ continue;
311
+ }
312
+
313
+ /**
314
+ * writeOnlyInvite need to be able to set writeKeys because every new writeOnly
315
+ * member comes with their own write key.
316
+ *
317
+ * We don't want to give the ability to invite members to override
318
+ * write keys, otherwise they could hide a write key to other writeOnly users
319
+ * blocking them from accessing the group.ß
320
+ */
321
+ if (writeKeys.has(change.key) && memberState[transactor] !== "admin") {
322
+ console.warn(
323
+ "Write key already exists and can't be overridden by invite",
324
+ );
325
+ continue;
326
+ }
327
+
328
+ writeKeys.add(change.key);
329
+
280
330
  validTransactions.push({ txID: { sessionID, txIndex }, tx });
281
331
  continue;
282
332
  }
@@ -288,10 +338,12 @@ function determineValidTransactionsForGroup(
288
338
  change.value !== "admin" &&
289
339
  change.value !== "writer" &&
290
340
  change.value !== "reader" &&
341
+ change.value !== "writeOnly" &&
291
342
  change.value !== "revoked" &&
292
343
  change.value !== "adminInvite" &&
293
344
  change.value !== "writerInvite" &&
294
- change.value !== "readerInvite"
345
+ change.value !== "readerInvite" &&
346
+ change.value !== "writeOnlyInvite"
295
347
  ) {
296
348
  console.warn("Group transaction must set a valid role");
297
349
  continue;
@@ -341,6 +393,11 @@ function determineValidTransactionsForGroup(
341
393
  console.warn("ReaderInvites can only create reader.");
342
394
  continue;
343
395
  }
396
+ } else if (memberState[transactor] === "writeOnlyInvite") {
397
+ if (change.value !== "writeOnly") {
398
+ console.warn("WriteOnlyInvites can only create writeOnly.");
399
+ continue;
400
+ }
344
401
  } else {
345
402
  console.warn(
346
403
  "Group transaction must be made by current admin or invite",
@@ -377,6 +434,12 @@ function agentInAccountOrMemberInGroup(
377
434
  return transactor;
378
435
  }
379
436
 
437
+ export function isWriteKeyForMember(
438
+ co: string,
439
+ ): co is `writeKeyFor_${RawAccountID | AgentID}` {
440
+ return co.startsWith("writeKeyFor_");
441
+ }
442
+
380
443
  export function isKeyForKeyField(co: string): co is `${KeyID}_for_${KeyID}` {
381
444
  return co.startsWith("key_") && co.includes("_for_key");
382
445
  }
@@ -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]!),