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,1473 @@
1
+ import { assert, beforeEach, describe, expect, test } from "vitest";
2
+ import { WasmCrypto } from "../crypto/WasmCrypto.js";
3
+ import { SessionID } from "../ids.js";
4
+ import { expectGroup } from "../typeUtils/expectGroup.js";
5
+ import { LocalNode } from "../localNode.js";
6
+ import { RawGroup } from "../coValues/group.js";
7
+ import {
8
+ SyncMessagesLog,
9
+ createNConnectedNodes,
10
+ createTwoConnectedNodes,
11
+ createThreeConnectedNodes,
12
+ loadCoValueOrFail,
13
+ setupTestNode,
14
+ } from "./testUtils.js";
15
+
16
+ const crypto = await WasmCrypto.create();
17
+
18
+ /**
19
+ * Creates a group without the groupSealer field, simulating a legacy group
20
+ * created before the groupSealer feature was introduced.
21
+ */
22
+ function createLegacyGroup(node: LocalNode): RawGroup {
23
+ const account = node.getCurrentAgent();
24
+
25
+ const groupCoValue = node.createCoValue({
26
+ type: "comap",
27
+ ruleset: { type: "group", initialAdmin: account.id },
28
+ meta: null,
29
+ ...node.crypto.createdNowUnique(),
30
+ });
31
+
32
+ const group = expectGroup(groupCoValue.getCurrentContent());
33
+
34
+ group.set(account.id, "admin", "trusting");
35
+
36
+ const readKey = node.crypto.newRandomKeySecret();
37
+
38
+ group.set(
39
+ `${readKey.id}_for_${account.id}`,
40
+ node.crypto.seal({
41
+ message: readKey.secret,
42
+ from: account.currentSealerSecret(),
43
+ to: account.currentSealerID(),
44
+ nOnceMaterial: {
45
+ in: groupCoValue.id,
46
+ tx: groupCoValue.nextTransactionID(),
47
+ },
48
+ }),
49
+ "trusting",
50
+ );
51
+
52
+ group.set("readKey", readKey.id, "trusting");
53
+
54
+ // Intentionally NOT setting groupSealer to simulate a pre-feature group
55
+
56
+ return group;
57
+ }
58
+
59
+ // ============================================================================
60
+ // Unit tests for sealForGroup / unsealForGroup crypto operations (Task 26)
61
+ // ============================================================================
62
+
63
+ describe("sealForGroup / unsealForGroup crypto operations", () => {
64
+ test("sealForGroup round-trips with correct sealer secret", () => {
65
+ const data = { secret: "hello world", nested: { value: 123 } };
66
+ const sealer = crypto.newRandomSealer();
67
+ const sealerID = crypto.getSealerID(sealer);
68
+
69
+ const nOnceMaterial = {
70
+ in: "co_zTEST" as const,
71
+ tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
72
+ };
73
+
74
+ const sealed = crypto.sealForGroup({
75
+ message: data,
76
+ to: sealerID,
77
+ nOnceMaterial,
78
+ });
79
+
80
+ expect(sealed).toMatch(/^sealedForGroup_U/);
81
+
82
+ const unsealed = crypto.unsealForGroup(sealed, sealer, nOnceMaterial);
83
+ expect(unsealed).toEqual(data);
84
+ });
85
+
86
+ test("sealForGroup fails to unseal with wrong sealer secret", () => {
87
+ const data = { secret: "sensitive data" };
88
+ const sealer = crypto.newRandomSealer();
89
+ const wrongSealer = crypto.newRandomSealer();
90
+ const sealerID = crypto.getSealerID(sealer);
91
+
92
+ const nOnceMaterial = {
93
+ in: "co_zTEST" as const,
94
+ tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
95
+ };
96
+
97
+ const sealed = crypto.sealForGroup({
98
+ message: data,
99
+ to: sealerID,
100
+ nOnceMaterial,
101
+ });
102
+
103
+ // Wrong sealer should fail to unseal
104
+ const result = crypto.unsealForGroup(sealed, wrongSealer, nOnceMaterial);
105
+ expect(result).toBeUndefined();
106
+ });
107
+
108
+ test("sealForGroup fails to unseal with wrong nonce material", () => {
109
+ const data = { secret: "sensitive data" };
110
+ const sealer = crypto.newRandomSealer();
111
+ const sealerID = crypto.getSealerID(sealer);
112
+
113
+ const nOnceMaterial = {
114
+ in: "co_zTEST" as const,
115
+ tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
116
+ };
117
+
118
+ const wrongNOnceMaterial = {
119
+ in: "co_zDIFFERENT" as const,
120
+ tx: { sessionID: "co_zDIFFERENT_session_zTEST" as SessionID, txIndex: 0 },
121
+ };
122
+
123
+ const sealed = crypto.sealForGroup({
124
+ message: data,
125
+ to: sealerID,
126
+ nOnceMaterial,
127
+ });
128
+
129
+ // Wrong nonce material should fail to unseal
130
+ const result = crypto.unsealForGroup(sealed, sealer, wrongNOnceMaterial);
131
+ expect(result).toBeUndefined();
132
+ });
133
+
134
+ test("sealForGroup uses ephemeral keys (different ciphertext each time)", () => {
135
+ const data = { message: "same message" };
136
+ const sealer = crypto.newRandomSealer();
137
+ const sealerID = crypto.getSealerID(sealer);
138
+
139
+ const nOnceMaterial = {
140
+ in: "co_zTEST" as const,
141
+ tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
142
+ };
143
+
144
+ const sealed1 = crypto.sealForGroup({
145
+ message: data,
146
+ to: sealerID,
147
+ nOnceMaterial,
148
+ });
149
+
150
+ const sealed2 = crypto.sealForGroup({
151
+ message: data,
152
+ to: sealerID,
153
+ nOnceMaterial,
154
+ });
155
+
156
+ // Same message should produce different ciphertext due to ephemeral keys
157
+ expect(sealed1).not.toEqual(sealed2);
158
+
159
+ // But both should decrypt to the same value
160
+ expect(crypto.unsealForGroup(sealed1, sealer, nOnceMaterial)).toEqual(data);
161
+ expect(crypto.unsealForGroup(sealed2, sealer, nOnceMaterial)).toEqual(data);
162
+ });
163
+
164
+ test("groupSealerFromReadKey is deterministic", () => {
165
+ const readKey = crypto.newRandomKeySecret();
166
+
167
+ const sealer1 = crypto.groupSealerFromReadKey(readKey.secret);
168
+ const sealer2 = crypto.groupSealerFromReadKey(readKey.secret);
169
+
170
+ expect(sealer1.publicKey).toEqual(sealer2.publicKey);
171
+ expect(sealer1.secret).toEqual(sealer2.secret);
172
+ });
173
+
174
+ test("groupSealerFromReadKey produces different sealers for different keys", () => {
175
+ const readKey1 = crypto.newRandomKeySecret();
176
+ const readKey2 = crypto.newRandomKeySecret();
177
+
178
+ const sealer1 = crypto.groupSealerFromReadKey(readKey1.secret);
179
+ const sealer2 = crypto.groupSealerFromReadKey(readKey2.secret);
180
+
181
+ expect(sealer1.publicKey).not.toEqual(sealer2.publicKey);
182
+ expect(sealer1.secret).not.toEqual(sealer2.secret);
183
+ });
184
+
185
+ test("derived sealer works with sealForGroup/unsealForGroup", () => {
186
+ const readKey = crypto.newRandomKeySecret();
187
+ const sealer = crypto.groupSealerFromReadKey(readKey.secret);
188
+
189
+ const data = { key: "value", num: 42 };
190
+ const nOnceMaterial = {
191
+ in: "co_zTEST" as const,
192
+ tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
193
+ };
194
+
195
+ const sealed = crypto.sealForGroup({
196
+ message: data,
197
+ to: sealer.publicKey,
198
+ nOnceMaterial,
199
+ });
200
+
201
+ const unsealed = crypto.unsealForGroup(
202
+ sealed,
203
+ sealer.secret,
204
+ nOnceMaterial,
205
+ );
206
+ expect(unsealed).toEqual(data);
207
+ });
208
+ });
209
+
210
+ // ============================================================================
211
+ // Integration tests for groupSealer (Tasks 27-36)
212
+ // ============================================================================
213
+
214
+ let jazzCloud: ReturnType<typeof setupTestNode>;
215
+
216
+ beforeEach(async () => {
217
+ SyncMessagesLog.clear();
218
+ jazzCloud = setupTestNode({ isSyncServer: true });
219
+ });
220
+
221
+ describe("groups created with groupSealer", () => {
222
+ test("new groups are created with a groupSealer field (Task 27)", async () => {
223
+ const { node1 } = await createTwoConnectedNodes("server", "server");
224
+
225
+ const group = node1.node.createGroup();
226
+
227
+ const groupSealer = group.get("groupSealer");
228
+ expect(groupSealer).toBeDefined();
229
+ // New composite format: "key_z...@sealer_z..."
230
+ expect(groupSealer).toMatch(/^key_z.+@sealer_z/);
231
+ });
232
+
233
+ test("groupSealer is derived from readKey deterministically (Task 28)", async () => {
234
+ const { node1 } = await createTwoConnectedNodes("server", "server");
235
+
236
+ const group = node1.node.createGroup();
237
+
238
+ const groupSealer = group.get("groupSealer") as string;
239
+ const readKey = group.getCurrentReadKey();
240
+
241
+ expect(readKey.secret).toBeDefined();
242
+ if (!readKey.secret) {
243
+ throw new Error("Expected read key secret");
244
+ }
245
+
246
+ const derivedSealer = crypto.groupSealerFromReadKey(readKey.secret);
247
+ // Composite format: "readKeyID@sealerID"
248
+ expect(groupSealer).toEqual(`${readKey.id}@${derivedSealer.publicKey}`);
249
+ });
250
+
251
+ test("getGroupSealerSecret derives the correct secret", async () => {
252
+ const { node1 } = await createTwoConnectedNodes("server", "server");
253
+
254
+ const group = node1.node.createGroup();
255
+
256
+ const groupSealerSecret = group.getGroupSealerSecret();
257
+ expect(groupSealerSecret).toBeDefined();
258
+
259
+ const groupSealerValue = group.get("groupSealer") as string;
260
+ expect(groupSealerValue).toBeDefined();
261
+
262
+ // Verify the secret corresponds to the SealerID embedded in the composite value
263
+ assert(groupSealerSecret, "Expected groupSealerSecret");
264
+ const derivedID = crypto.getSealerID(groupSealerSecret);
265
+ expect(groupSealerValue).toContain(derivedID);
266
+ });
267
+ });
268
+
269
+ describe("non-member extending child to parent via groupSealer", () => {
270
+ test("parent member can read child group content via extension", async () => {
271
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
272
+ "server",
273
+ "server",
274
+ "server",
275
+ );
276
+
277
+ // Node1 creates parent group and adds node2 as member
278
+ const parentGroup = node1.node.createGroup();
279
+ await parentGroup.core.waitForSync();
280
+
281
+ // Node2 creates child group and extends parent (node2 is a member of parent)
282
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
283
+ const childGroup = node2.node.createGroup();
284
+ childGroup.extend(parentOnNode2);
285
+
286
+ // Add node3 as writeOnly to child (node3 is NOT a member of parent)
287
+ const account3OnNode2 = await loadCoValueOrFail(
288
+ node2.node,
289
+ node3.accountID,
290
+ );
291
+ childGroup.addMember(account3OnNode2, "writeOnly");
292
+
293
+ const map = childGroup.createMap();
294
+ map.set("test", "Written by node2");
295
+
296
+ await map.core.waitForSync();
297
+ await childGroup.core.waitForSync();
298
+
299
+ // Node1 (parent member) should be able to read content from child
300
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
301
+ expect(mapOnNode1.get("test")).toEqual("Written by node2");
302
+
303
+ // Verify that the child group has the groupSealer set
304
+ const childGroupOnNode1 = await loadCoValueOrFail(
305
+ node1.node,
306
+ childGroup.id,
307
+ );
308
+ const childGroupSealer = childGroupOnNode1.get("groupSealer");
309
+ expect(childGroupSealer).toBeDefined();
310
+ // New composite format: "key_z...@sealer_z..."
311
+ expect(childGroupSealer).toMatch(/^key_z.+@sealer_z/);
312
+ });
313
+
314
+ test("writeOnly member uses groupSealer for key revelation", async () => {
315
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
316
+ "server",
317
+ "server",
318
+ "server",
319
+ );
320
+
321
+ // Node1 creates parent group and adds node2 as admin
322
+ const parentGroup = node1.node.createGroup();
323
+ const account2OnNode1 = await loadCoValueOrFail(
324
+ node1.node,
325
+ node2.accountID,
326
+ );
327
+ parentGroup.addMember(account2OnNode1, "admin");
328
+
329
+ await parentGroup.core.waitForSync();
330
+
331
+ // Node2 creates child group and extends parent
332
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
333
+ const childGroup = node2.node.createGroup();
334
+ childGroup.extend(parentOnNode2);
335
+
336
+ // Add node3 as writeOnly to child (node3 is NOT a member of parent)
337
+ const account3OnNode2 = await loadCoValueOrFail(
338
+ node2.node,
339
+ node3.accountID,
340
+ );
341
+ childGroup.addMember(account3OnNode2, "writeOnly");
342
+
343
+ await childGroup.core.waitForSync();
344
+
345
+ // Node3 now has writeOnly access to child but no access to parent
346
+ // When node3 writes, the child needs to reveal keys to parent via groupSealer
347
+ const childGroupOnNode3 = await loadCoValueOrFail(
348
+ node3.node,
349
+ childGroup.id,
350
+ );
351
+
352
+ const map = childGroupOnNode3.createMap();
353
+ map.set("test", "Written by node3 (writeOnly)");
354
+
355
+ await map.core.waitForSync();
356
+
357
+ // Node1 (parent admin) should be able to read content written by node3
358
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
359
+ expect(mapOnNode1.get("test")).toEqual("Written by node3 (writeOnly)");
360
+ });
361
+
362
+ test("no writeOnly key created when parent has groupSealer", async () => {
363
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
364
+ "server",
365
+ "server",
366
+ "server",
367
+ );
368
+
369
+ const parentGroup = node1.node.createGroup();
370
+ const account2OnNode1 = await loadCoValueOrFail(
371
+ node1.node,
372
+ node2.accountID,
373
+ );
374
+ parentGroup.addMember(account2OnNode1, "writer");
375
+
376
+ await parentGroup.core.waitForSync();
377
+
378
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
379
+ const childGroup = node2.node.createGroup();
380
+ childGroup.extend(parentOnNode2);
381
+
382
+ // Add node3 as writeOnly (this should use groupSealer, not writeOnly key)
383
+ const account3OnNode2 = await loadCoValueOrFail(
384
+ node2.node,
385
+ node3.accountID,
386
+ );
387
+ childGroup.addMember(account3OnNode2, "writeOnly");
388
+
389
+ await childGroup.core.waitForSync();
390
+
391
+ // Check that no writeKeyFor_ entry exists for the parent group
392
+ // This verifies we're using groupSealer instead of writeOnly keys
393
+ const childGroupOnNode1 = await loadCoValueOrFail(
394
+ node1.node,
395
+ childGroup.id,
396
+ );
397
+
398
+ // The parent group ID should not have a writeKeyFor entry in the child
399
+ const writeKeyForParent = childGroupOnNode1.get(
400
+ `writeKeyFor_${parentGroup.id}` as any,
401
+ );
402
+ expect(writeKeyForParent).toBeUndefined();
403
+ });
404
+
405
+ // Role variation tests
406
+ test("reader in parent can read child content via groupSealer extension", async () => {
407
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
408
+ "server",
409
+ "server",
410
+ "server",
411
+ );
412
+
413
+ // Node1 creates parent group and adds node2 as reader (not admin/writer)
414
+ const parentGroup = node1.node.createGroup();
415
+ const account2OnNode1 = await loadCoValueOrFail(
416
+ node1.node,
417
+ node2.accountID,
418
+ );
419
+ parentGroup.addMember(account2OnNode1, "reader");
420
+
421
+ await parentGroup.core.waitForSync();
422
+
423
+ // Node3 creates child group and extends parent (node3 is NOT a member of parent)
424
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
425
+ const childGroup = node3.node.createGroup();
426
+ childGroup.extend(parentOnNode3);
427
+
428
+ const map = childGroup.createMap();
429
+ map.set("test", "Written by non-member node3");
430
+
431
+ await map.core.waitForSync();
432
+ await childGroup.core.waitForSync();
433
+
434
+ // Node2 (reader in parent) should be able to read content from child
435
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
436
+ expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
437
+ });
438
+
439
+ test("writer in parent can read child content via groupSealer extension", async () => {
440
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
441
+ "server",
442
+ "server",
443
+ "server",
444
+ );
445
+
446
+ // Node1 creates parent group and adds node2 as writer
447
+ const parentGroup = node1.node.createGroup();
448
+ const account2OnNode1 = await loadCoValueOrFail(
449
+ node1.node,
450
+ node2.accountID,
451
+ );
452
+ parentGroup.addMember(account2OnNode1, "writer");
453
+
454
+ await parentGroup.core.waitForSync();
455
+
456
+ // Node3 creates child group and extends parent (node3 is NOT a member of parent)
457
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
458
+ const childGroup = node3.node.createGroup();
459
+ childGroup.extend(parentOnNode3);
460
+
461
+ const map = childGroup.createMap();
462
+ map.set("test", "Written by non-member node3");
463
+
464
+ await map.core.waitForSync();
465
+ await childGroup.core.waitForSync();
466
+
467
+ // Node2 (writer in parent) should be able to read content from child
468
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
469
+ expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
470
+ });
471
+
472
+ test("manager in parent can read child content via groupSealer extension", async () => {
473
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
474
+ "server",
475
+ "server",
476
+ "server",
477
+ );
478
+
479
+ // Node1 creates parent group and adds node2 as manager
480
+ const parentGroup = node1.node.createGroup();
481
+ const account2OnNode1 = await loadCoValueOrFail(
482
+ node1.node,
483
+ node2.accountID,
484
+ );
485
+ parentGroup.addMember(account2OnNode1, "manager");
486
+
487
+ await parentGroup.core.waitForSync();
488
+
489
+ // Node3 creates child group and extends parent (node3 is NOT a member of parent)
490
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
491
+ const childGroup = node3.node.createGroup();
492
+ childGroup.extend(parentOnNode3);
493
+
494
+ const map = childGroup.createMap();
495
+ map.set("test", "Written by non-member node3");
496
+
497
+ await map.core.waitForSync();
498
+ await childGroup.core.waitForSync();
499
+
500
+ // Node2 (manager in parent) should be able to read content from child
501
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
502
+ expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
503
+ });
504
+
505
+ // Multi-level hierarchy tests
506
+ test("three-level extension chain: grandparent can read grandchild content", async () => {
507
+ const nodes = await createNConnectedNodes(
508
+ "server",
509
+ "server",
510
+ "server",
511
+ "server",
512
+ );
513
+ const node1 = nodes[0]!;
514
+ const node2 = nodes[1]!;
515
+ const node3 = nodes[2]!;
516
+ const node4 = nodes[3]!;
517
+
518
+ // Node1 creates grandparent group and adds node2 as admin
519
+ const grandparentGroup = node1.node.createGroup();
520
+ const account2OnNode1 = await loadCoValueOrFail(
521
+ node1.node,
522
+ node2.accountID,
523
+ );
524
+ grandparentGroup.addMember(account2OnNode1, "admin");
525
+
526
+ await grandparentGroup.core.waitForSync();
527
+
528
+ // Node2 creates parent group and extends grandparent
529
+ const grandparentOnNode2 = await loadCoValueOrFail(
530
+ node2.node,
531
+ grandparentGroup.id,
532
+ );
533
+ const parentGroup = node2.node.createGroup();
534
+ parentGroup.extend(grandparentOnNode2);
535
+
536
+ await parentGroup.core.waitForSync();
537
+
538
+ // Node3 (NOT a member of grandparent or parent) creates child and extends parent
539
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
540
+ const childGroup = node3.node.createGroup();
541
+ childGroup.extend(parentOnNode3);
542
+
543
+ // Node4 (NOT a member) creates grandchild and extends child
544
+ const childOnNode4 = await loadCoValueOrFail(node4.node, childGroup.id);
545
+ const grandchildGroup = node4.node.createGroup();
546
+ grandchildGroup.extend(childOnNode4);
547
+
548
+ const map = grandchildGroup.createMap();
549
+ map.set("test", "Written in grandchild by node4");
550
+
551
+ await map.core.waitForSync();
552
+ await grandchildGroup.core.waitForSync();
553
+ await childGroup.core.waitForSync();
554
+ await parentGroup.core.waitForSync();
555
+
556
+ // Node1 (grandparent admin) should be able to read content from grandchild
557
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
558
+ expect(mapOnNode1.get("test")).toEqual("Written in grandchild by node4");
559
+ });
560
+
561
+ test("parallel extensions: two non-members extend to same parent", async () => {
562
+ const nodes = await createNConnectedNodes(
563
+ "server",
564
+ "server",
565
+ "server",
566
+ "server",
567
+ );
568
+ const node1 = nodes[0]!;
569
+ const node2 = nodes[1]!;
570
+ const node3 = nodes[2]!;
571
+ const node4 = nodes[3]!;
572
+
573
+ // Node1 creates parent group
574
+ const parentGroup = node1.node.createGroup();
575
+
576
+ await parentGroup.core.waitForSync();
577
+
578
+ // Node2 (NOT a member of parent) creates child1 and extends parent
579
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
580
+ const childGroup1 = node2.node.createGroup();
581
+ childGroup1.extend(parentOnNode2);
582
+
583
+ const map1 = childGroup1.createMap();
584
+ map1.set("test", "Written by node2 in child1");
585
+
586
+ await map1.core.waitForSync();
587
+ await childGroup1.core.waitForSync();
588
+
589
+ // Node3 (NOT a member of parent) creates child2 and extends parent
590
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
591
+ const childGroup2 = node3.node.createGroup();
592
+ childGroup2.extend(parentOnNode3);
593
+
594
+ const map2 = childGroup2.createMap();
595
+ map2.set("test", "Written by node3 in child2");
596
+
597
+ await map2.core.waitForSync();
598
+ await childGroup2.core.waitForSync();
599
+
600
+ // Node4 (NOT a member of parent) creates child3 and extends parent
601
+ const parentOnNode4 = await loadCoValueOrFail(node4.node, parentGroup.id);
602
+ const childGroup3 = node4.node.createGroup();
603
+ childGroup3.extend(parentOnNode4);
604
+
605
+ const map3 = childGroup3.createMap();
606
+ map3.set("test", "Written by node4 in child3");
607
+
608
+ await map3.core.waitForSync();
609
+ await childGroup3.core.waitForSync();
610
+
611
+ // Node1 (parent admin) should be able to read all three child contents
612
+ const map1OnNode1 = await loadCoValueOrFail(node1.node, map1.id);
613
+ expect(map1OnNode1.get("test")).toEqual("Written by node2 in child1");
614
+
615
+ const map2OnNode1 = await loadCoValueOrFail(node1.node, map2.id);
616
+ expect(map2OnNode1.get("test")).toEqual("Written by node3 in child2");
617
+
618
+ const map3OnNode1 = await loadCoValueOrFail(node1.node, map3.id);
619
+ expect(map3OnNode1.get("test")).toEqual("Written by node4 in child3");
620
+ });
621
+
622
+ // Key rotation scenarios
623
+ test("extension after parent key rotation uses new groupSealer", async () => {
624
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
625
+ "server",
626
+ "server",
627
+ "server",
628
+ );
629
+
630
+ // Node1 creates parent group
631
+ const parentGroup = node1.node.createGroup();
632
+ const originalGroupSealer = parentGroup.get("groupSealer");
633
+
634
+ await parentGroup.core.waitForSync();
635
+
636
+ // Node1 rotates the parent group's read key (and thus groupSealer)
637
+ parentGroup.rotateReadKey();
638
+ const newGroupSealer = parentGroup.get("groupSealer");
639
+
640
+ expect(newGroupSealer).not.toEqual(originalGroupSealer);
641
+
642
+ await parentGroup.core.waitForSync();
643
+
644
+ // Node2 (NOT a member of parent) creates child and extends parent after rotation
645
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
646
+
647
+ // Verify node2 sees the new groupSealer
648
+ expect(parentOnNode2.get("groupSealer")).toEqual(newGroupSealer);
649
+
650
+ const childGroup = node2.node.createGroup();
651
+ childGroup.extend(parentOnNode2);
652
+
653
+ const map = childGroup.createMap();
654
+ map.set("test", "Written after parent key rotation");
655
+
656
+ await map.core.waitForSync();
657
+ await childGroup.core.waitForSync();
658
+
659
+ // Node1 (parent admin) should be able to read content using the new groupSealer
660
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
661
+ expect(mapOnNode1.get("test")).toEqual("Written after parent key rotation");
662
+ });
663
+
664
+ test("old content remains readable after child key rotation", async () => {
665
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
666
+ "server",
667
+ "server",
668
+ "server",
669
+ );
670
+
671
+ // Node1 creates parent group
672
+ const parentGroup = node1.node.createGroup();
673
+
674
+ await parentGroup.core.waitForSync();
675
+
676
+ // Node2 (NOT a member of parent) creates child and extends parent
677
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
678
+ const childGroup = node2.node.createGroup();
679
+ childGroup.extend(parentOnNode2);
680
+
681
+ // Create content before key rotation
682
+ const mapBefore = childGroup.createMap();
683
+ mapBefore.set("test", "Written before child key rotation");
684
+
685
+ await mapBefore.core.waitForSync();
686
+ await childGroup.core.waitForSync();
687
+
688
+ // Node1 verifies it can read content before rotation
689
+ const mapBeforeOnNode1 = await loadCoValueOrFail(node1.node, mapBefore.id);
690
+ expect(mapBeforeOnNode1.get("test")).toEqual(
691
+ "Written before child key rotation",
692
+ );
693
+
694
+ // Node2 rotates child group's key
695
+ // Note: For non-members, the new key cannot be revealed to parent via groupSealer
696
+ // in the current implementation (see GitHub issue #1979)
697
+ childGroup.rotateReadKey();
698
+
699
+ await childGroup.core.waitForSync();
700
+
701
+ // Node1 should still be able to read OLD content created before rotation
702
+ // This verifies the historical sealer mechanism works correctly
703
+ const mapBeforeOnNode1Again = await loadCoValueOrFail(
704
+ node1.node,
705
+ mapBefore.id,
706
+ );
707
+ expect(mapBeforeOnNode1Again.get("test")).toEqual(
708
+ "Written before child key rotation",
709
+ );
710
+ });
711
+
712
+ test("member of parent can read child content after child key rotation", async () => {
713
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
714
+ "server",
715
+ "server",
716
+ "server",
717
+ );
718
+
719
+ // Node1 creates parent group and adds node2 as admin
720
+ const parentGroup = node1.node.createGroup();
721
+ const account2OnNode1 = await loadCoValueOrFail(
722
+ node1.node,
723
+ node2.accountID,
724
+ );
725
+ parentGroup.addMember(account2OnNode1, "admin");
726
+
727
+ await parentGroup.core.waitForSync();
728
+
729
+ // Node2 (member of parent) creates child and extends parent
730
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
731
+ const childGroup = node2.node.createGroup();
732
+ childGroup.extend(parentOnNode2);
733
+
734
+ // Create content before key rotation
735
+ const mapBefore = childGroup.createMap();
736
+ mapBefore.set("test", "Written before rotation");
737
+
738
+ await mapBefore.core.waitForSync();
739
+ await childGroup.core.waitForSync();
740
+
741
+ // Node2 rotates child group's key (as a member, they have access to parent readKey)
742
+ childGroup.rotateReadKey();
743
+
744
+ // Create content after key rotation
745
+ const mapAfter = childGroup.createMap();
746
+ mapAfter.set("test", "Written after rotation");
747
+
748
+ await mapAfter.core.waitForSync();
749
+ await childGroup.core.waitForSync();
750
+
751
+ // Node1 should be able to read both old and new content
752
+ const mapBeforeOnNode1 = await loadCoValueOrFail(node1.node, mapBefore.id);
753
+ expect(mapBeforeOnNode1.get("test")).toEqual("Written before rotation");
754
+
755
+ const mapAfterOnNode1 = await loadCoValueOrFail(node1.node, mapAfter.id);
756
+ expect(mapAfterOnNode1.get("test")).toEqual("Written after rotation");
757
+ });
758
+
759
+ test("old content visible after 3 key rotations via historical groupSealer", async () => {
760
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
761
+ "server",
762
+ "server",
763
+ "server",
764
+ );
765
+
766
+ // Node1 creates parent group and adds node2 as admin
767
+ const parentGroup = node1.node.createGroup();
768
+ const account2OnNode1 = await loadCoValueOrFail(
769
+ node1.node,
770
+ node2.accountID,
771
+ );
772
+ parentGroup.addMember(account2OnNode1, "admin");
773
+
774
+ await parentGroup.core.waitForSync();
775
+
776
+ // Node2 (member of parent) creates child and extends parent
777
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
778
+ const childGroup = node2.node.createGroup();
779
+ childGroup.extend(parentOnNode2);
780
+
781
+ // Create content with the initial key
782
+ const map0 = childGroup.createMap();
783
+ map0.set("test", "Before any rotation");
784
+
785
+ await map0.core.waitForSync();
786
+ await childGroup.core.waitForSync();
787
+
788
+ // Rotation 1
789
+ childGroup.rotateReadKey();
790
+
791
+ const map1 = childGroup.createMap();
792
+ map1.set("test", "After rotation 1");
793
+
794
+ await map1.core.waitForSync();
795
+ await childGroup.core.waitForSync();
796
+
797
+ // Rotation 2
798
+ childGroup.rotateReadKey();
799
+
800
+ const map2 = childGroup.createMap();
801
+ map2.set("test", "After rotation 2");
802
+
803
+ await map2.core.waitForSync();
804
+ await childGroup.core.waitForSync();
805
+
806
+ // Rotation 3
807
+ childGroup.rotateReadKey();
808
+
809
+ const map3 = childGroup.createMap();
810
+ map3.set("test", "After rotation 3");
811
+
812
+ await map3.core.waitForSync();
813
+ await childGroup.core.waitForSync();
814
+
815
+ // Node1 (parent admin) should be able to read content from all key generations
816
+ const map0OnNode1 = await loadCoValueOrFail(node1.node, map0.id);
817
+ expect(map0OnNode1.get("test")).toEqual("Before any rotation");
818
+
819
+ const map1OnNode1 = await loadCoValueOrFail(node1.node, map1.id);
820
+ expect(map1OnNode1.get("test")).toEqual("After rotation 1");
821
+
822
+ const map2OnNode1 = await loadCoValueOrFail(node1.node, map2.id);
823
+ expect(map2OnNode1.get("test")).toEqual("After rotation 2");
824
+
825
+ const map3OnNode1 = await loadCoValueOrFail(node1.node, map3.id);
826
+ expect(map3OnNode1.get("test")).toEqual("After rotation 3");
827
+ });
828
+
829
+ // Edge case tests
830
+ test("non-member can create multiple CoValues readable by parent", async () => {
831
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
832
+ "server",
833
+ "server",
834
+ "server",
835
+ );
836
+
837
+ // Node1 creates parent group
838
+ const parentGroup = node1.node.createGroup();
839
+
840
+ await parentGroup.core.waitForSync();
841
+
842
+ // Node2 (NOT a member of parent) creates child and extends parent
843
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
844
+ const childGroup = node2.node.createGroup();
845
+ childGroup.extend(parentOnNode2);
846
+
847
+ // Create multiple maps
848
+ const map1 = childGroup.createMap();
849
+ map1.set("name", "Map 1");
850
+
851
+ const map2 = childGroup.createMap();
852
+ map2.set("name", "Map 2");
853
+
854
+ const map3 = childGroup.createMap();
855
+ map3.set("name", "Map 3");
856
+
857
+ const map4 = childGroup.createMap();
858
+ map4.set("name", "Map 4");
859
+
860
+ const map5 = childGroup.createMap();
861
+ map5.set("name", "Map 5");
862
+
863
+ await Promise.all([
864
+ map1.core.waitForSync(),
865
+ map2.core.waitForSync(),
866
+ map3.core.waitForSync(),
867
+ map4.core.waitForSync(),
868
+ map5.core.waitForSync(),
869
+ ]);
870
+ await childGroup.core.waitForSync();
871
+
872
+ // Node1 (parent admin) should be able to read all maps
873
+ const map1OnNode1 = await loadCoValueOrFail(node1.node, map1.id);
874
+ expect(map1OnNode1.get("name")).toEqual("Map 1");
875
+
876
+ const map2OnNode1 = await loadCoValueOrFail(node1.node, map2.id);
877
+ expect(map2OnNode1.get("name")).toEqual("Map 2");
878
+
879
+ const map3OnNode1 = await loadCoValueOrFail(node1.node, map3.id);
880
+ expect(map3OnNode1.get("name")).toEqual("Map 3");
881
+
882
+ const map4OnNode1 = await loadCoValueOrFail(node1.node, map4.id);
883
+ expect(map4OnNode1.get("name")).toEqual("Map 4");
884
+
885
+ const map5OnNode1 = await loadCoValueOrFail(node1.node, map5.id);
886
+ expect(map5OnNode1.get("name")).toEqual("Map 5");
887
+ });
888
+
889
+ test("content created before extension is accessible after extension", async () => {
890
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
891
+ "server",
892
+ "server",
893
+ "server",
894
+ );
895
+
896
+ // Node1 creates parent group
897
+ const parentGroup = node1.node.createGroup();
898
+
899
+ await parentGroup.core.waitForSync();
900
+
901
+ // Node2 creates child group and content BEFORE extending
902
+ const childGroup = node2.node.createGroup();
903
+
904
+ const mapBefore = childGroup.createMap();
905
+ mapBefore.set("test", "Created before extension");
906
+
907
+ await mapBefore.core.waitForSync();
908
+ await childGroup.core.waitForSync();
909
+
910
+ // Now extend the child to parent
911
+ const parentOnNode2 = await loadCoValueOrFail(node2.node, parentGroup.id);
912
+ childGroup.extend(parentOnNode2);
913
+
914
+ // Create content after extension
915
+ const mapAfter = childGroup.createMap();
916
+ mapAfter.set("test", "Created after extension");
917
+
918
+ await mapAfter.core.waitForSync();
919
+ await childGroup.core.waitForSync();
920
+
921
+ // Node1 should be able to read content created both before and after extension
922
+ const mapBeforeOnNode1 = await loadCoValueOrFail(node1.node, mapBefore.id);
923
+ expect(mapBeforeOnNode1.get("test")).toEqual("Created before extension");
924
+
925
+ const mapAfterOnNode1 = await loadCoValueOrFail(node1.node, mapAfter.id);
926
+ expect(mapAfterOnNode1.get("test")).toEqual("Created after extension");
927
+ });
928
+
929
+ test("nested group membership: members of member-group can read via groupSealer", async () => {
930
+ const nodes = await createNConnectedNodes(
931
+ "server",
932
+ "server",
933
+ "server",
934
+ "server",
935
+ );
936
+ const node1 = nodes[0]!;
937
+ const node2 = nodes[1]!;
938
+ const node3 = nodes[2]!;
939
+ const node4 = nodes[3]!;
940
+
941
+ // Node1 creates an inner group and adds node2 as admin
942
+ const innerGroup = node1.node.createGroup();
943
+ const account2OnNode1 = await loadCoValueOrFail(
944
+ node1.node,
945
+ node2.accountID,
946
+ );
947
+ innerGroup.addMember(account2OnNode1, "admin");
948
+
949
+ await innerGroup.core.waitForSync();
950
+
951
+ // Node1 creates parent group and adds innerGroup as member (nested group membership)
952
+ // Using extend() to add innerGroup's members to parentGroup
953
+ const parentGroup = node1.node.createGroup();
954
+ parentGroup.extend(innerGroup, "writer");
955
+
956
+ await parentGroup.core.waitForSync();
957
+
958
+ // Node3 (NOT a member of parent or innerGroup) creates child and extends parent
959
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
960
+ const childGroup = node3.node.createGroup();
961
+ childGroup.extend(parentOnNode3);
962
+
963
+ const map = childGroup.createMap();
964
+ map.set("test", "Written by non-member node3");
965
+
966
+ await map.core.waitForSync();
967
+ await childGroup.core.waitForSync();
968
+
969
+ // Node2 (member of innerGroup, which is extended by parent) should be able to read
970
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
971
+ expect(mapOnNode2.get("test")).toEqual("Written by non-member node3");
972
+
973
+ // Node1 (admin of both groups) should also be able to read
974
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
975
+ expect(mapOnNode1.get("test")).toEqual("Written by non-member node3");
976
+ });
977
+ });
978
+
979
+ describe("key rotation updates groupSealer", () => {
980
+ test("rotating readKey also rotates groupSealer", async () => {
981
+ const { node1 } = await createTwoConnectedNodes("server", "server");
982
+
983
+ const group = node1.node.createGroup();
984
+ const originalGroupSealer = group.get("groupSealer");
985
+
986
+ // Force key rotation
987
+ group.rotateReadKey();
988
+
989
+ const newGroupSealer = group.get("groupSealer") as string;
990
+
991
+ expect(newGroupSealer).toBeDefined();
992
+ expect(newGroupSealer).not.toEqual(originalGroupSealer);
993
+
994
+ // Verify the new sealer is derived from the new read key
995
+ const newReadKey = group.getCurrentReadKey();
996
+ if (!newReadKey.secret) {
997
+ throw new Error("Expected read key secret after rotation");
998
+ }
999
+
1000
+ const derivedSealer = crypto.groupSealerFromReadKey(newReadKey.secret);
1001
+ // Composite format includes the readKeyID
1002
+ expect(newGroupSealer).toEqual(
1003
+ `${newReadKey.id}@${derivedSealer.publicKey}`,
1004
+ );
1005
+ });
1006
+ });
1007
+
1008
+ describe("concurrent group sealer initialization", () => {
1009
+ test("concurrent group sealer initialization produces same result", () => {
1010
+ // Since groupSealer is derived deterministically from readKey,
1011
+ // multiple calls with the same readKey will always produce the same result
1012
+ const readKey = crypto.newRandomKeySecret();
1013
+
1014
+ // Simulate concurrent derivations
1015
+ const results = Array.from({ length: 10 }, () =>
1016
+ crypto.groupSealerFromReadKey(readKey.secret),
1017
+ );
1018
+
1019
+ // All results should be identical
1020
+ const firstResult = results[0];
1021
+ for (const result of results) {
1022
+ expect(result.publicKey).toEqual(firstResult!.publicKey);
1023
+ expect(result.secret).toEqual(firstResult!.secret);
1024
+ }
1025
+ });
1026
+ });
1027
+
1028
+ describe("groupSealer composite format (readKeyID association)", () => {
1029
+ test("groupSealer stores readKeyID in composite format", async () => {
1030
+ const { node1 } = await createTwoConnectedNodes("server", "server");
1031
+
1032
+ const group = node1.node.createGroup();
1033
+ const groupSealerValue = group.get("groupSealer") as string;
1034
+
1035
+ // Should be in composite format: "key_z...@sealer_z..."
1036
+ expect(groupSealerValue).toMatch(/^key_z.+@sealer_z/);
1037
+
1038
+ // The readKeyID portion should match the current readKey
1039
+ const readKeyId = group.getCurrentReadKeyId();
1040
+ expect(groupSealerValue.startsWith(readKeyId!)).toBe(true);
1041
+ });
1042
+
1043
+ test("concurrent key rotation and migration produce correct readKey association", async () => {
1044
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
1045
+ "server",
1046
+ "server",
1047
+ "server",
1048
+ );
1049
+
1050
+ // Create a legacy group on node1, add node2 and node3 as admins
1051
+ const legacyGroup = createLegacyGroup(node1.node);
1052
+ const account2OnNode1 = await loadCoValueOrFail(
1053
+ node1.node,
1054
+ node2.accountID,
1055
+ );
1056
+ const account3OnNode1 = await loadCoValueOrFail(
1057
+ node1.node,
1058
+ node3.accountID,
1059
+ );
1060
+ legacyGroup.addMember(account2OnNode1, "admin");
1061
+ legacyGroup.addMember(account3OnNode1, "admin");
1062
+
1063
+ await legacyGroup.core.waitForSync();
1064
+
1065
+ // Node2 loads the group and triggers migration (sets groupSealer from key1)
1066
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
1067
+ await groupOnNode2.core.waitForSync();
1068
+
1069
+ // Verify groupSealer was set with composite format
1070
+ const sealerOnNode2 = groupOnNode2.get("groupSealer") as string;
1071
+ expect(sealerOnNode2).toMatch(/^key_z.+@sealer_z/);
1072
+
1073
+ // Now node2 rotates the read key (simulating concurrent rotation)
1074
+ groupOnNode2.rotateReadKey();
1075
+ await groupOnNode2.core.waitForSync();
1076
+
1077
+ // The groupSealer should now reference the NEW readKey, not the old one
1078
+ const newSealerOnNode2 = groupOnNode2.get("groupSealer") as string;
1079
+ const newReadKeyId = groupOnNode2.getCurrentReadKeyId();
1080
+ expect(newSealerOnNode2.startsWith(newReadKeyId!)).toBe(true);
1081
+
1082
+ // The new sealer should be different from the old one
1083
+ expect(newSealerOnNode2).not.toEqual(sealerOnNode2);
1084
+ });
1085
+
1086
+ test("child group extended via groupSealer is readable after parent key rotation + migration race", async () => {
1087
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
1088
+ "server",
1089
+ "server",
1090
+ "server",
1091
+ );
1092
+
1093
+ // Node1 creates parent group with both node2 and node3 as admins
1094
+ const parentGroup = createLegacyGroup(node1.node);
1095
+ const account2OnNode1 = await loadCoValueOrFail(
1096
+ node1.node,
1097
+ node2.accountID,
1098
+ );
1099
+ parentGroup.addMember(account2OnNode1, "admin");
1100
+
1101
+ await parentGroup.core.waitForSync();
1102
+
1103
+ // Node2 sees the parent group
1104
+ parentGroup.rotateReadKey();
1105
+ const parentGroupOnNode2 = await loadCoValueOrFail(
1106
+ node2.node,
1107
+ parentGroup.id,
1108
+ );
1109
+
1110
+ await Promise.all([
1111
+ parentGroup.core.waitForSync(),
1112
+ parentGroupOnNode2.core.waitForSync(),
1113
+ ]);
1114
+
1115
+ // Node3 (non-member) creates child and extends parent using the current groupSealer
1116
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, parentGroup.id);
1117
+ const childGroup = node3.node.createGroup();
1118
+ childGroup.extend(parentOnNode3);
1119
+
1120
+ const map = childGroup.createMap();
1121
+ map.set("test", "Written by non-member");
1122
+
1123
+ await map.core.waitForSync();
1124
+ await childGroup.core.waitForSync();
1125
+
1126
+ // Both node1 and node2 should be able to read the child content
1127
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
1128
+ expect(mapOnNode1.get("test")).toEqual("Written by non-member");
1129
+
1130
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
1131
+ expect(mapOnNode2.get("test")).toEqual("Written by non-member");
1132
+ });
1133
+ });
1134
+
1135
+ describe("permission validation for groupSealer", () => {
1136
+ test("non-admin cannot set groupSealer", async () => {
1137
+ const { node1, node2 } = await createTwoConnectedNodes("server", "server");
1138
+
1139
+ const group = node1.node.createGroup();
1140
+ const account2OnNode1 = await loadCoValueOrFail(
1141
+ node1.node,
1142
+ node2.accountID,
1143
+ );
1144
+
1145
+ // Add node2 as reader (not admin)
1146
+ group.addMember(account2OnNode1, "reader");
1147
+
1148
+ await group.core.waitForSync();
1149
+
1150
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
1151
+
1152
+ const originalSealer = groupOnNode2.get("groupSealer");
1153
+
1154
+ // Attempt to set groupSealer as a reader
1155
+ const fakeSealer = crypto.newRandomSealer();
1156
+ const fakeSealerID = crypto.getSealerID(fakeSealer);
1157
+
1158
+ groupOnNode2.set(
1159
+ "groupSealer",
1160
+ `${group.getCurrentReadKeyId()!}@${fakeSealerID}`,
1161
+ "trusting",
1162
+ );
1163
+
1164
+ // The change should be rejected (sealer should remain original)
1165
+ // Wait for sync to ensure changes are processed
1166
+ await groupOnNode2.core.waitForSync();
1167
+
1168
+ // Re-load group on node1 to check if the invalid change was rejected
1169
+ // The original sealer should be preserved because readers can't set groupSealer
1170
+ expect(group.get("groupSealer")).toEqual(originalSealer);
1171
+ });
1172
+
1173
+ test("admin can set groupSealer", async () => {
1174
+ const { node1 } = await createTwoConnectedNodes("server", "server");
1175
+
1176
+ const group = node1.node.createGroup();
1177
+ const originalSealer = group.get("groupSealer");
1178
+
1179
+ // As admin, rotate the read key which will update groupSealer
1180
+ group.rotateReadKey();
1181
+
1182
+ const newSealer = group.get("groupSealer");
1183
+ expect(newSealer).toBeDefined();
1184
+ expect(newSealer).not.toEqual(originalSealer);
1185
+ });
1186
+ });
1187
+
1188
+ describe("groupSealer migration for legacy groups", () => {
1189
+ test("legacy group without groupSealer gets migrated when loaded by admin on another node", async () => {
1190
+ const { node1, node2 } = await createTwoConnectedNodes("server", "server");
1191
+
1192
+ // Create a legacy group (without groupSealer) on node1, add node2 as admin
1193
+ const legacyGroup = createLegacyGroup(node1.node);
1194
+ const account2OnNode1 = await loadCoValueOrFail(
1195
+ node1.node,
1196
+ node2.accountID,
1197
+ );
1198
+ legacyGroup.addMember(account2OnNode1, "admin");
1199
+
1200
+ expect(legacyGroup.get("groupSealer")).toBeUndefined();
1201
+
1202
+ await legacyGroup.core.waitForSync();
1203
+
1204
+ await new Promise((resolve) => setTimeout(resolve, 10));
1205
+
1206
+ // Node2 (admin) loads the group - migration should add the groupSealer
1207
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
1208
+ expect(groupOnNode2.get("groupSealer")).toBeDefined();
1209
+ // Migrated groups use the new composite format
1210
+ expect(groupOnNode2.get("groupSealer")).toMatch(/^key_z.+@sealer_z/);
1211
+
1212
+ // Verify it's derived from the current read key
1213
+ const readKey = groupOnNode2.getCurrentReadKey();
1214
+ expect(readKey.secret).toBeDefined();
1215
+ const expectedSealer = crypto.groupSealerFromReadKey(readKey.secret!);
1216
+ expect(groupOnNode2.get("groupSealer")).toEqual(
1217
+ `${readKey.id}@${expectedSealer.publicKey}`,
1218
+ );
1219
+ });
1220
+
1221
+ test("migration is idempotent - loading on two admin nodes produces same groupSealer", async () => {
1222
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
1223
+ "server",
1224
+ "server",
1225
+ "server",
1226
+ );
1227
+
1228
+ // Create a legacy group on node1, add node2 and node3 as admins
1229
+ const legacyGroup = createLegacyGroup(node1.node);
1230
+ const account2OnNode1 = await loadCoValueOrFail(
1231
+ node1.node,
1232
+ node2.accountID,
1233
+ );
1234
+ const account3OnNode1 = await loadCoValueOrFail(
1235
+ node1.node,
1236
+ node3.accountID,
1237
+ );
1238
+ legacyGroup.addMember(account2OnNode1, "admin");
1239
+ legacyGroup.addMember(account3OnNode1, "admin");
1240
+
1241
+ expect(legacyGroup.get("groupSealer")).toBeUndefined();
1242
+
1243
+ await legacyGroup.core.waitForSync();
1244
+
1245
+ // Node2 loads and migrates
1246
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
1247
+ await groupOnNode2.core.waitForSync();
1248
+
1249
+ const sealerFromNode2 = groupOnNode2.get("groupSealer");
1250
+ expect(sealerFromNode2).toBeDefined();
1251
+
1252
+ // Record transaction count after node2's migration has synced
1253
+ const transactionsAfterNode2 =
1254
+ groupOnNode2.core.getValidSortedTransactions();
1255
+
1256
+ // Node3 loads - groupSealer is already set via sync from node2,
1257
+ // so no new migration should be applied
1258
+ const groupOnNode3 = await loadCoValueOrFail(node3.node, legacyGroup.id);
1259
+ await groupOnNode3.core.waitForSync();
1260
+
1261
+ const sealerFromNode3 = groupOnNode3.get("groupSealer");
1262
+ expect(sealerFromNode3).toBeDefined();
1263
+
1264
+ // Both should have the same groupSealer (deterministic from readKey)
1265
+ expect(sealerFromNode3).toEqual(sealerFromNode2);
1266
+
1267
+ // Verify no redundant migration was applied — transaction count should be unchanged
1268
+ expect(groupOnNode3.core.getValidSortedTransactions()).toHaveLength(
1269
+ transactionsAfterNode2.length,
1270
+ );
1271
+ });
1272
+
1273
+ test("parallel migrations from different accounts produce same groupSealer", async () => {
1274
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
1275
+ "server",
1276
+ "server",
1277
+ "server",
1278
+ );
1279
+
1280
+ // Create a legacy group on node1, add node2 and node3 as admins
1281
+ const legacyGroup = createLegacyGroup(node1.node);
1282
+ const account2OnNode1 = await loadCoValueOrFail(
1283
+ node1.node,
1284
+ node2.accountID,
1285
+ );
1286
+ const account3OnNode1 = await loadCoValueOrFail(
1287
+ node1.node,
1288
+ node3.accountID,
1289
+ );
1290
+ legacyGroup.addMember(account2OnNode1, "admin");
1291
+ legacyGroup.addMember(account3OnNode1, "admin");
1292
+
1293
+ expect(legacyGroup.get("groupSealer")).toBeUndefined();
1294
+
1295
+ await legacyGroup.core.waitForSync();
1296
+
1297
+ // Both node2 and node3 load the group concurrently, triggering parallel migrations
1298
+ const [groupOnNode2, groupOnNode3] = await Promise.all([
1299
+ loadCoValueOrFail(node2.node, legacyGroup.id),
1300
+ loadCoValueOrFail(node3.node, legacyGroup.id),
1301
+ ]);
1302
+
1303
+ // Both should have a groupSealer set
1304
+ const sealerOnNode2 = groupOnNode2.get("groupSealer");
1305
+ const sealerOnNode3 = groupOnNode3.get("groupSealer");
1306
+
1307
+ expect(sealerOnNode2).toBeDefined();
1308
+ expect(sealerOnNode3).toBeDefined();
1309
+
1310
+ // Both should derive the same groupSealer since it's deterministic from readKey
1311
+ expect(sealerOnNode2).toEqual(sealerOnNode3);
1312
+
1313
+ // Wait for sync and verify convergence
1314
+ await groupOnNode2.core.waitForSync();
1315
+ await groupOnNode3.core.waitForSync();
1316
+
1317
+ // After sync, both should still agree
1318
+ expect(groupOnNode2.get("groupSealer")).toEqual(
1319
+ groupOnNode3.get("groupSealer"),
1320
+ );
1321
+ });
1322
+
1323
+ test("non-admin member does not trigger migration", async () => {
1324
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
1325
+ "server",
1326
+ "server",
1327
+ "server",
1328
+ );
1329
+
1330
+ // Create a legacy group on node1, add node2 as reader only
1331
+ const legacyGroup = createLegacyGroup(node1.node);
1332
+ const account2OnNode1 = await loadCoValueOrFail(
1333
+ node1.node,
1334
+ node2.accountID,
1335
+ );
1336
+ legacyGroup.addMember(account2OnNode1, "reader");
1337
+
1338
+ expect(legacyGroup.get("groupSealer")).toBeUndefined();
1339
+
1340
+ await legacyGroup.core.waitForSync();
1341
+
1342
+ const transactions = legacyGroup.core.getValidSortedTransactions();
1343
+
1344
+ // Node2 (reader) loads the group - should NOT trigger migration
1345
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, legacyGroup.id);
1346
+
1347
+ // The groupSealer should still be undefined because node2 is only a reader
1348
+ // and cannot set the groupSealer field
1349
+ expect(groupOnNode2.get("groupSealer")).toBeUndefined();
1350
+ expect(groupOnNode2.core.getValidSortedTransactions()).toHaveLength(
1351
+ transactions.length,
1352
+ );
1353
+ });
1354
+
1355
+ test("migrated legacy group works with non-member extension via groupSealer", async () => {
1356
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
1357
+ "server",
1358
+ "server",
1359
+ "server",
1360
+ );
1361
+
1362
+ // Create a legacy group on node1 (no groupSealer), add node2 as admin
1363
+ const legacyGroup = createLegacyGroup(node1.node);
1364
+ const account2OnNode1 = await loadCoValueOrFail(
1365
+ node1.node,
1366
+ node2.accountID,
1367
+ );
1368
+ legacyGroup.addMember(account2OnNode1, "admin");
1369
+
1370
+ expect(legacyGroup.get("groupSealer")).toBeUndefined();
1371
+
1372
+ await legacyGroup.core.waitForSync();
1373
+
1374
+ // Node2 (admin) loads the group, triggering migration
1375
+ const parentGroup = await loadCoValueOrFail(node2.node, legacyGroup.id);
1376
+
1377
+ // Wait for async migration to complete (runs via waitFor when not fully downloaded)
1378
+ await parentGroup.core.waitForSync();
1379
+
1380
+ // Verify migration happened
1381
+ expect(parentGroup.get("groupSealer")).toBeDefined();
1382
+
1383
+ await parentGroup.core.waitForSync();
1384
+
1385
+ // Node3 (NOT a member of parent) creates a child group and extends parent
1386
+ const parentOnNode3 = await loadCoValueOrFail(node3.node, legacyGroup.id);
1387
+ const childGroup = node3.node.createGroup();
1388
+ childGroup.extend(parentOnNode3);
1389
+
1390
+ const map = childGroup.createMap();
1391
+ map.set("test", "Written by non-member after migration");
1392
+
1393
+ await map.core.waitForSync();
1394
+ await childGroup.core.waitForSync();
1395
+
1396
+ // Node1 (original creator/admin) should be able to read content via migrated groupSealer
1397
+ // First, sync to pick up the migrated groupSealer
1398
+ const parentOnNode1 = await loadCoValueOrFail(node1.node, legacyGroup.id);
1399
+ await parentOnNode1.core.waitForSync();
1400
+
1401
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
1402
+ expect(mapOnNode1.get("test")).toEqual(
1403
+ "Written by non-member after migration",
1404
+ );
1405
+ });
1406
+
1407
+ test("legacy fallback: parent without groupSealer uses writeOnly key for non-member extension", async () => {
1408
+ const { node1, node2, node3 } = await createThreeConnectedNodes(
1409
+ "server",
1410
+ "server",
1411
+ "server",
1412
+ );
1413
+
1414
+ // Create a legacy parent group (without groupSealer) on node1
1415
+ const legacyParent = createLegacyGroup(node1.node);
1416
+ expect(legacyParent.get("groupSealer")).toBeUndefined();
1417
+
1418
+ await legacyParent.core.waitForSync();
1419
+
1420
+ // Node2 (NOT a member of parent) creates child and extends legacy parent
1421
+ const legacyParentOnNode2 = await loadCoValueOrFail(
1422
+ node2.node,
1423
+ legacyParent.id,
1424
+ );
1425
+ const childGroup = node2.node.createGroup();
1426
+ childGroup.extend(legacyParentOnNode2);
1427
+
1428
+ await childGroup.core.waitForSync();
1429
+
1430
+ // Verify the legacy fallback was used: a writeKeyFor_ entry should exist
1431
+ // in the parent group for the extending account
1432
+ const legacyParentUpdated = await loadCoValueOrFail(
1433
+ node1.node,
1434
+ legacyParent.id,
1435
+ );
1436
+
1437
+ const writeKeyForNode2 = legacyParentUpdated.get(
1438
+ `writeKeyFor_${node2.node.getCurrentAgent().id}` as any,
1439
+ );
1440
+ expect(writeKeyForNode2).toBeDefined();
1441
+
1442
+ // Verify NO _sealedFor_ entries exist (groupSealer path was NOT used)
1443
+ const sealedForKeys = legacyParentUpdated
1444
+ .keys()
1445
+ .filter((key) => key.includes("_sealedFor_"));
1446
+ expect(sealedForKeys).toHaveLength(0);
1447
+
1448
+ // Node3 is added as writeOnly to child by node2
1449
+ const account3OnNode2 = await loadCoValueOrFail(
1450
+ node2.node,
1451
+ node3.accountID,
1452
+ );
1453
+ childGroup.addMember(account3OnNode2, "writeOnly");
1454
+
1455
+ await childGroup.core.waitForSync();
1456
+
1457
+ // Node3 writes content
1458
+ const childGroupOnNode3 = await loadCoValueOrFail(
1459
+ node3.node,
1460
+ childGroup.id,
1461
+ );
1462
+ const map = childGroupOnNode3.createMap();
1463
+ map.set("test", "Written via legacy writeOnly fallback");
1464
+
1465
+ await map.core.waitForSync();
1466
+
1467
+ // Node1 (parent admin) should be able to read via the writeOnly key
1468
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
1469
+ expect(mapOnNode1.get("test")).toEqual(
1470
+ "Written via legacy writeOnly fallback",
1471
+ );
1472
+ });
1473
+ });