cojson 0.20.7 → 0.20.9

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 (209) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/dist/SyncStateManager.d.ts.map +1 -1
  4. package/dist/SyncStateManager.js +0 -2
  5. package/dist/SyncStateManager.js.map +1 -1
  6. package/dist/base64url.d.ts +15 -0
  7. package/dist/base64url.d.ts.map +1 -1
  8. package/dist/base64url.js +101 -5
  9. package/dist/base64url.js.map +1 -1
  10. package/dist/base64url.test.js +76 -1
  11. package/dist/base64url.test.js.map +1 -1
  12. package/dist/coValue.d.ts +2 -1
  13. package/dist/coValue.d.ts.map +1 -1
  14. package/dist/coValue.js.map +1 -1
  15. package/dist/coValueCore/coValueCore.d.ts +9 -11
  16. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  17. package/dist/coValueCore/coValueCore.js +92 -65
  18. package/dist/coValueCore/coValueCore.js.map +1 -1
  19. package/dist/coValueCore/verifiedState.d.ts +38 -7
  20. package/dist/coValueCore/verifiedState.d.ts.map +1 -1
  21. package/dist/coValueCore/verifiedState.js +226 -30
  22. package/dist/coValueCore/verifiedState.js.map +1 -1
  23. package/dist/coValues/binaryCoStream.d.ts +63 -0
  24. package/dist/coValues/binaryCoStream.d.ts.map +1 -0
  25. package/dist/coValues/binaryCoStream.js +125 -0
  26. package/dist/coValues/binaryCoStream.js.map +1 -0
  27. package/dist/coValues/coList.d.ts +3 -1
  28. package/dist/coValues/coList.d.ts.map +1 -1
  29. package/dist/coValues/coList.js +15 -6
  30. package/dist/coValues/coList.js.map +1 -1
  31. package/dist/coValues/coMap.d.ts +1 -1
  32. package/dist/coValues/coMap.d.ts.map +1 -1
  33. package/dist/coValues/coMap.js +2 -2
  34. package/dist/coValues/coMap.js.map +1 -1
  35. package/dist/coValues/coStream.d.ts +0 -38
  36. package/dist/coValues/coStream.d.ts.map +1 -1
  37. package/dist/coValues/coStream.js +0 -86
  38. package/dist/coValues/coStream.js.map +1 -1
  39. package/dist/coValues/group.d.ts +44 -6
  40. package/dist/coValues/group.d.ts.map +1 -1
  41. package/dist/coValues/group.js +198 -17
  42. package/dist/coValues/group.js.map +1 -1
  43. package/dist/coreToCoValue.d.ts +2 -1
  44. package/dist/coreToCoValue.d.ts.map +1 -1
  45. package/dist/coreToCoValue.js +2 -1
  46. package/dist/coreToCoValue.js.map +1 -1
  47. package/dist/crypto/NapiCrypto.d.ts +18 -24
  48. package/dist/crypto/NapiCrypto.d.ts.map +1 -1
  49. package/dist/crypto/NapiCrypto.js +98 -60
  50. package/dist/crypto/NapiCrypto.js.map +1 -1
  51. package/dist/crypto/RNCrypto.d.ts +16 -3
  52. package/dist/crypto/RNCrypto.d.ts.map +1 -1
  53. package/dist/crypto/RNCrypto.js +117 -54
  54. package/dist/crypto/RNCrypto.js.map +1 -1
  55. package/dist/crypto/WasmCrypto.d.ts +18 -24
  56. package/dist/crypto/WasmCrypto.d.ts.map +1 -1
  57. package/dist/crypto/WasmCrypto.js +100 -61
  58. package/dist/crypto/WasmCrypto.js.map +1 -1
  59. package/dist/crypto/crypto.d.ts +55 -19
  60. package/dist/crypto/crypto.d.ts.map +1 -1
  61. package/dist/crypto/crypto.js +14 -3
  62. package/dist/crypto/crypto.js.map +1 -1
  63. package/dist/exports.d.ts +7 -3
  64. package/dist/exports.d.ts.map +1 -1
  65. package/dist/exports.js +4 -2
  66. package/dist/exports.js.map +1 -1
  67. package/dist/localNode.d.ts +3 -1
  68. package/dist/localNode.d.ts.map +1 -1
  69. package/dist/localNode.js +10 -3
  70. package/dist/localNode.js.map +1 -1
  71. package/dist/media.d.ts +1 -1
  72. package/dist/media.d.ts.map +1 -1
  73. package/dist/permissions.d.ts +2 -1
  74. package/dist/permissions.d.ts.map +1 -1
  75. package/dist/permissions.js +19 -3
  76. package/dist/permissions.js.map +1 -1
  77. package/dist/storage/sqliteAsync/client.d.ts +24 -12
  78. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  79. package/dist/storage/sqliteAsync/client.js +70 -58
  80. package/dist/storage/sqliteAsync/client.js.map +1 -1
  81. package/dist/storage/sqliteAsync/types.d.ts +1 -1
  82. package/dist/storage/sqliteAsync/types.d.ts.map +1 -1
  83. package/dist/storage/types.d.ts +1 -0
  84. package/dist/storage/types.d.ts.map +1 -1
  85. package/dist/sync.d.ts.map +1 -1
  86. package/dist/sync.js +7 -1
  87. package/dist/sync.js.map +1 -1
  88. package/dist/tests/CojsonMessageChannel.test.js +2 -2
  89. package/dist/tests/SQLiteClientAsync.test.d.ts +2 -0
  90. package/dist/tests/SQLiteClientAsync.test.d.ts.map +1 -0
  91. package/dist/tests/SQLiteClientAsync.test.js +64 -0
  92. package/dist/tests/SQLiteClientAsync.test.js.map +1 -0
  93. package/dist/tests/StorageApiAsync.test.js +2 -8
  94. package/dist/tests/StorageApiAsync.test.js.map +1 -1
  95. package/dist/tests/SyncStateManager.test.js +2 -2
  96. package/dist/tests/WasmCrypto.test.js +1 -15
  97. package/dist/tests/WasmCrypto.test.js.map +1 -1
  98. package/dist/tests/coList.test.js +24 -5
  99. package/dist/tests/coList.test.js.map +1 -1
  100. package/dist/tests/coStream.test.js +4 -3
  101. package/dist/tests/coStream.test.js.map +1 -1
  102. package/dist/tests/coValueCore.initTransaction.test.d.ts +2 -0
  103. package/dist/tests/coValueCore.initTransaction.test.d.ts.map +1 -0
  104. package/dist/tests/coValueCore.initTransaction.test.js +438 -0
  105. package/dist/tests/coValueCore.initTransaction.test.js.map +1 -0
  106. package/dist/tests/coValueCore.test.js +11 -19
  107. package/dist/tests/coValueCore.test.js.map +1 -1
  108. package/dist/tests/crypto.test.js +83 -0
  109. package/dist/tests/crypto.test.js.map +1 -1
  110. package/dist/tests/deleteCoValue.test.js +5 -5
  111. package/dist/tests/deleteCoValue.test.js.map +1 -1
  112. package/dist/tests/group.inheritance.test.js +11 -0
  113. package/dist/tests/group.inheritance.test.js.map +1 -1
  114. package/dist/tests/group.test.js +24 -1
  115. package/dist/tests/group.test.js.map +1 -1
  116. package/dist/tests/groupSealer.test.d.ts +2 -0
  117. package/dist/tests/groupSealer.test.d.ts.map +1 -0
  118. package/dist/tests/groupSealer.test.js +913 -0
  119. package/dist/tests/groupSealer.test.js.map +1 -0
  120. package/dist/tests/setup.js +5 -0
  121. package/dist/tests/setup.js.map +1 -1
  122. package/dist/tests/sync.auth.test.js +10 -10
  123. package/dist/tests/sync.concurrentLoad.test.js +12 -12
  124. package/dist/tests/sync.deleted.test.js +8 -8
  125. package/dist/tests/sync.garbageCollection.test.js +10 -10
  126. package/dist/tests/sync.invite.test.js +12 -12
  127. package/dist/tests/sync.known.test.js +2 -2
  128. package/dist/tests/sync.load.test.js +107 -107
  129. package/dist/tests/sync.mesh.test.js +164 -46
  130. package/dist/tests/sync.mesh.test.js.map +1 -1
  131. package/dist/tests/sync.multipleServers.test.js +43 -43
  132. package/dist/tests/sync.peerReconciliation.test.js +29 -29
  133. package/dist/tests/sync.sharding.test.js +3 -3
  134. package/dist/tests/sync.storage.test.js +104 -104
  135. package/dist/tests/sync.storage.test.js.map +1 -1
  136. package/dist/tests/sync.storageAsync.test.js +56 -56
  137. package/dist/tests/sync.upload.test.js +22 -22
  138. package/dist/tests/testStorage.d.ts +2 -0
  139. package/dist/tests/testStorage.d.ts.map +1 -1
  140. package/dist/tests/testStorage.js +30 -6
  141. package/dist/tests/testStorage.js.map +1 -1
  142. package/dist/typeUtils/isCoValue.js +1 -1
  143. package/dist/typeUtils/isCoValue.js.map +1 -1
  144. package/package.json +4 -4
  145. package/src/SyncStateManager.ts +0 -2
  146. package/src/base64url.test.ts +89 -1
  147. package/src/base64url.ts +134 -6
  148. package/src/coValue.ts +2 -1
  149. package/src/coValueCore/coValueCore.ts +126 -84
  150. package/src/coValueCore/verifiedState.ts +335 -53
  151. package/src/coValues/binaryCoStream.ts +217 -0
  152. package/src/coValues/coList.ts +21 -8
  153. package/src/coValues/coMap.ts +3 -0
  154. package/src/coValues/coStream.ts +0 -170
  155. package/src/coValues/group.ts +270 -21
  156. package/src/coreToCoValue.ts +2 -1
  157. package/src/crypto/NapiCrypto.ts +198 -95
  158. package/src/crypto/RNCrypto.ts +229 -102
  159. package/src/crypto/WasmCrypto.ts +201 -95
  160. package/src/crypto/crypto.ts +118 -45
  161. package/src/exports.ts +11 -5
  162. package/src/localNode.ts +17 -1
  163. package/src/media.ts +1 -1
  164. package/src/permissions.ts +30 -7
  165. package/src/storage/sqliteAsync/client.ts +136 -115
  166. package/src/storage/sqliteAsync/types.ts +3 -1
  167. package/src/storage/types.ts +4 -0
  168. package/src/sync.ts +10 -1
  169. package/src/tests/CojsonMessageChannel.test.ts +2 -2
  170. package/src/tests/SQLiteClientAsync.test.ts +75 -0
  171. package/src/tests/StorageApiAsync.test.ts +4 -9
  172. package/src/tests/SyncStateManager.test.ts +2 -2
  173. package/src/tests/WasmCrypto.test.ts +1 -25
  174. package/src/tests/coList.test.ts +39 -5
  175. package/src/tests/coStream.test.ts +4 -5
  176. package/src/tests/coValueCore.initTransaction.test.ts +836 -0
  177. package/src/tests/coValueCore.test.ts +11 -22
  178. package/src/tests/crypto.test.ts +107 -0
  179. package/src/tests/deleteCoValue.test.ts +5 -5
  180. package/src/tests/group.inheritance.test.ts +16 -0
  181. package/src/tests/group.test.ts +29 -1
  182. package/src/tests/groupSealer.test.ts +1473 -0
  183. package/src/tests/setup.ts +6 -0
  184. package/src/tests/sync.auth.test.ts +10 -10
  185. package/src/tests/sync.concurrentLoad.test.ts +12 -12
  186. package/src/tests/sync.deleted.test.ts +8 -8
  187. package/src/tests/sync.garbageCollection.test.ts +10 -10
  188. package/src/tests/sync.invite.test.ts +12 -12
  189. package/src/tests/sync.known.test.ts +2 -2
  190. package/src/tests/sync.load.test.ts +107 -107
  191. package/src/tests/sync.mesh.test.ts +189 -46
  192. package/src/tests/sync.multipleServers.test.ts +43 -43
  193. package/src/tests/sync.peerReconciliation.test.ts +29 -29
  194. package/src/tests/sync.sharding.test.ts +3 -3
  195. package/src/tests/sync.storage.test.ts +104 -104
  196. package/src/tests/sync.storageAsync.test.ts +56 -56
  197. package/src/tests/sync.upload.test.ts +22 -22
  198. package/src/tests/testStorage.ts +39 -9
  199. package/src/typeUtils/isCoValue.ts +1 -1
  200. package/dist/coValueCore/SessionMap.d.ts +0 -55
  201. package/dist/coValueCore/SessionMap.d.ts.map +0 -1
  202. package/dist/coValueCore/SessionMap.js +0 -206
  203. package/dist/coValueCore/SessionMap.js.map +0 -1
  204. package/dist/tests/coreWasm.test.d.ts +0 -2
  205. package/dist/tests/coreWasm.test.d.ts.map +0 -1
  206. package/dist/tests/coreWasm.test.js +0 -203
  207. package/dist/tests/coreWasm.test.js.map +0 -1
  208. package/src/coValueCore/SessionMap.ts +0 -394
  209. package/src/tests/coreWasm.test.ts +0 -452
@@ -0,0 +1,913 @@
1
+ import { assert, beforeEach, describe, expect, test } from "vitest";
2
+ import { WasmCrypto } from "../crypto/WasmCrypto.js";
3
+ import { expectGroup } from "../typeUtils/expectGroup.js";
4
+ import { SyncMessagesLog, createNConnectedNodes, createTwoConnectedNodes, createThreeConnectedNodes, loadCoValueOrFail, setupTestNode, } from "./testUtils.js";
5
+ const crypto = await WasmCrypto.create();
6
+ /**
7
+ * Creates a group without the groupSealer field, simulating a legacy group
8
+ * created before the groupSealer feature was introduced.
9
+ */
10
+ function createLegacyGroup(node) {
11
+ const account = node.getCurrentAgent();
12
+ const groupCoValue = node.createCoValue({
13
+ type: "comap",
14
+ ruleset: { type: "group", initialAdmin: account.id },
15
+ meta: null,
16
+ ...node.crypto.createdNowUnique(),
17
+ });
18
+ const group = expectGroup(groupCoValue.getCurrentContent());
19
+ group.set(account.id, "admin", "trusting");
20
+ const readKey = node.crypto.newRandomKeySecret();
21
+ group.set(`${readKey.id}_for_${account.id}`, node.crypto.seal({
22
+ message: readKey.secret,
23
+ from: account.currentSealerSecret(),
24
+ to: account.currentSealerID(),
25
+ nOnceMaterial: {
26
+ in: groupCoValue.id,
27
+ tx: groupCoValue.nextTransactionID(),
28
+ },
29
+ }), "trusting");
30
+ group.set("readKey", readKey.id, "trusting");
31
+ // Intentionally NOT setting groupSealer to simulate a pre-feature group
32
+ return group;
33
+ }
34
+ // ============================================================================
35
+ // Unit tests for sealForGroup / unsealForGroup crypto operations (Task 26)
36
+ // ============================================================================
37
+ describe("sealForGroup / unsealForGroup crypto operations", () => {
38
+ test("sealForGroup round-trips with correct sealer secret", () => {
39
+ const data = { secret: "hello world", nested: { value: 123 } };
40
+ const sealer = crypto.newRandomSealer();
41
+ const sealerID = crypto.getSealerID(sealer);
42
+ const nOnceMaterial = {
43
+ in: "co_zTEST",
44
+ tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
45
+ };
46
+ const sealed = crypto.sealForGroup({
47
+ message: data,
48
+ to: sealerID,
49
+ nOnceMaterial,
50
+ });
51
+ expect(sealed).toMatch(/^sealedForGroup_U/);
52
+ const unsealed = crypto.unsealForGroup(sealed, sealer, nOnceMaterial);
53
+ expect(unsealed).toEqual(data);
54
+ });
55
+ test("sealForGroup fails to unseal with wrong sealer secret", () => {
56
+ const data = { secret: "sensitive data" };
57
+ const sealer = crypto.newRandomSealer();
58
+ const wrongSealer = crypto.newRandomSealer();
59
+ const sealerID = crypto.getSealerID(sealer);
60
+ const nOnceMaterial = {
61
+ in: "co_zTEST",
62
+ tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
63
+ };
64
+ const sealed = crypto.sealForGroup({
65
+ message: data,
66
+ to: sealerID,
67
+ nOnceMaterial,
68
+ });
69
+ // Wrong sealer should fail to unseal
70
+ const result = crypto.unsealForGroup(sealed, wrongSealer, nOnceMaterial);
71
+ expect(result).toBeUndefined();
72
+ });
73
+ test("sealForGroup fails to unseal with wrong nonce material", () => {
74
+ const data = { secret: "sensitive data" };
75
+ const sealer = crypto.newRandomSealer();
76
+ const sealerID = crypto.getSealerID(sealer);
77
+ const nOnceMaterial = {
78
+ in: "co_zTEST",
79
+ tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
80
+ };
81
+ const wrongNOnceMaterial = {
82
+ in: "co_zDIFFERENT",
83
+ tx: { sessionID: "co_zDIFFERENT_session_zTEST", txIndex: 0 },
84
+ };
85
+ const sealed = crypto.sealForGroup({
86
+ message: data,
87
+ to: sealerID,
88
+ nOnceMaterial,
89
+ });
90
+ // Wrong nonce material should fail to unseal
91
+ const result = crypto.unsealForGroup(sealed, sealer, wrongNOnceMaterial);
92
+ expect(result).toBeUndefined();
93
+ });
94
+ test("sealForGroup uses ephemeral keys (different ciphertext each time)", () => {
95
+ const data = { message: "same message" };
96
+ const sealer = crypto.newRandomSealer();
97
+ const sealerID = crypto.getSealerID(sealer);
98
+ const nOnceMaterial = {
99
+ in: "co_zTEST",
100
+ tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
101
+ };
102
+ const sealed1 = crypto.sealForGroup({
103
+ message: data,
104
+ to: sealerID,
105
+ nOnceMaterial,
106
+ });
107
+ const sealed2 = crypto.sealForGroup({
108
+ message: data,
109
+ to: sealerID,
110
+ nOnceMaterial,
111
+ });
112
+ // Same message should produce different ciphertext due to ephemeral keys
113
+ expect(sealed1).not.toEqual(sealed2);
114
+ // But both should decrypt to the same value
115
+ expect(crypto.unsealForGroup(sealed1, sealer, nOnceMaterial)).toEqual(data);
116
+ expect(crypto.unsealForGroup(sealed2, sealer, nOnceMaterial)).toEqual(data);
117
+ });
118
+ test("groupSealerFromReadKey is deterministic", () => {
119
+ const readKey = crypto.newRandomKeySecret();
120
+ const sealer1 = crypto.groupSealerFromReadKey(readKey.secret);
121
+ const sealer2 = crypto.groupSealerFromReadKey(readKey.secret);
122
+ expect(sealer1.publicKey).toEqual(sealer2.publicKey);
123
+ expect(sealer1.secret).toEqual(sealer2.secret);
124
+ });
125
+ test("groupSealerFromReadKey produces different sealers for different keys", () => {
126
+ const readKey1 = crypto.newRandomKeySecret();
127
+ const readKey2 = crypto.newRandomKeySecret();
128
+ const sealer1 = crypto.groupSealerFromReadKey(readKey1.secret);
129
+ const sealer2 = crypto.groupSealerFromReadKey(readKey2.secret);
130
+ expect(sealer1.publicKey).not.toEqual(sealer2.publicKey);
131
+ expect(sealer1.secret).not.toEqual(sealer2.secret);
132
+ });
133
+ test("derived sealer works with sealForGroup/unsealForGroup", () => {
134
+ const readKey = crypto.newRandomKeySecret();
135
+ const sealer = crypto.groupSealerFromReadKey(readKey.secret);
136
+ const data = { key: "value", num: 42 };
137
+ const nOnceMaterial = {
138
+ in: "co_zTEST",
139
+ tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
140
+ };
141
+ const sealed = crypto.sealForGroup({
142
+ message: data,
143
+ to: sealer.publicKey,
144
+ nOnceMaterial,
145
+ });
146
+ const unsealed = crypto.unsealForGroup(sealed, sealer.secret, nOnceMaterial);
147
+ expect(unsealed).toEqual(data);
148
+ });
149
+ });
150
+ // ============================================================================
151
+ // Integration tests for groupSealer (Tasks 27-36)
152
+ // ============================================================================
153
+ let jazzCloud;
154
+ beforeEach(async () => {
155
+ SyncMessagesLog.clear();
156
+ jazzCloud = setupTestNode({ isSyncServer: true });
157
+ });
158
+ describe("groups created with groupSealer", () => {
159
+ test("new groups are created with a groupSealer field (Task 27)", async () => {
160
+ const { node1 } = await createTwoConnectedNodes("server", "server");
161
+ const group = node1.node.createGroup();
162
+ const groupSealer = group.get("groupSealer");
163
+ expect(groupSealer).toBeDefined();
164
+ // New composite format: "key_z...@sealer_z..."
165
+ expect(groupSealer).toMatch(/^key_z.+@sealer_z/);
166
+ });
167
+ test("groupSealer is derived from readKey deterministically (Task 28)", async () => {
168
+ const { node1 } = await createTwoConnectedNodes("server", "server");
169
+ const group = node1.node.createGroup();
170
+ const groupSealer = group.get("groupSealer");
171
+ const readKey = group.getCurrentReadKey();
172
+ expect(readKey.secret).toBeDefined();
173
+ if (!readKey.secret) {
174
+ throw new Error("Expected read key secret");
175
+ }
176
+ const derivedSealer = crypto.groupSealerFromReadKey(readKey.secret);
177
+ // Composite format: "readKeyID@sealerID"
178
+ expect(groupSealer).toEqual(`${readKey.id}@${derivedSealer.publicKey}`);
179
+ });
180
+ test("getGroupSealerSecret derives the correct secret", async () => {
181
+ const { node1 } = await createTwoConnectedNodes("server", "server");
182
+ const group = node1.node.createGroup();
183
+ const groupSealerSecret = group.getGroupSealerSecret();
184
+ expect(groupSealerSecret).toBeDefined();
185
+ const groupSealerValue = group.get("groupSealer");
186
+ expect(groupSealerValue).toBeDefined();
187
+ // Verify the secret corresponds to the SealerID embedded in the composite value
188
+ assert(groupSealerSecret, "Expected groupSealerSecret");
189
+ const derivedID = crypto.getSealerID(groupSealerSecret);
190
+ expect(groupSealerValue).toContain(derivedID);
191
+ });
192
+ });
193
+ describe("non-member extending child to parent via groupSealer", () => {
194
+ test("parent member can read child group content via extension", async () => {
195
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
196
+ // Node1 creates parent group and adds node2 as member
197
+ const parentGroup = node1.node.createGroup();
198
+ await parentGroup.core.waitForSync();
199
+ // Node2 creates child group and extends parent (node2 is a member of parent)
200
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
201
+ const childGroup = node2.node.createGroup();
202
+ childGroup.extend(parentOnNode2);
203
+ // Add node3 as writeOnly to child (node3 is NOT a member of parent)
204
+ const account3OnNode2 = await loadCoValueOrFail(node2.node, node3.accountID);
205
+ childGroup.addMember(account3OnNode2, "writeOnly");
206
+ const map = childGroup.createMap();
207
+ map.set("test", "Written by node2");
208
+ await map.core.waitForSync();
209
+ await childGroup.core.waitForSync();
210
+ // Node1 (parent member) should be able to read content from child
211
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
212
+ expect(mapOnNode1.get("test")).toEqual("Written by node2");
213
+ // Verify that the child group has the groupSealer set
214
+ const childGroupOnNode1 = await loadCoValueOrFail(node1.node, childGroup.id);
215
+ const childGroupSealer = childGroupOnNode1.get("groupSealer");
216
+ expect(childGroupSealer).toBeDefined();
217
+ // New composite format: "key_z...@sealer_z..."
218
+ expect(childGroupSealer).toMatch(/^key_z.+@sealer_z/);
219
+ });
220
+ test("writeOnly member uses groupSealer for key revelation", async () => {
221
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
222
+ // Node1 creates parent group and adds node2 as admin
223
+ const parentGroup = node1.node.createGroup();
224
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
225
+ parentGroup.addMember(account2OnNode1, "admin");
226
+ await parentGroup.core.waitForSync();
227
+ // Node2 creates child group and extends parent
228
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
229
+ const childGroup = node2.node.createGroup();
230
+ childGroup.extend(parentOnNode2);
231
+ // Add node3 as writeOnly to child (node3 is NOT a member of parent)
232
+ const account3OnNode2 = await loadCoValueOrFail(node2.node, node3.accountID);
233
+ childGroup.addMember(account3OnNode2, "writeOnly");
234
+ await childGroup.core.waitForSync();
235
+ // Node3 now has writeOnly access to child but no access to parent
236
+ // When node3 writes, the child needs to reveal keys to parent via groupSealer
237
+ const childGroupOnNode3 = await loadCoValueOrFail(node3.node, childGroup.id);
238
+ const map = childGroupOnNode3.createMap();
239
+ map.set("test", "Written by node3 (writeOnly)");
240
+ await map.core.waitForSync();
241
+ // Node1 (parent admin) should be able to read content written by node3
242
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
243
+ expect(mapOnNode1.get("test")).toEqual("Written by node3 (writeOnly)");
244
+ });
245
+ test("no writeOnly key created when parent has groupSealer", async () => {
246
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
247
+ const parentGroup = node1.node.createGroup();
248
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
249
+ parentGroup.addMember(account2OnNode1, "writer");
250
+ await parentGroup.core.waitForSync();
251
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
252
+ const childGroup = node2.node.createGroup();
253
+ childGroup.extend(parentOnNode2);
254
+ // Add node3 as writeOnly (this should use groupSealer, not writeOnly key)
255
+ const account3OnNode2 = await loadCoValueOrFail(node2.node, node3.accountID);
256
+ childGroup.addMember(account3OnNode2, "writeOnly");
257
+ await childGroup.core.waitForSync();
258
+ // Check that no writeKeyFor_ entry exists for the parent group
259
+ // This verifies we're using groupSealer instead of writeOnly keys
260
+ const childGroupOnNode1 = await loadCoValueOrFail(node1.node, childGroup.id);
261
+ // The parent group ID should not have a writeKeyFor entry in the child
262
+ const writeKeyForParent = childGroupOnNode1.get(`writeKeyFor_${parentGroup.id}`);
263
+ expect(writeKeyForParent).toBeUndefined();
264
+ });
265
+ // Role variation tests
266
+ test("reader in parent can read child content via groupSealer extension", async () => {
267
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
268
+ // Node1 creates parent group and adds node2 as reader (not admin/writer)
269
+ const parentGroup = node1.node.createGroup();
270
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
271
+ parentGroup.addMember(account2OnNode1, "reader");
272
+ await parentGroup.core.waitForSync();
273
+ // Node3 creates child group and extends parent (node3 is NOT a member of parent)
274
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
275
+ const childGroup = node3.node.createGroup();
276
+ childGroup.extend(parentOnNode3);
277
+ const map = childGroup.createMap();
278
+ map.set("test", "Written by non-member node3");
279
+ await map.core.waitForSync();
280
+ await childGroup.core.waitForSync();
281
+ // Node2 (reader in parent) should be able to read content from child
282
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
283
+ expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
284
+ });
285
+ test("writer in parent can read child content via groupSealer extension", async () => {
286
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
287
+ // Node1 creates parent group and adds node2 as writer
288
+ const parentGroup = node1.node.createGroup();
289
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
290
+ parentGroup.addMember(account2OnNode1, "writer");
291
+ await parentGroup.core.waitForSync();
292
+ // Node3 creates child group and extends parent (node3 is NOT a member of parent)
293
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
294
+ const childGroup = node3.node.createGroup();
295
+ childGroup.extend(parentOnNode3);
296
+ const map = childGroup.createMap();
297
+ map.set("test", "Written by non-member node3");
298
+ await map.core.waitForSync();
299
+ await childGroup.core.waitForSync();
300
+ // Node2 (writer in parent) should be able to read content from child
301
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
302
+ expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
303
+ });
304
+ test("manager in parent can read child content via groupSealer extension", async () => {
305
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
306
+ // Node1 creates parent group and adds node2 as manager
307
+ const parentGroup = node1.node.createGroup();
308
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
309
+ parentGroup.addMember(account2OnNode1, "manager");
310
+ await parentGroup.core.waitForSync();
311
+ // Node3 creates child group and extends parent (node3 is NOT a member of parent)
312
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
313
+ const childGroup = node3.node.createGroup();
314
+ childGroup.extend(parentOnNode3);
315
+ const map = childGroup.createMap();
316
+ map.set("test", "Written by non-member node3");
317
+ await map.core.waitForSync();
318
+ await childGroup.core.waitForSync();
319
+ // Node2 (manager in parent) should be able to read content from child
320
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
321
+ expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
322
+ });
323
+ // Multi-level hierarchy tests
324
+ test("three-level extension chain: grandparent can read grandchild content", async () => {
325
+ const nodes = await createNConnectedNodes("server", "server", "server", "server");
326
+ const node1 = nodes[0];
327
+ const node2 = nodes[1];
328
+ const node3 = nodes[2];
329
+ const node4 = nodes[3];
330
+ // Node1 creates grandparent group and adds node2 as admin
331
+ const grandparentGroup = node1.node.createGroup();
332
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
333
+ grandparentGroup.addMember(account2OnNode1, "admin");
334
+ await grandparentGroup.core.waitForSync();
335
+ // Node2 creates parent group and extends grandparent
336
+ const grandparentOnNode2 = await loadCoValueOrFail(node2.node, grandparentGroup.id);
337
+ const parentGroup = node2.node.createGroup();
338
+ parentGroup.extend(grandparentOnNode2);
339
+ await parentGroup.core.waitForSync();
340
+ // Node3 (NOT a member of grandparent or parent) creates child and extends parent
341
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
342
+ const childGroup = node3.node.createGroup();
343
+ childGroup.extend(parentOnNode3);
344
+ // Node4 (NOT a member) creates grandchild and extends child
345
+ const childOnNode4 = await loadCoValueOrFail(node4.node, childGroup.id);
346
+ const grandchildGroup = node4.node.createGroup();
347
+ grandchildGroup.extend(childOnNode4);
348
+ const map = grandchildGroup.createMap();
349
+ map.set("test", "Written in grandchild by node4");
350
+ await map.core.waitForSync();
351
+ await grandchildGroup.core.waitForSync();
352
+ await childGroup.core.waitForSync();
353
+ await parentGroup.core.waitForSync();
354
+ // Node1 (grandparent admin) should be able to read content from grandchild
355
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
356
+ expect(mapOnNode1.get("test")).toEqual("Written in grandchild by node4");
357
+ });
358
+ test("parallel extensions: two non-members extend to same parent", async () => {
359
+ const nodes = await createNConnectedNodes("server", "server", "server", "server");
360
+ const node1 = nodes[0];
361
+ const node2 = nodes[1];
362
+ const node3 = nodes[2];
363
+ const node4 = nodes[3];
364
+ // Node1 creates parent group
365
+ const parentGroup = node1.node.createGroup();
366
+ await parentGroup.core.waitForSync();
367
+ // Node2 (NOT a member of parent) creates child1 and extends parent
368
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
369
+ const childGroup1 = node2.node.createGroup();
370
+ childGroup1.extend(parentOnNode2);
371
+ const map1 = childGroup1.createMap();
372
+ map1.set("test", "Written by node2 in child1");
373
+ await map1.core.waitForSync();
374
+ await childGroup1.core.waitForSync();
375
+ // Node3 (NOT a member of parent) creates child2 and extends parent
376
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
377
+ const childGroup2 = node3.node.createGroup();
378
+ childGroup2.extend(parentOnNode3);
379
+ const map2 = childGroup2.createMap();
380
+ map2.set("test", "Written by node3 in child2");
381
+ await map2.core.waitForSync();
382
+ await childGroup2.core.waitForSync();
383
+ // Node4 (NOT a member of parent) creates child3 and extends parent
384
+ const parentOnNode4 = await loadCoValueOrFail(node4.node, parentGroup.id);
385
+ const childGroup3 = node4.node.createGroup();
386
+ childGroup3.extend(parentOnNode4);
387
+ const map3 = childGroup3.createMap();
388
+ map3.set("test", "Written by node4 in child3");
389
+ await map3.core.waitForSync();
390
+ await childGroup3.core.waitForSync();
391
+ // Node1 (parent admin) should be able to read all three child contents
392
+ const map1OnNode1 = await loadCoValueOrFail(node1.node, map1.id);
393
+ expect(map1OnNode1.get("test")).toEqual("Written by node2 in child1");
394
+ const map2OnNode1 = await loadCoValueOrFail(node1.node, map2.id);
395
+ expect(map2OnNode1.get("test")).toEqual("Written by node3 in child2");
396
+ const map3OnNode1 = await loadCoValueOrFail(node1.node, map3.id);
397
+ expect(map3OnNode1.get("test")).toEqual("Written by node4 in child3");
398
+ });
399
+ // Key rotation scenarios
400
+ test("extension after parent key rotation uses new groupSealer", async () => {
401
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
402
+ // Node1 creates parent group
403
+ const parentGroup = node1.node.createGroup();
404
+ const originalGroupSealer = parentGroup.get("groupSealer");
405
+ await parentGroup.core.waitForSync();
406
+ // Node1 rotates the parent group's read key (and thus groupSealer)
407
+ parentGroup.rotateReadKey();
408
+ const newGroupSealer = parentGroup.get("groupSealer");
409
+ expect(newGroupSealer).not.toEqual(originalGroupSealer);
410
+ await parentGroup.core.waitForSync();
411
+ // Node2 (NOT a member of parent) creates child and extends parent after rotation
412
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
413
+ // Verify node2 sees the new groupSealer
414
+ expect(parentOnNode2.get("groupSealer")).toEqual(newGroupSealer);
415
+ const childGroup = node2.node.createGroup();
416
+ childGroup.extend(parentOnNode2);
417
+ const map = childGroup.createMap();
418
+ map.set("test", "Written after parent key rotation");
419
+ await map.core.waitForSync();
420
+ await childGroup.core.waitForSync();
421
+ // Node1 (parent admin) should be able to read content using the new groupSealer
422
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
423
+ expect(mapOnNode1.get("test")).toEqual("Written after parent key rotation");
424
+ });
425
+ test("old content remains readable after child key rotation", async () => {
426
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
427
+ // Node1 creates parent group
428
+ const parentGroup = node1.node.createGroup();
429
+ await parentGroup.core.waitForSync();
430
+ // Node2 (NOT a member of parent) creates child and extends parent
431
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
432
+ const childGroup = node2.node.createGroup();
433
+ childGroup.extend(parentOnNode2);
434
+ // Create content before key rotation
435
+ const mapBefore = childGroup.createMap();
436
+ mapBefore.set("test", "Written before child key rotation");
437
+ await mapBefore.core.waitForSync();
438
+ await childGroup.core.waitForSync();
439
+ // Node1 verifies it can read content before rotation
440
+ const mapBeforeOnNode1 = await loadCoValueOrFail(node1.node, mapBefore.id);
441
+ expect(mapBeforeOnNode1.get("test")).toEqual("Written before child key rotation");
442
+ // Node2 rotates child group's key
443
+ // Note: For non-members, the new key cannot be revealed to parent via groupSealer
444
+ // in the current implementation (see GitHub issue #1979)
445
+ childGroup.rotateReadKey();
446
+ await childGroup.core.waitForSync();
447
+ // Node1 should still be able to read OLD content created before rotation
448
+ // This verifies the historical sealer mechanism works correctly
449
+ const mapBeforeOnNode1Again = await loadCoValueOrFail(node1.node, mapBefore.id);
450
+ expect(mapBeforeOnNode1Again.get("test")).toEqual("Written before child key rotation");
451
+ });
452
+ test("member of parent can read child content after child key rotation", async () => {
453
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
454
+ // Node1 creates parent group and adds node2 as admin
455
+ const parentGroup = node1.node.createGroup();
456
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
457
+ parentGroup.addMember(account2OnNode1, "admin");
458
+ await parentGroup.core.waitForSync();
459
+ // Node2 (member of parent) creates child and extends parent
460
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
461
+ const childGroup = node2.node.createGroup();
462
+ childGroup.extend(parentOnNode2);
463
+ // Create content before key rotation
464
+ const mapBefore = childGroup.createMap();
465
+ mapBefore.set("test", "Written before rotation");
466
+ await mapBefore.core.waitForSync();
467
+ await childGroup.core.waitForSync();
468
+ // Node2 rotates child group's key (as a member, they have access to parent readKey)
469
+ childGroup.rotateReadKey();
470
+ // Create content after key rotation
471
+ const mapAfter = childGroup.createMap();
472
+ mapAfter.set("test", "Written after rotation");
473
+ await mapAfter.core.waitForSync();
474
+ await childGroup.core.waitForSync();
475
+ // Node1 should be able to read both old and new content
476
+ const mapBeforeOnNode1 = await loadCoValueOrFail(node1.node, mapBefore.id);
477
+ expect(mapBeforeOnNode1.get("test")).toEqual("Written before rotation");
478
+ const mapAfterOnNode1 = await loadCoValueOrFail(node1.node, mapAfter.id);
479
+ expect(mapAfterOnNode1.get("test")).toEqual("Written after rotation");
480
+ });
481
+ test("old content visible after 3 key rotations via historical groupSealer", async () => {
482
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
483
+ // Node1 creates parent group and adds node2 as admin
484
+ const parentGroup = node1.node.createGroup();
485
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
486
+ parentGroup.addMember(account2OnNode1, "admin");
487
+ await parentGroup.core.waitForSync();
488
+ // Node2 (member of parent) creates child and extends parent
489
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
490
+ const childGroup = node2.node.createGroup();
491
+ childGroup.extend(parentOnNode2);
492
+ // Create content with the initial key
493
+ const map0 = childGroup.createMap();
494
+ map0.set("test", "Before any rotation");
495
+ await map0.core.waitForSync();
496
+ await childGroup.core.waitForSync();
497
+ // Rotation 1
498
+ childGroup.rotateReadKey();
499
+ const map1 = childGroup.createMap();
500
+ map1.set("test", "After rotation 1");
501
+ await map1.core.waitForSync();
502
+ await childGroup.core.waitForSync();
503
+ // Rotation 2
504
+ childGroup.rotateReadKey();
505
+ const map2 = childGroup.createMap();
506
+ map2.set("test", "After rotation 2");
507
+ await map2.core.waitForSync();
508
+ await childGroup.core.waitForSync();
509
+ // Rotation 3
510
+ childGroup.rotateReadKey();
511
+ const map3 = childGroup.createMap();
512
+ map3.set("test", "After rotation 3");
513
+ await map3.core.waitForSync();
514
+ await childGroup.core.waitForSync();
515
+ // Node1 (parent admin) should be able to read content from all key generations
516
+ const map0OnNode1 = await loadCoValueOrFail(node1.node, map0.id);
517
+ expect(map0OnNode1.get("test")).toEqual("Before any rotation");
518
+ const map1OnNode1 = await loadCoValueOrFail(node1.node, map1.id);
519
+ expect(map1OnNode1.get("test")).toEqual("After rotation 1");
520
+ const map2OnNode1 = await loadCoValueOrFail(node1.node, map2.id);
521
+ expect(map2OnNode1.get("test")).toEqual("After rotation 2");
522
+ const map3OnNode1 = await loadCoValueOrFail(node1.node, map3.id);
523
+ expect(map3OnNode1.get("test")).toEqual("After rotation 3");
524
+ });
525
+ // Edge case tests
526
+ test("non-member can create multiple CoValues readable by parent", async () => {
527
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
528
+ // Node1 creates parent group
529
+ const parentGroup = node1.node.createGroup();
530
+ await parentGroup.core.waitForSync();
531
+ // Node2 (NOT a member of parent) creates child and extends parent
532
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
533
+ const childGroup = node2.node.createGroup();
534
+ childGroup.extend(parentOnNode2);
535
+ // Create multiple maps
536
+ const map1 = childGroup.createMap();
537
+ map1.set("name", "Map 1");
538
+ const map2 = childGroup.createMap();
539
+ map2.set("name", "Map 2");
540
+ const map3 = childGroup.createMap();
541
+ map3.set("name", "Map 3");
542
+ const map4 = childGroup.createMap();
543
+ map4.set("name", "Map 4");
544
+ const map5 = childGroup.createMap();
545
+ map5.set("name", "Map 5");
546
+ await Promise.all([
547
+ map1.core.waitForSync(),
548
+ map2.core.waitForSync(),
549
+ map3.core.waitForSync(),
550
+ map4.core.waitForSync(),
551
+ map5.core.waitForSync(),
552
+ ]);
553
+ await childGroup.core.waitForSync();
554
+ // Node1 (parent admin) should be able to read all maps
555
+ const map1OnNode1 = await loadCoValueOrFail(node1.node, map1.id);
556
+ expect(map1OnNode1.get("name")).toEqual("Map 1");
557
+ const map2OnNode1 = await loadCoValueOrFail(node1.node, map2.id);
558
+ expect(map2OnNode1.get("name")).toEqual("Map 2");
559
+ const map3OnNode1 = await loadCoValueOrFail(node1.node, map3.id);
560
+ expect(map3OnNode1.get("name")).toEqual("Map 3");
561
+ const map4OnNode1 = await loadCoValueOrFail(node1.node, map4.id);
562
+ expect(map4OnNode1.get("name")).toEqual("Map 4");
563
+ const map5OnNode1 = await loadCoValueOrFail(node1.node, map5.id);
564
+ expect(map5OnNode1.get("name")).toEqual("Map 5");
565
+ });
566
+ test("content created before extension is accessible after extension", async () => {
567
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
568
+ // Node1 creates parent group
569
+ const parentGroup = node1.node.createGroup();
570
+ await parentGroup.core.waitForSync();
571
+ // Node2 creates child group and content BEFORE extending
572
+ const childGroup = node2.node.createGroup();
573
+ const mapBefore = childGroup.createMap();
574
+ mapBefore.set("test", "Created before extension");
575
+ await mapBefore.core.waitForSync();
576
+ await childGroup.core.waitForSync();
577
+ // Now extend the child to parent
578
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
579
+ childGroup.extend(parentOnNode2);
580
+ // Create content after extension
581
+ const mapAfter = childGroup.createMap();
582
+ mapAfter.set("test", "Created after extension");
583
+ await mapAfter.core.waitForSync();
584
+ await childGroup.core.waitForSync();
585
+ // Node1 should be able to read content created both before and after extension
586
+ const mapBeforeOnNode1 = await loadCoValueOrFail(node1.node, mapBefore.id);
587
+ expect(mapBeforeOnNode1.get("test")).toEqual("Created before extension");
588
+ const mapAfterOnNode1 = await loadCoValueOrFail(node1.node, mapAfter.id);
589
+ expect(mapAfterOnNode1.get("test")).toEqual("Created after extension");
590
+ });
591
+ test("nested group membership: members of member-group can read via groupSealer", async () => {
592
+ const nodes = await createNConnectedNodes("server", "server", "server", "server");
593
+ const node1 = nodes[0];
594
+ const node2 = nodes[1];
595
+ const node3 = nodes[2];
596
+ const node4 = nodes[3];
597
+ // Node1 creates an inner group and adds node2 as admin
598
+ const innerGroup = node1.node.createGroup();
599
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
600
+ innerGroup.addMember(account2OnNode1, "admin");
601
+ await innerGroup.core.waitForSync();
602
+ // Node1 creates parent group and adds innerGroup as member (nested group membership)
603
+ // Using extend() to add innerGroup's members to parentGroup
604
+ const parentGroup = node1.node.createGroup();
605
+ parentGroup.extend(innerGroup, "writer");
606
+ await parentGroup.core.waitForSync();
607
+ // Node3 (NOT a member of parent or innerGroup) creates child and extends parent
608
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
609
+ const childGroup = node3.node.createGroup();
610
+ childGroup.extend(parentOnNode3);
611
+ const map = childGroup.createMap();
612
+ map.set("test", "Written by non-member node3");
613
+ await map.core.waitForSync();
614
+ await childGroup.core.waitForSync();
615
+ // Node2 (member of innerGroup, which is extended by parent) should be able to read
616
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
617
+ expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
618
+ // Node1 (admin of both groups) should also be able to read
619
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
620
+ expect(mapOnNode1.get("test")).toEqual("Written by non-member node3");
621
+ });
622
+ });
623
+ describe("key rotation updates groupSealer", () => {
624
+ test("rotating readKey also rotates groupSealer", async () => {
625
+ const { node1 } = await createTwoConnectedNodes("server", "server");
626
+ const group = node1.node.createGroup();
627
+ const originalGroupSealer = group.get("groupSealer");
628
+ // Force key rotation
629
+ group.rotateReadKey();
630
+ const newGroupSealer = group.get("groupSealer");
631
+ expect(newGroupSealer).toBeDefined();
632
+ expect(newGroupSealer).not.toEqual(originalGroupSealer);
633
+ // Verify the new sealer is derived from the new read key
634
+ const newReadKey = group.getCurrentReadKey();
635
+ if (!newReadKey.secret) {
636
+ throw new Error("Expected read key secret after rotation");
637
+ }
638
+ const derivedSealer = crypto.groupSealerFromReadKey(newReadKey.secret);
639
+ // Composite format includes the readKeyID
640
+ expect(newGroupSealer).toEqual(`${newReadKey.id}@${derivedSealer.publicKey}`);
641
+ });
642
+ });
643
+ describe("concurrent group sealer initialization", () => {
644
+ test("concurrent group sealer initialization produces same result", () => {
645
+ // Since groupSealer is derived deterministically from readKey,
646
+ // multiple calls with the same readKey will always produce the same result
647
+ const readKey = crypto.newRandomKeySecret();
648
+ // Simulate concurrent derivations
649
+ const results = Array.from({ length: 10 }, () => crypto.groupSealerFromReadKey(readKey.secret));
650
+ // All results should be identical
651
+ const firstResult = results[0];
652
+ for (const result of results) {
653
+ expect(result.publicKey).toEqual(firstResult.publicKey);
654
+ expect(result.secret).toEqual(firstResult.secret);
655
+ }
656
+ });
657
+ });
658
+ describe("groupSealer composite format (readKeyID association)", () => {
659
+ test("groupSealer stores readKeyID in composite format", async () => {
660
+ const { node1 } = await createTwoConnectedNodes("server", "server");
661
+ const group = node1.node.createGroup();
662
+ const groupSealerValue = group.get("groupSealer");
663
+ // Should be in composite format: "key_z...@sealer_z..."
664
+ expect(groupSealerValue).toMatch(/^key_z.+@sealer_z/);
665
+ // The readKeyID portion should match the current readKey
666
+ const readKeyId = group.getCurrentReadKeyId();
667
+ expect(groupSealerValue.startsWith(readKeyId)).toBe(true);
668
+ });
669
+ test("concurrent key rotation and migration produce correct readKey association", async () => {
670
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
671
+ // Create a legacy group on node1, add node2 and node3 as admins
672
+ const legacyGroup = createLegacyGroup(node1.node);
673
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
674
+ const account3OnNode1 = await loadCoValueOrFail(node1.node, node3.accountID);
675
+ legacyGroup.addMember(account2OnNode1, "admin");
676
+ legacyGroup.addMember(account3OnNode1, "admin");
677
+ await legacyGroup.core.waitForSync();
678
+ // Node2 loads the group and triggers migration (sets groupSealer from key1)
679
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
680
+ await groupOnNode2.core.waitForSync();
681
+ // Verify groupSealer was set with composite format
682
+ const sealerOnNode2 = groupOnNode2.get("groupSealer");
683
+ expect(sealerOnNode2).toMatch(/^key_z.+@sealer_z/);
684
+ // Now node2 rotates the read key (simulating concurrent rotation)
685
+ groupOnNode2.rotateReadKey();
686
+ await groupOnNode2.core.waitForSync();
687
+ // The groupSealer should now reference the NEW readKey, not the old one
688
+ const newSealerOnNode2 = groupOnNode2.get("groupSealer");
689
+ const newReadKeyId = groupOnNode2.getCurrentReadKeyId();
690
+ expect(newSealerOnNode2.startsWith(newReadKeyId)).toBe(true);
691
+ // The new sealer should be different from the old one
692
+ expect(newSealerOnNode2).not.toEqual(sealerOnNode2);
693
+ });
694
+ test("child group extended via groupSealer is readable after parent key rotation + migration race", async () => {
695
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
696
+ // Node1 creates parent group with both node2 and node3 as admins
697
+ const parentGroup = createLegacyGroup(node1.node);
698
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
699
+ parentGroup.addMember(account2OnNode1, "admin");
700
+ await parentGroup.core.waitForSync();
701
+ // Node2 sees the parent group
702
+ parentGroup.rotateReadKey();
703
+ const parentGroupOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
704
+ await Promise.all([
705
+ parentGroup.core.waitForSync(),
706
+ parentGroupOnNode2.core.waitForSync(),
707
+ ]);
708
+ // Node3 (non-member) creates child and extends parent using the current groupSealer
709
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
710
+ const childGroup = node3.node.createGroup();
711
+ childGroup.extend(parentOnNode3);
712
+ const map = childGroup.createMap();
713
+ map.set("test", "Written by non-member");
714
+ await map.core.waitForSync();
715
+ await childGroup.core.waitForSync();
716
+ // Both node1 and node2 should be able to read the child content
717
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
718
+ expect(mapOnNode1.get("test")).toEqual("Written by non-member");
719
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
720
+ expect(mapOnNode2.get("test")).toEqual("Written by non-member");
721
+ });
722
+ });
723
+ describe("permission validation for groupSealer", () => {
724
+ test("non-admin cannot set groupSealer", async () => {
725
+ const { node1, node2 } = await createTwoConnectedNodes("server", "server");
726
+ const group = node1.node.createGroup();
727
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
728
+ // Add node2 as reader (not admin)
729
+ group.addMember(account2OnNode1, "reader");
730
+ await group.core.waitForSync();
731
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
732
+ const originalSealer = groupOnNode2.get("groupSealer");
733
+ // Attempt to set groupSealer as a reader
734
+ const fakeSealer = crypto.newRandomSealer();
735
+ const fakeSealerID = crypto.getSealerID(fakeSealer);
736
+ groupOnNode2.set("groupSealer", `${group.getCurrentReadKeyId()}@${fakeSealerID}`, "trusting");
737
+ // The change should be rejected (sealer should remain original)
738
+ // Wait for sync to ensure changes are processed
739
+ await groupOnNode2.core.waitForSync();
740
+ // Re-load group on node1 to check if the invalid change was rejected
741
+ // The original sealer should be preserved because readers can't set groupSealer
742
+ expect(group.get("groupSealer")).toEqual(originalSealer);
743
+ });
744
+ test("admin can set groupSealer", async () => {
745
+ const { node1 } = await createTwoConnectedNodes("server", "server");
746
+ const group = node1.node.createGroup();
747
+ const originalSealer = group.get("groupSealer");
748
+ // As admin, rotate the read key which will update groupSealer
749
+ group.rotateReadKey();
750
+ const newSealer = group.get("groupSealer");
751
+ expect(newSealer).toBeDefined();
752
+ expect(newSealer).not.toEqual(originalSealer);
753
+ });
754
+ });
755
+ describe("groupSealer migration for legacy groups", () => {
756
+ test("legacy group without groupSealer gets migrated when loaded by admin on another node", async () => {
757
+ const { node1, node2 } = await createTwoConnectedNodes("server", "server");
758
+ // Create a legacy group (without groupSealer) on node1, add node2 as admin
759
+ const legacyGroup = createLegacyGroup(node1.node);
760
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
761
+ legacyGroup.addMember(account2OnNode1, "admin");
762
+ expect(legacyGroup.get("groupSealer")).toBeUndefined();
763
+ await legacyGroup.core.waitForSync();
764
+ await new Promise((resolve) => setTimeout(resolve, 10));
765
+ // Node2 (admin) loads the group - migration should add the groupSealer
766
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
767
+ expect(groupOnNode2.get("groupSealer")).toBeDefined();
768
+ // Migrated groups use the new composite format
769
+ expect(groupOnNode2.get("groupSealer")).toMatch(/^key_z.+@sealer_z/);
770
+ // Verify it's derived from the current read key
771
+ const readKey = groupOnNode2.getCurrentReadKey();
772
+ expect(readKey.secret).toBeDefined();
773
+ const expectedSealer = crypto.groupSealerFromReadKey(readKey.secret);
774
+ expect(groupOnNode2.get("groupSealer")).toEqual(`${readKey.id}@${expectedSealer.publicKey}`);
775
+ });
776
+ test("migration is idempotent - loading on two admin nodes produces same groupSealer", async () => {
777
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
778
+ // Create a legacy group on node1, add node2 and node3 as admins
779
+ const legacyGroup = createLegacyGroup(node1.node);
780
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
781
+ const account3OnNode1 = await loadCoValueOrFail(node1.node, node3.accountID);
782
+ legacyGroup.addMember(account2OnNode1, "admin");
783
+ legacyGroup.addMember(account3OnNode1, "admin");
784
+ expect(legacyGroup.get("groupSealer")).toBeUndefined();
785
+ await legacyGroup.core.waitForSync();
786
+ // Node2 loads and migrates
787
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
788
+ await groupOnNode2.core.waitForSync();
789
+ const sealerFromNode2 = groupOnNode2.get("groupSealer");
790
+ expect(sealerFromNode2).toBeDefined();
791
+ // Record transaction count after node2's migration has synced
792
+ const transactionsAfterNode2 = groupOnNode2.core.getValidSortedTransactions();
793
+ // Node3 loads - groupSealer is already set via sync from node2,
794
+ // so no new migration should be applied
795
+ const groupOnNode3 = await loadCoValueOrFail(node3.node, legacyGroup.id);
796
+ await groupOnNode3.core.waitForSync();
797
+ const sealerFromNode3 = groupOnNode3.get("groupSealer");
798
+ expect(sealerFromNode3).toBeDefined();
799
+ // Both should have the same groupSealer (deterministic from readKey)
800
+ expect(sealerFromNode3).toEqual(sealerFromNode2);
801
+ // Verify no redundant migration was applied — transaction count should be unchanged
802
+ expect(groupOnNode3.core.getValidSortedTransactions()).toHaveLength(transactionsAfterNode2.length);
803
+ });
804
+ test("parallel migrations from different accounts produce same groupSealer", async () => {
805
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
806
+ // Create a legacy group on node1, add node2 and node3 as admins
807
+ const legacyGroup = createLegacyGroup(node1.node);
808
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
809
+ const account3OnNode1 = await loadCoValueOrFail(node1.node, node3.accountID);
810
+ legacyGroup.addMember(account2OnNode1, "admin");
811
+ legacyGroup.addMember(account3OnNode1, "admin");
812
+ expect(legacyGroup.get("groupSealer")).toBeUndefined();
813
+ await legacyGroup.core.waitForSync();
814
+ // Both node2 and node3 load the group concurrently, triggering parallel migrations
815
+ const [groupOnNode2, groupOnNode3] = await Promise.all([
816
+ loadCoValueOrFail(node2.node, legacyGroup.id),
817
+ loadCoValueOrFail(node3.node, legacyGroup.id),
818
+ ]);
819
+ // Both should have a groupSealer set
820
+ const sealerOnNode2 = groupOnNode2.get("groupSealer");
821
+ const sealerOnNode3 = groupOnNode3.get("groupSealer");
822
+ expect(sealerOnNode2).toBeDefined();
823
+ expect(sealerOnNode3).toBeDefined();
824
+ // Both should derive the same groupSealer since it's deterministic from readKey
825
+ expect(sealerOnNode2).toEqual(sealerOnNode3);
826
+ // Wait for sync and verify convergence
827
+ await groupOnNode2.core.waitForSync();
828
+ await groupOnNode3.core.waitForSync();
829
+ // After sync, both should still agree
830
+ expect(groupOnNode2.get("groupSealer")).toEqual(groupOnNode3.get("groupSealer"));
831
+ });
832
+ test("non-admin member does not trigger migration", async () => {
833
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
834
+ // Create a legacy group on node1, add node2 as reader only
835
+ const legacyGroup = createLegacyGroup(node1.node);
836
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
837
+ legacyGroup.addMember(account2OnNode1, "reader");
838
+ expect(legacyGroup.get("groupSealer")).toBeUndefined();
839
+ await legacyGroup.core.waitForSync();
840
+ const transactions = legacyGroup.core.getValidSortedTransactions();
841
+ // Node2 (reader) loads the group - should NOT trigger migration
842
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
843
+ // The groupSealer should still be undefined because node2 is only a reader
844
+ // and cannot set the groupSealer field
845
+ expect(groupOnNode2.get("groupSealer")).toBeUndefined();
846
+ expect(groupOnNode2.core.getValidSortedTransactions()).toHaveLength(transactions.length);
847
+ });
848
+ test("migrated legacy group works with non-member extension via groupSealer", async () => {
849
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
850
+ // Create a legacy group on node1 (no groupSealer), add node2 as admin
851
+ const legacyGroup = createLegacyGroup(node1.node);
852
+ const account2OnNode1 = await loadCoValueOrFail(node1.node, node2.accountID);
853
+ legacyGroup.addMember(account2OnNode1, "admin");
854
+ expect(legacyGroup.get("groupSealer")).toBeUndefined();
855
+ await legacyGroup.core.waitForSync();
856
+ // Node2 (admin) loads the group, triggering migration
857
+ const parentGroup = await loadCoValueOrFail(node2.node, legacyGroup.id);
858
+ // Wait for async migration to complete (runs via waitFor when not fully downloaded)
859
+ await parentGroup.core.waitForSync();
860
+ // Verify migration happened
861
+ expect(parentGroup.get("groupSealer")).toBeDefined();
862
+ await parentGroup.core.waitForSync();
863
+ // Node3 (NOT a member of parent) creates a child group and extends parent
864
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, legacyGroup.id);
865
+ const childGroup = node3.node.createGroup();
866
+ childGroup.extend(parentOnNode3);
867
+ const map = childGroup.createMap();
868
+ map.set("test", "Written by non-member after migration");
869
+ await map.core.waitForSync();
870
+ await childGroup.core.waitForSync();
871
+ // Node1 (original creator/admin) should be able to read content via migrated groupSealer
872
+ // First, sync to pick up the migrated groupSealer
873
+ const parentOnNode1 = await loadCoValueOrFail(node1.node, legacyGroup.id);
874
+ await parentOnNode1.core.waitForSync();
875
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
876
+ expect(mapOnNode1.get("test")).toEqual("Written by non-member after migration");
877
+ });
878
+ test("legacy fallback: parent without groupSealer uses writeOnly key for non-member extension", async () => {
879
+ const { node1, node2, node3 } = await createThreeConnectedNodes("server", "server", "server");
880
+ // Create a legacy parent group (without groupSealer) on node1
881
+ const legacyParent = createLegacyGroup(node1.node);
882
+ expect(legacyParent.get("groupSealer")).toBeUndefined();
883
+ await legacyParent.core.waitForSync();
884
+ // Node2 (NOT a member of parent) creates child and extends legacy parent
885
+ const legacyParentOnNode2 = await loadCoValueOrFail(node2.node, legacyParent.id);
886
+ const childGroup = node2.node.createGroup();
887
+ childGroup.extend(legacyParentOnNode2);
888
+ await childGroup.core.waitForSync();
889
+ // Verify the legacy fallback was used: a writeKeyFor_ entry should exist
890
+ // in the parent group for the extending account
891
+ const legacyParentUpdated = await loadCoValueOrFail(node1.node, legacyParent.id);
892
+ const writeKeyForNode2 = legacyParentUpdated.get(`writeKeyFor_${node2.node.getCurrentAgent().id}`);
893
+ expect(writeKeyForNode2).toBeDefined();
894
+ // Verify NO _sealedFor_ entries exist (groupSealer path was NOT used)
895
+ const sealedForKeys = legacyParentUpdated
896
+ .keys()
897
+ .filter((key) => key.includes("_sealedFor_"));
898
+ expect(sealedForKeys).toHaveLength(0);
899
+ // Node3 is added as writeOnly to child by node2
900
+ const account3OnNode2 = await loadCoValueOrFail(node2.node, node3.accountID);
901
+ childGroup.addMember(account3OnNode2, "writeOnly");
902
+ await childGroup.core.waitForSync();
903
+ // Node3 writes content
904
+ const childGroupOnNode3 = await loadCoValueOrFail(node3.node, childGroup.id);
905
+ const map = childGroupOnNode3.createMap();
906
+ map.set("test", "Written via legacy writeOnly fallback");
907
+ await map.core.waitForSync();
908
+ // Node1 (parent admin) should be able to read via the writeOnly key
909
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
910
+ expect(mapOnNode1.get("test")).toEqual("Written via legacy writeOnly fallback");
911
+ });
912
+ });
913
+ //# sourceMappingURL=groupSealer.test.js.map