@meshline/sdk 0.0.0-stage → 0.1.0-alpha.1

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 (338) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/channels/repository.d.ts +73 -0
  4. package/dist/channels/repository.d.ts.map +1 -0
  5. package/dist/channels/repository.js +367 -0
  6. package/dist/channels/repository.js.map +1 -0
  7. package/dist/client/account-operations.d.ts +41 -0
  8. package/dist/client/account-operations.d.ts.map +1 -0
  9. package/dist/client/account-operations.js +304 -0
  10. package/dist/client/account-operations.js.map +1 -0
  11. package/dist/client/conversations.d.ts +36 -0
  12. package/dist/client/conversations.d.ts.map +1 -0
  13. package/dist/client/conversations.js +144 -0
  14. package/dist/client/conversations.js.map +1 -0
  15. package/dist/client.d.ts +53 -0
  16. package/dist/client.d.ts.map +1 -0
  17. package/dist/client.js +162 -0
  18. package/dist/client.js.map +1 -0
  19. package/dist/components/account.d.ts +38 -0
  20. package/dist/components/account.d.ts.map +1 -0
  21. package/dist/components/account.js +243 -0
  22. package/dist/components/account.js.map +1 -0
  23. package/dist/components/channel.d.ts +78 -0
  24. package/dist/components/channel.d.ts.map +1 -0
  25. package/dist/components/channel.js +475 -0
  26. package/dist/components/channel.js.map +1 -0
  27. package/dist/components/component.d.ts +49 -0
  28. package/dist/components/component.d.ts.map +1 -0
  29. package/dist/components/component.js +194 -0
  30. package/dist/components/component.js.map +1 -0
  31. package/dist/components/device.d.ts +60 -0
  32. package/dist/components/device.d.ts.map +1 -0
  33. package/dist/components/device.js +648 -0
  34. package/dist/components/device.js.map +1 -0
  35. package/dist/components/group.d.ts +141 -0
  36. package/dist/components/group.d.ts.map +1 -0
  37. package/dist/components/group.js +933 -0
  38. package/dist/components/group.js.map +1 -0
  39. package/dist/components/message.d.ts +63 -0
  40. package/dist/components/message.d.ts.map +1 -0
  41. package/dist/components/message.js +439 -0
  42. package/dist/components/message.js.map +1 -0
  43. package/dist/components/profile.d.ts +37 -0
  44. package/dist/components/profile.d.ts.map +1 -0
  45. package/dist/components/profile.js +232 -0
  46. package/dist/components/profile.js.map +1 -0
  47. package/dist/crypto/groups.d.ts +56 -0
  48. package/dist/crypto/groups.d.ts.map +1 -0
  49. package/dist/crypto/groups.js +163 -0
  50. package/dist/crypto/groups.js.map +1 -0
  51. package/dist/crypto/messages.d.ts +52 -0
  52. package/dist/crypto/messages.d.ts.map +1 -0
  53. package/dist/crypto/messages.js +180 -0
  54. package/dist/crypto/messages.js.map +1 -0
  55. package/dist/crypto/primitives.d.ts +30 -0
  56. package/dist/crypto/primitives.d.ts.map +1 -0
  57. package/dist/crypto/primitives.js +102 -0
  58. package/dist/crypto/primitives.js.map +1 -0
  59. package/dist/errors.d.ts +11 -0
  60. package/dist/errors.d.ts.map +1 -0
  61. package/dist/errors.js +14 -0
  62. package/dist/errors.js.map +1 -0
  63. package/dist/groups/account-sync.d.ts +28 -0
  64. package/dist/groups/account-sync.d.ts.map +1 -0
  65. package/dist/groups/account-sync.js +235 -0
  66. package/dist/groups/account-sync.js.map +1 -0
  67. package/dist/groups/messages.d.ts +39 -0
  68. package/dist/groups/messages.d.ts.map +1 -0
  69. package/dist/groups/messages.js +141 -0
  70. package/dist/groups/messages.js.map +1 -0
  71. package/dist/groups/operations.d.ts +57 -0
  72. package/dist/groups/operations.d.ts.map +1 -0
  73. package/dist/groups/operations.js +225 -0
  74. package/dist/groups/operations.js.map +1 -0
  75. package/dist/groups/repository.d.ts +56 -0
  76. package/dist/groups/repository.d.ts.map +1 -0
  77. package/dist/groups/repository.js +254 -0
  78. package/dist/groups/repository.js.map +1 -0
  79. package/dist/groups/secrets.d.ts +67 -0
  80. package/dist/groups/secrets.d.ts.map +1 -0
  81. package/dist/groups/secrets.js +452 -0
  82. package/dist/groups/secrets.js.map +1 -0
  83. package/dist/groups/state.d.ts +39 -0
  84. package/dist/groups/state.d.ts.map +1 -0
  85. package/dist/groups/state.js +201 -0
  86. package/dist/groups/state.js.map +1 -0
  87. package/dist/identity/identifiers.d.ts +9 -0
  88. package/dist/identity/identifiers.d.ts.map +1 -0
  89. package/dist/identity/identifiers.js +37 -0
  90. package/dist/identity/identifiers.js.map +1 -0
  91. package/dist/identity/neo.d.ts +11 -0
  92. package/dist/identity/neo.d.ts.map +1 -0
  93. package/dist/identity/neo.js +77 -0
  94. package/dist/identity/neo.js.map +1 -0
  95. package/dist/index.d.ts +53 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +46 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/interactions.d.ts +31 -0
  100. package/dist/interactions.d.ts.map +1 -0
  101. package/dist/interactions.js +2 -0
  102. package/dist/interactions.js.map +1 -0
  103. package/dist/messages/account-sender.d.ts +9 -0
  104. package/dist/messages/account-sender.d.ts.map +1 -0
  105. package/dist/messages/account-sender.js +46 -0
  106. package/dist/messages/account-sender.js.map +1 -0
  107. package/dist/messages/contacts.d.ts +103 -0
  108. package/dist/messages/contacts.d.ts.map +1 -0
  109. package/dist/messages/contacts.js +525 -0
  110. package/dist/messages/contacts.js.map +1 -0
  111. package/dist/messages/outbox.d.ts +66 -0
  112. package/dist/messages/outbox.d.ts.map +1 -0
  113. package/dist/messages/outbox.js +167 -0
  114. package/dist/messages/outbox.js.map +1 -0
  115. package/dist/messages/receiver.d.ts +35 -0
  116. package/dist/messages/receiver.d.ts.map +1 -0
  117. package/dist/messages/receiver.js +113 -0
  118. package/dist/messages/receiver.js.map +1 -0
  119. package/dist/messages/repository.d.ts +89 -0
  120. package/dist/messages/repository.d.ts.map +1 -0
  121. package/dist/messages/repository.js +295 -0
  122. package/dist/messages/repository.js.map +1 -0
  123. package/dist/models/authentication.d.ts +37 -0
  124. package/dist/models/authentication.d.ts.map +1 -0
  125. package/dist/models/authentication.js +53 -0
  126. package/dist/models/authentication.js.map +1 -0
  127. package/dist/models/channels.d.ts +141 -0
  128. package/dist/models/channels.d.ts.map +1 -0
  129. package/dist/models/channels.js +258 -0
  130. package/dist/models/channels.js.map +1 -0
  131. package/dist/models/contacts.d.ts +68 -0
  132. package/dist/models/contacts.d.ts.map +1 -0
  133. package/dist/models/contacts.js +186 -0
  134. package/dist/models/contacts.js.map +1 -0
  135. package/dist/models/content.d.ts +37 -0
  136. package/dist/models/content.d.ts.map +1 -0
  137. package/dist/models/content.js +137 -0
  138. package/dist/models/content.js.map +1 -0
  139. package/dist/models/device-state.d.ts +8 -0
  140. package/dist/models/device-state.d.ts.map +1 -0
  141. package/dist/models/device-state.js +22 -0
  142. package/dist/models/device-state.js.map +1 -0
  143. package/dist/models/group-admission.d.ts +111 -0
  144. package/dist/models/group-admission.d.ts.map +1 -0
  145. package/dist/models/group-admission.js +153 -0
  146. package/dist/models/group-admission.js.map +1 -0
  147. package/dist/models/group-keys.d.ts +30 -0
  148. package/dist/models/group-keys.d.ts.map +1 -0
  149. package/dist/models/group-keys.js +52 -0
  150. package/dist/models/group-keys.js.map +1 -0
  151. package/dist/models/group-management.d.ts +190 -0
  152. package/dist/models/group-management.d.ts.map +1 -0
  153. package/dist/models/group-management.js +263 -0
  154. package/dist/models/group-management.js.map +1 -0
  155. package/dist/models/groups.d.ts +81 -0
  156. package/dist/models/groups.d.ts.map +1 -0
  157. package/dist/models/groups.js +127 -0
  158. package/dist/models/groups.js.map +1 -0
  159. package/dist/models/identity.d.ts +47 -0
  160. package/dist/models/identity.d.ts.map +1 -0
  161. package/dist/models/identity.js +136 -0
  162. package/dist/models/identity.js.map +1 -0
  163. package/dist/models/messages.d.ts +104 -0
  164. package/dist/models/messages.d.ts.map +1 -0
  165. package/dist/models/messages.js +168 -0
  166. package/dist/models/messages.js.map +1 -0
  167. package/dist/models/profile.d.ts +24 -0
  168. package/dist/models/profile.d.ts.map +1 -0
  169. package/dist/models/profile.js +47 -0
  170. package/dist/models/profile.js.map +1 -0
  171. package/dist/models/relay.d.ts +39 -0
  172. package/dist/models/relay.d.ts.map +1 -0
  173. package/dist/models/relay.js +160 -0
  174. package/dist/models/relay.js.map +1 -0
  175. package/dist/protocol/codec.d.ts +36 -0
  176. package/dist/protocol/codec.d.ts.map +1 -0
  177. package/dist/protocol/codec.js +144 -0
  178. package/dist/protocol/codec.js.map +1 -0
  179. package/dist/protocol/context.d.ts +14 -0
  180. package/dist/protocol/context.d.ts.map +1 -0
  181. package/dist/protocol/context.js +43 -0
  182. package/dist/protocol/context.js.map +1 -0
  183. package/dist/protocol/encoding.d.ts +15 -0
  184. package/dist/protocol/encoding.d.ts.map +1 -0
  185. package/dist/protocol/encoding.js +133 -0
  186. package/dist/protocol/encoding.js.map +1 -0
  187. package/dist/protocol/json.d.ts +16 -0
  188. package/dist/protocol/json.d.ts.map +1 -0
  189. package/dist/protocol/json.js +172 -0
  190. package/dist/protocol/json.js.map +1 -0
  191. package/dist/protocol/validation.d.ts +4 -0
  192. package/dist/protocol/validation.d.ts.map +1 -0
  193. package/dist/protocol/validation.js +18 -0
  194. package/dist/protocol/validation.js.map +1 -0
  195. package/dist/runtime/abort.d.ts +6 -0
  196. package/dist/runtime/abort.d.ts.map +1 -0
  197. package/dist/runtime/abort.js +32 -0
  198. package/dist/runtime/abort.js.map +1 -0
  199. package/dist/runtime/async-gate.d.ts +6 -0
  200. package/dist/runtime/async-gate.d.ts.map +1 -0
  201. package/dist/runtime/async-gate.js +15 -0
  202. package/dist/runtime/async-gate.js.map +1 -0
  203. package/dist/runtime/async-pulse.d.ts +7 -0
  204. package/dist/runtime/async-pulse.d.ts.map +1 -0
  205. package/dist/runtime/async-pulse.js +23 -0
  206. package/dist/runtime/async-pulse.js.map +1 -0
  207. package/dist/runtime/clock.d.ts +16 -0
  208. package/dist/runtime/clock.d.ts.map +1 -0
  209. package/dist/runtime/clock.js +62 -0
  210. package/dist/runtime/clock.js.map +1 -0
  211. package/dist/runtime/events.d.ts +11 -0
  212. package/dist/runtime/events.d.ts.map +1 -0
  213. package/dist/runtime/events.js +45 -0
  214. package/dist/runtime/events.js.map +1 -0
  215. package/dist/runtime/url.d.ts +3 -0
  216. package/dist/runtime/url.d.ts.map +1 -0
  217. package/dist/runtime/url.js +23 -0
  218. package/dist/runtime/url.js.map +1 -0
  219. package/dist/storage/signed-request.d.ts +14 -0
  220. package/dist/storage/signed-request.d.ts.map +1 -0
  221. package/dist/storage/signed-request.js +26 -0
  222. package/dist/storage/signed-request.js.map +1 -0
  223. package/dist/storage/store.d.ts +58 -0
  224. package/dist/storage/store.d.ts.map +1 -0
  225. package/dist/storage/store.js +52 -0
  226. package/dist/storage/store.js.map +1 -0
  227. package/dist/storage/transaction.d.ts +7 -0
  228. package/dist/storage/transaction.d.ts.map +1 -0
  229. package/dist/storage/transaction.js +22 -0
  230. package/dist/storage/transaction.js.map +1 -0
  231. package/dist/transport/client.d.ts +61 -0
  232. package/dist/transport/client.d.ts.map +1 -0
  233. package/dist/transport/client.js +392 -0
  234. package/dist/transport/client.js.map +1 -0
  235. package/dist/transport/discovery.d.ts +19 -0
  236. package/dist/transport/discovery.d.ts.map +1 -0
  237. package/dist/transport/discovery.js +75 -0
  238. package/dist/transport/discovery.js.map +1 -0
  239. package/dist/transport/endpoint.d.ts +6 -0
  240. package/dist/transport/endpoint.d.ts.map +1 -0
  241. package/dist/transport/endpoint.js +35 -0
  242. package/dist/transport/endpoint.js.map +1 -0
  243. package/dist/transport/http.d.ts +52 -0
  244. package/dist/transport/http.d.ts.map +1 -0
  245. package/dist/transport/http.js +128 -0
  246. package/dist/transport/http.js.map +1 -0
  247. package/dist/transport/pool.d.ts +31 -0
  248. package/dist/transport/pool.d.ts.map +1 -0
  249. package/dist/transport/pool.js +137 -0
  250. package/dist/transport/pool.js.map +1 -0
  251. package/dist/transport/relay-error.d.ts +23 -0
  252. package/dist/transport/relay-error.d.ts.map +1 -0
  253. package/dist/transport/relay-error.js +47 -0
  254. package/dist/transport/relay-error.js.map +1 -0
  255. package/dist/transport/rpc.d.ts +31 -0
  256. package/dist/transport/rpc.d.ts.map +1 -0
  257. package/dist/transport/rpc.js +56 -0
  258. package/dist/transport/rpc.js.map +1 -0
  259. package/dist/transport/session.d.ts +49 -0
  260. package/dist/transport/session.d.ts.map +1 -0
  261. package/dist/transport/session.js +169 -0
  262. package/dist/transport/session.js.map +1 -0
  263. package/dist/transport/socket.d.ts +55 -0
  264. package/dist/transport/socket.d.ts.map +1 -0
  265. package/dist/transport/socket.js +266 -0
  266. package/dist/transport/socket.js.map +1 -0
  267. package/dist/transport/subscription.d.ts +36 -0
  268. package/dist/transport/subscription.d.ts.map +1 -0
  269. package/dist/transport/subscription.js +169 -0
  270. package/dist/transport/subscription.js.map +1 -0
  271. package/package.json +35 -4
  272. package/src/channels/repository.ts +244 -0
  273. package/src/client/account-operations.ts +183 -0
  274. package/src/client/conversations.ts +95 -0
  275. package/src/client.ts +108 -0
  276. package/src/components/account.ts +206 -0
  277. package/src/components/channel.ts +253 -0
  278. package/src/components/component.ts +151 -0
  279. package/src/components/device.ts +455 -0
  280. package/src/components/group.ts +546 -0
  281. package/src/components/message.ts +253 -0
  282. package/src/components/profile.ts +179 -0
  283. package/src/crypto/groups.ts +107 -0
  284. package/src/crypto/messages.ts +129 -0
  285. package/src/crypto/primitives.ts +103 -0
  286. package/src/errors.ts +13 -0
  287. package/src/groups/account-sync.ts +144 -0
  288. package/src/groups/messages.ts +89 -0
  289. package/src/groups/operations.ts +145 -0
  290. package/src/groups/repository.ts +161 -0
  291. package/src/groups/secrets.ts +256 -0
  292. package/src/groups/state.ts +138 -0
  293. package/src/identity/identifiers.ts +41 -0
  294. package/src/identity/neo.ts +72 -0
  295. package/src/index.ts +52 -0
  296. package/src/interactions.ts +35 -0
  297. package/src/messages/account-sender.ts +33 -0
  298. package/src/messages/contacts.ts +379 -0
  299. package/src/messages/outbox.ts +152 -0
  300. package/src/messages/receiver.ts +86 -0
  301. package/src/messages/repository.ts +230 -0
  302. package/src/models/authentication.ts +63 -0
  303. package/src/models/channels.ts +155 -0
  304. package/src/models/contacts.ts +141 -0
  305. package/src/models/content.ts +105 -0
  306. package/src/models/device-state.ts +18 -0
  307. package/src/models/group-admission.ts +110 -0
  308. package/src/models/group-keys.ts +34 -0
  309. package/src/models/group-management.ts +182 -0
  310. package/src/models/groups.ts +100 -0
  311. package/src/models/identity.ts +164 -0
  312. package/src/models/messages.ts +139 -0
  313. package/src/models/profile.ts +46 -0
  314. package/src/models/relay.ts +151 -0
  315. package/src/protocol/codec.ts +144 -0
  316. package/src/protocol/context.ts +39 -0
  317. package/src/protocol/encoding.ts +106 -0
  318. package/src/protocol/json.ts +140 -0
  319. package/src/protocol/validation.ts +13 -0
  320. package/src/runtime/abort.ts +27 -0
  321. package/src/runtime/async-gate.ts +16 -0
  322. package/src/runtime/async-pulse.ts +20 -0
  323. package/src/runtime/clock.ts +63 -0
  324. package/src/runtime/events.ts +31 -0
  325. package/src/runtime/url.ts +17 -0
  326. package/src/storage/signed-request.ts +26 -0
  327. package/src/storage/store.ts +90 -0
  328. package/src/storage/transaction.ts +17 -0
  329. package/src/transport/client.ts +328 -0
  330. package/src/transport/discovery.ts +64 -0
  331. package/src/transport/endpoint.ts +32 -0
  332. package/src/transport/http.ts +129 -0
  333. package/src/transport/pool.ts +115 -0
  334. package/src/transport/relay-error.ts +46 -0
  335. package/src/transport/rpc.ts +56 -0
  336. package/src/transport/session.ts +154 -0
  337. package/src/transport/socket.ts +248 -0
  338. package/src/transport/subscription.ts +117 -0
@@ -0,0 +1,145 @@
1
+ import { ProtocolError, StateConflictError } from '../errors.js';
2
+ import { validateAccountId } from '../identity/neo.js';
3
+ import { groupApplicationCodec, groupCreateRequestCodec, groupEventCodec, groupInviteCodec, groupManagementPayloadCodec, groupMemberRecoveryRequestCodec, groupStateCodec, validateGroupApplication, validateGroupCreateRequest, validateGroupInvite, validateGroupManagementPayload, validateGroupMemberRecoveryRequest, validateGroupState, type GroupState } from '../models/group-management.js';
4
+ import { groupAccountsRequestCodec, groupApplicationApproveRequestCodec, groupInviteQueryCodec, groupRecoveryApproveRequestCodec, groupRecoverySubmitResultCodec, validateGroupAccountsRequest, validateGroupApplicationApproveRequest, validateGroupInviteQuery, validateGroupRecoveryApproveRequest, validateGroupRecoverySubmitResult } from '../models/group-admission.js';
5
+ import { groupMessageEnvelopeCodec, validateGroupMessageEnvelope, validateGroupRef, type GroupRef } from '../models/groups.js';
6
+ import type { NetworkContext } from '../protocol/context.js';
7
+ import { equalBytes } from '../protocol/encoding.js';
8
+ import { canonicalJson, requireObject, requireSafeInteger, type JsonObject } from '../protocol/json.js';
9
+ import type { MeshlineStore, RecordKey, StoreMutation } from '../storage/store.js';
10
+ import { updateStore } from '../storage/transaction.js';
11
+ import type { HttpMethod } from '../transport/http.js';
12
+ import { decodeGroupProjection, groupAcceptanceKey, groupEventKey, groupKey } from './repository.js';
13
+ import { groupMemberSecretKey, groupRotationKey, type PreparedGroupMemberKey } from './secrets.js';
14
+
15
+ export const groupOperationMethods = {
16
+ 'group.create': 'POST', 'group.update': 'PATCH', 'group.close': 'DELETE', 'group.message.send': 'POST',
17
+ 'group.member.leave': 'DELETE', 'group.member.remove': 'DELETE', 'group.member.ban': 'PUT', 'group.member.unban': 'DELETE',
18
+ 'group.role.update': 'PUT', 'group.owner.transfer': 'POST',
19
+ 'group.invite.create': 'POST', 'group.invite.revoke': 'DELETE',
20
+ 'group.application.submit': 'POST', 'group.application.approve': 'POST', 'group.application.reject': 'DELETE',
21
+ 'group.member.recovery.submit': 'POST', 'group.member.recovery.approve': 'POST', 'group.member.recovery.reject': 'DELETE',
22
+ 'group.secret.rotation.commit': 'POST',
23
+ } as const satisfies Record<string, HttpMethod>;
24
+ export type GroupOperationMethod = keyof typeof groupOperationMethods;
25
+ export interface GroupOperation { readonly group: GroupRef; readonly method: GroupOperationMethod; readonly request: JsonObject; readonly accepted?: true; readonly acceptedSequence?: number; readonly acceptedResult?: JsonObject }
26
+ const key = (operation: Pick<GroupOperation, 'group' | 'method'>): RecordKey => ({ collection: 'group_operations', key: `${operation.group.groupId}|${operation.method}` });
27
+ const put = (record: RecordKey, value: JsonObject): StoreMutation => ({ ...record, kind: 'put', value });
28
+ export function groupOperationPayload(operation: GroupOperation): JsonObject | undefined {
29
+ validateGroupRef(operation.group); if (!Object.hasOwn(groupOperationMethods, operation.method)) throw new ProtocolError('invalid_operation', 'Unknown stored group operation.');
30
+ let payload: JsonObject | undefined;
31
+ if (operation.method === 'group.create') { const request = groupCreateRequestCodec.decode(operation.request); validateGroupCreateRequest(request); payload = requireObject(operation.request.create!); }
32
+ else if (operation.method === 'group.invite.create') { const request = groupInviteCodec.decode(operation.request); validateGroupInvite(request, request.createdAt); }
33
+ else if (operation.method === 'group.invite.revoke') validateGroupInviteQuery(groupInviteQueryCodec.decode(operation.request));
34
+ else if (operation.method === 'group.application.submit') validateGroupApplication(groupApplicationCodec.decode(operation.request));
35
+ else if (operation.method === 'group.member.recovery.submit') validateGroupMemberRecoveryRequest(groupMemberRecoveryRequestCodec.decode(operation.request));
36
+ else if (operation.method === 'group.application.reject' || operation.method === 'group.member.recovery.reject') validateGroupAccountsRequest(groupAccountsRequestCodec.decode(operation.request));
37
+ else if (operation.method === 'group.application.approve') { validateGroupApplicationApproveRequest(groupApplicationApproveRequestCodec.decode(operation.request)); payload = requireObject(operation.request.approval!); }
38
+ else if (operation.method === 'group.member.recovery.approve') { validateGroupRecoveryApproveRequest(groupRecoveryApproveRequestCodec.decode(operation.request)); payload = requireObject(operation.request.approval!); }
39
+ else if (operation.method === 'group.message.send') { validateGroupMessageEnvelope(groupMessageEnvelopeCodec.decode(operation.request)); payload = operation.request; }
40
+ else {
41
+ const value = groupManagementPayloadCodec.decode(operation.request); validateGroupManagementPayload(value);
42
+ const expected: Partial<Record<GroupOperationMethod, typeof value.kind>> = { 'group.update': 'update', 'group.close': 'close', 'group.member.leave': 'memberLeave', 'group.member.remove': 'memberRemoval', 'group.member.ban': 'memberBan', 'group.member.unban': 'memberUnban', 'group.role.update': 'roleUpdate', 'group.owner.transfer': 'ownerTransfer', 'group.secret.rotation.commit': 'secretRotation' };
43
+ if (value.kind !== expected[operation.method]) throw new ProtocolError('invalid_operation', 'Stored operation method and signed payload differ.'); payload = operation.request;
44
+ }
45
+ if ((payload ?? operation.request).group_id !== operation.group.groupId) throw new ProtocolError('invalid_binding', 'Stored operation belongs to another group.');
46
+ if (operation.acceptedSequence !== undefined) { requireSafeInteger(operation.acceptedSequence, 1); if (operation.method !== 'group.message.send' || !operation.accepted) throw new ProtocolError('invalid_operation', 'Unexpected group operation sequence acknowledgement.'); }
47
+ if (operation.acceptedResult !== undefined) {
48
+ if (operation.method !== 'group.member.recovery.submit' || !operation.accepted) throw new ProtocolError('invalid_operation', 'Unexpected group operation acceptance result.');
49
+ validateGroupRecoverySubmitResult(groupRecoverySubmitResultCodec.decode(operation.acceptedResult));
50
+ }
51
+ return payload;
52
+ }
53
+ function encode(operation: GroupOperation): JsonObject { return { groupId: operation.group.groupId, relayId: operation.group.relayId, method: operation.method, request: operation.request, ...(operation.accepted ? { accepted: true } : {}), ...(operation.acceptedSequence === undefined ? {} : { acceptedSequence: operation.acceptedSequence }), ...(operation.acceptedResult === undefined ? {} : { acceptedResult: operation.acceptedResult }) }; }
54
+ function decode(value: JsonObject): GroupOperation {
55
+ if (typeof value.groupId !== 'string' || typeof value.relayId !== 'string' || typeof value.method !== 'string' || value.accepted !== undefined && value.accepted !== true) throw new ProtocolError('invalid_storage', 'Malformed stored group operation.');
56
+ const operation: GroupOperation = { group: { groupId: value.groupId, relayId: value.relayId }, method: value.method as GroupOperationMethod, request: requireObject(value.request!), ...(value.accepted ? { accepted: true } : {}), ...(value.acceptedSequence === undefined ? {} : { acceptedSequence: value.acceptedSequence as number }), ...(value.acceptedResult === undefined ? {} : { acceptedResult: requireObject(value.acceptedResult) }) };
57
+ groupOperationPayload(operation); return operation;
58
+ }
59
+ const same = (a: GroupOperation, b: GroupOperation): boolean => a.group.relayId === b.group.relayId && canonicalJson(a.request) === canonicalJson(b.request);
60
+
61
+ /** Exact signed requests survive uncertain responses; positive receipts are durable before any follow-up I/O. */
62
+ export class GroupOperations {
63
+ constructor(readonly store: MeshlineStore, readonly context: NetworkContext, readonly accountId: string) { validateAccountId(accountId); }
64
+ async all(signal?: AbortSignal): Promise<readonly GroupOperation[]> { return (await this.store.read([{ collection: 'group_operations' }], signal)).sets[0]!.map(row => { const value = decode(row.value); if (key(value).key !== row.key) throw new ProtocolError('invalid_storage', 'Group operation key differs from its record.'); return value; }); }
65
+ async ensureAvailable(group: GroupRef, method: GroupOperationMethod, signal?: AbortSignal): Promise<void> {
66
+ if ((await this.store.read([key({ group, method })], signal)).sets[0]!.length) throw new StateConflictError('An earlier group operation is awaiting confirmation; start the manager to recover it.');
67
+ }
68
+ async save(operation: GroupOperation, member: PreparedGroupMemberKey | undefined, signal?: AbortSignal, preview?: GroupState): Promise<void> {
69
+ operation = decode(encode(operation)); const queries = [key(operation), groupKey(operation.group)];
70
+ const previewWire = preview === undefined ? undefined : groupStateCodec.encode(preview);
71
+ if (preview) { validateGroupState(preview); if (preview.groupId !== operation.group.groupId) throw new ProtocolError('invalid_binding', 'Group preview belongs to another group.'); }
72
+ if (member) { if (member.group.groupId !== operation.group.groupId || member.group.relayId !== operation.group.relayId) throw new ProtocolError('invalid_binding', 'Prepared member key belongs to another group.'); queries.push(groupMemberSecretKey(member.group.groupId, member.publicKey)); }
73
+ await updateStore(this.store, queries, snapshot => {
74
+ if (snapshot.sets[0]!.length) throw new StateConflictError('An earlier group operation is awaiting confirmation.');
75
+ const group = snapshot.sets[1]![0]?.value;
76
+ if (group && group.relayId !== operation.group.relayId) throw new ProtocolError('invalid_binding', 'Stored group belongs to another hosting relay.');
77
+ const mutations: StoreMutation[] = [put(key(operation), encode(operation))];
78
+ if (!group || previewWire && !group.projection) mutations.push(put(groupKey(operation.group), { ...group, relayId: operation.group.relayId, ...(previewWire ? { preview: previewWire } : {}) }));
79
+ if (member) {
80
+ if (snapshot.sets[2]!.length) throw new StateConflictError('Prepared member key already exists.');
81
+ mutations.push(put(queries[2]!, { publicKey: queries[2]!.key.split('|')[1]!, protectedKey: member.protectedKey, shared: false }));
82
+ }
83
+ return { mutations, result: undefined };
84
+ }, signal);
85
+ }
86
+ async acknowledge(operation: GroupOperation, acceptedSequence?: number, acceptedResult?: JsonObject): Promise<GroupOperation> {
87
+ if (acceptedSequence !== undefined) requireSafeInteger(acceptedSequence, 1);
88
+ return updateStore(this.store, [key(operation)], snapshot => {
89
+ const row = snapshot.sets[0]![0]; if (!row) throw new StateConflictError('Group operation disappeared before its acceptance was recorded.'); const current = decode(row.value);
90
+ if (!same(current, operation) || current.acceptedSequence !== undefined && current.acceptedSequence !== acceptedSequence || current.acceptedResult !== undefined && canonicalJson(current.acceptedResult) !== canonicalJson(acceptedResult ?? null)) throw new StateConflictError('Group operation acknowledgement conflicts with the pending request.');
91
+ const result: GroupOperation = { ...current, accepted: true, ...(acceptedSequence === undefined ? {} : { acceptedSequence }), ...(acceptedResult === undefined ? {} : { acceptedResult }) }; groupOperationPayload(result);
92
+ return { mutations: [put(key(operation), encode(result))], result };
93
+ });
94
+ }
95
+ async acceptedEvent(operation: GroupOperation, signal?: AbortSignal): Promise<{ sequence: number; record: JsonObject } | undefined> {
96
+ const payload = groupOperationPayload(operation); if (!payload) return undefined;
97
+ const row = (await this.store.read([groupAcceptanceKey(operation.group, payload, this.context)], signal)).sets[0]![0]; if (!row) return undefined;
98
+ requireSafeInteger(row.value.sequence, 0); const sequence = row.value.sequence;
99
+ const record = (await this.store.read([groupEventKey(operation.group, sequence)], signal)).sets[0]![0]?.value;
100
+ if (!record || canonicalJson(groupEventCodec.decode(record.event!).payload) !== canonicalJson(payload)) throw new ProtocolError('invalid_storage', 'Group acceptance evidence differs from its indexed signed payload.');
101
+ if (operation.acceptedSequence !== undefined && operation.acceptedSequence !== sequence) throw new ProtocolError('conflicting_acknowledgement', 'Relay acknowledgement and verified group message sequence differ.');
102
+ return { sequence, record };
103
+ }
104
+ /** Approval can outlive a lost or malformed submission acknowledgement. */
105
+ async acceptedMemberRequest(operation: GroupOperation, signal?: AbortSignal): Promise<boolean> {
106
+ const recovery = operation.method === 'group.member.recovery.submit';
107
+ if (!recovery && operation.method !== 'group.application.submit') return false;
108
+ const request = recovery ? groupMemberRecoveryRequestCodec.decode(operation.request) : groupApplicationCodec.decode(operation.request);
109
+ if (request.account !== this.accountId) throw new ProtocolError('invalid_binding', 'Pending member request belongs to another account.');
110
+ const snapshot = await this.store.read([groupKey(operation.group), { collection: 'group_events', prefix: `${operation.group.groupId}|` }], signal);
111
+ const group = snapshot.sets[0]![0]?.value;
112
+ if (!group?.projection) return false;
113
+ if (group.relayId !== operation.group.relayId) throw new ProtocolError('invalid_binding', 'Pending member request belongs to another hosting relay.');
114
+ const projection = decodeGroupProjection(requireObject(group.projection), operation.group);
115
+ for (const row of snapshot.sets[1]!) {
116
+ if (row.value.rejection !== undefined) continue;
117
+ const event = groupEventCodec.decode(row.value.event!);
118
+ if (event.payload.$type !== (recovery ? 'meshline.group.member.recovery.approval' : 'meshline.group.application.approval')) continue;
119
+ const approval = groupManagementPayloadCodec.decode(event.payload);
120
+ if (approval.kind !== 'applicationApproval' && approval.kind !== 'memberRecoveryApproval') continue;
121
+ if (approval.value.groupId !== operation.group.groupId
122
+ || !approval.value.members.some(member => member.account === request.account && equalBytes(member.memberEncryptionPublicKey, request.memberEncryptionPublicKey))) continue;
123
+ const index = (await this.store.read([groupAcceptanceKey(operation.group, event.payload, this.context)], signal)).sets[0]![0];
124
+ if (!event.signerDeviceId || event.sequence > projection.sequence || row.key !== groupEventKey(operation.group, event.sequence).key || index?.value.sequence !== event.sequence) throw new ProtocolError('invalid_storage', 'Member request approval is not indexed in verified group history.');
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+ /** The caller supplies verified evidence or a positive acknowledgement. Rejections never erase an earlier uncertain operation. */
130
+ async complete(operation: GroupOperation, localEffect = false): Promise<JsonObject | undefined> {
131
+ return updateStore(this.store, [key(operation), groupKey(operation.group), groupRotationKey(operation.group)], snapshot => {
132
+ const row = snapshot.sets[0]![0]; if (!row) return { mutations: [], result: undefined };
133
+ if (!same(decode(row.value), operation)) throw new StateConflictError('Pending group request changed before completion.');
134
+ const mutations: StoreMutation[] = [{ kind: 'delete', ...key(operation) }]; const group = snapshot.sets[1]![0]?.value;
135
+ if (localEffect && group) {
136
+ if (operation.method === 'group.close') mutations.push(put(groupKey(operation.group), { ...group, locallyClosed: true }));
137
+ if (operation.method === 'group.member.leave') mutations.push(put(groupKey(operation.group), { ...group, localDepartureAfter: requireObject(group.projection!).sequence! }));
138
+ if (operation.method === 'group.application.submit' && (!group.projection || !decodeGroupProjection(requireObject(group.projection), operation.group).members.some(member => member.account === this.accountId))) mutations.push(put(groupKey(operation.group), { ...group, pendingApplication: true }));
139
+ if (operation.method === 'group.secret.rotation.commit' && snapshot.sets[2]![0]?.value.commitment === operation.request.client_secret_commitment) mutations.push({ kind: 'delete', ...groupRotationKey(operation.group) });
140
+ }
141
+ const change = mutations.find(mutation => mutation.collection === 'groups' && mutation.kind === 'put');
142
+ return { mutations, result: change?.kind === 'put' ? change.value : undefined };
143
+ });
144
+ }
145
+ }
@@ -0,0 +1,161 @@
1
+ import { ProtocolError, StateConflictError } from '../errors.js';
2
+ import { groupManagementHash } from '../crypto/groups.js';
3
+ import { validateAccountId } from '../identity/neo.js';
4
+ import { contentHashBytes } from '../models/content.js';
5
+ import { groupEventCodec, groupMemberCodec, groupStateCodec, groupSyncPageCodec, validateGroupState, validateGroupSyncPage, type GroupEvent, type GroupSequenceQuery, type GroupSyncPage } from '../models/group-management.js';
6
+ import { groupKeyEntryCodec, groupKeyPageCodec, validateGroupKeyPage, type GroupKeyEntry, type GroupKeyPage } from '../models/group-keys.js';
7
+ import { validateGroupRef, type GroupRef } from '../models/groups.js';
8
+ import { certificateId, deviceCertificateCodec } from '../models/identity.js';
9
+ import type { NetworkContext } from '../protocol/context.js';
10
+ import { decodeBase64Url, encodeBase64Url, equalBytes } from '../protocol/encoding.js';
11
+ import { canonicalJson, requireObject, requireSafeInteger, type JsonObject } from '../protocol/json.js';
12
+ import { requireLength } from '../crypto/primitives.js';
13
+ import type { MeshlineStore, RecordKey, StoreMutation, StoredRecord } from '../storage/store.js';
14
+ import { updateStore } from '../storage/transaction.js';
15
+ import { applyGroupEvent, groupChangeKinds, type GroupChangeKind, type GroupDeparture, type GroupEventResult, type GroupProjectedMember, type GroupProjection } from './state.js';
16
+
17
+ export interface GroupEpochRecord { readonly group: GroupRef; readonly epoch: number; readonly commitment: string; readonly memberPublicKey?: Uint8Array; readonly keyEntry?: GroupKeyEntry }
18
+ export interface GroupPageResult { readonly projection?: GroupProjection; readonly hasMore: boolean; readonly rejected: readonly { readonly sequence: number; readonly error: ProtocolError }[]; readonly messageSequences: readonly number[];
19
+ readonly change?: { readonly record: JsonObject; readonly kinds: readonly GroupChangeKind[] } }
20
+ export interface GroupTimelineSource { read(query: GroupSequenceQuery, signal?: AbortSignal): Promise<GroupSyncPage> }
21
+ const index = (value: number): string => { requireSafeInteger(value, 0); return String(value).padStart(16, '0'); };
22
+ export const groupKey = (group: GroupRef): RecordKey => ({ collection: 'groups', key: group.groupId });
23
+ export const groupEventKey = (group: GroupRef, sequence: number): RecordKey => ({ collection: 'group_events', key: `${group.groupId}|${index(sequence)}` });
24
+ export const groupEpochKey = (group: GroupRef, epoch: number): RecordKey => ({ collection: 'group_epochs', key: `${group.groupId}|${index(epoch)}` });
25
+ export const groupPendingMessageKey = (group: GroupRef, sequence: number): RecordKey => ({ collection: 'group_pending_messages', key: `${group.groupId}|${index(sequence)}` });
26
+ export const groupAcceptanceKey = (group: GroupRef, payload: JsonObject, context: NetworkContext): RecordKey => ({ collection: 'group_acceptances', key: `${group.groupId}|${groupManagementHash(payload, context)}` });
27
+ const messageKey = (group: GroupRef, sender: string, messageId: string): RecordKey => ({ collection: 'group_message_ids', key: `${group.groupId}|${sender}|${messageId}` });
28
+ const put = (key: RecordKey, value: JsonObject): StoreMutation => ({ ...key, kind: 'put', value });
29
+ const fingerprint = (value: JsonObject | undefined): string => canonicalJson(value ?? null);
30
+ export function encodeGroupProjection(value: GroupProjection): JsonObject {
31
+ return { sequence: value.sequence, epoch: value.epoch, managementHash: value.managementHash, clientSecretCommitment: value.clientSecretCommitment, state: groupStateCodec.encode(value.state),
32
+ members: value.members.map(member => ({ ...groupMemberCodec.encode({ account: member.account, role: member.role, memberEncryptionPublicKey: member.memberEncryptionPublicKey }), joinedAtSequence: member.joinedAtSequence,
33
+ ...(member.nickname === undefined ? {} : { nickname: member.nickname }), ...(member.nicknameSequence === undefined ? {} : { nicknameSequence: member.nicknameSequence }) })), bans: [...value.bans], departures: { ...value.departures } };
34
+ }
35
+ /** Persisted projections are validated as local state, never accepted from a relay preview. */
36
+ export function decodeGroupProjection(value: JsonObject, group: GroupRef): GroupProjection {
37
+ validateGroupRef(group); requireSafeInteger(value.sequence, 0); requireSafeInteger(value.epoch, 0); const sequence = value.sequence; const state = groupStateCodec.decode(value.state!); validateGroupState(state);
38
+ if (state.groupId !== group.groupId || typeof value.managementHash !== 'string' || typeof value.clientSecretCommitment !== 'string' || !Array.isArray(value.members) || !Array.isArray(value.bans)) throw new ProtocolError('invalid_storage', 'Malformed verified group state.');
39
+ contentHashBytes(value.managementHash); contentHashBytes(value.clientSecretCommitment); const seen = new Set<string>();
40
+ const members = value.members.map(item => {
41
+ const wire = { ...requireObject(item) }; const joinedAtSequence = wire.joinedAtSequence; const nickname = wire.nickname; const nicknameSequence = wire.nicknameSequence;
42
+ delete wire.joinedAtSequence; delete wire.nickname; delete wire.nicknameSequence;
43
+ const member = groupMemberCodec.decode(wire); validateAccountId(member.account); requireLength(member.memberEncryptionPublicKey, 32, 'Stored member public key'); requireSafeInteger(joinedAtSequence, 0);
44
+ if (joinedAtSequence > sequence || seen.has(member.account)) throw new ProtocolError('invalid_storage', 'Stored group member has an invalid join sequence or duplicate account.'); seen.add(member.account);
45
+ if (nickname !== undefined && typeof nickname !== 'string') throw new ProtocolError('invalid_storage', 'Stored nickname must be a string.');
46
+ if (nicknameSequence !== undefined) { requireSafeInteger(nicknameSequence, joinedAtSequence); if (nicknameSequence > sequence) throw new ProtocolError('invalid_storage', 'Stored nickname sequence exceeds group position.'); }
47
+ return { ...member, joinedAtSequence, ...(nickname === undefined ? {} : { nickname }), ...(nicknameSequence === undefined ? {} : { nicknameSequence }) } as GroupProjectedMember;
48
+ });
49
+ const bans = value.bans.map(item => { if (typeof item !== 'string') throw new ProtocolError('invalid_storage', 'Stored ban must name an account.'); validateAccountId(item); return item; });
50
+ if (new Set(bans).size !== bans.length || bans.some(account => seen.has(account)) || members.length !== state.memberCount || members.filter(member => member.role === 'owner').length !== 1 || members.find(member => member.role === 'owner')!.account !== state.owner) throw new ProtocolError('invalid_storage', 'Stored group membership, owner or bans are inconsistent.');
51
+ const departures: Record<string, GroupDeparture> = Object.create(null) as Record<string, GroupDeparture>;
52
+ for (const [account, departure] of Object.entries(requireObject(value.departures!))) {
53
+ validateAccountId(account); if (typeof departure !== 'string' || !['left', 'removed', 'banned', 'not_member'].includes(departure) || seen.has(account)) throw new ProtocolError('invalid_storage', 'Invalid stored departure.'); departures[account] = departure as GroupDeparture;
54
+ }
55
+ return { ...group, sequence: value.sequence, epoch: value.epoch, managementHash: value.managementHash, clientSecretCommitment: value.clientSecretCommitment, state, members, bans, departures };
56
+ }
57
+ function recordProjection(value: JsonObject | undefined, group: GroupRef): GroupProjection | undefined {
58
+ if (!value) return undefined; if (value.relayId !== group.relayId) throw new ProtocolError('invalid_binding', 'Stored group belongs to another hosting relay.');
59
+ return value.projection === undefined ? undefined : decodeGroupProjection(requireObject(value.projection), group);
60
+ }
61
+ function decodeEpoch(row: StoredRecord, group: GroupRef): GroupEpochRecord {
62
+ const value = row.value; requireSafeInteger(value.epoch, 0);
63
+ if (row.key !== groupEpochKey(group, value.epoch).key || typeof value.commitment !== 'string') throw new ProtocolError('invalid_storage', 'Stored group epoch has an invalid identity or commitment.'); contentHashBytes(value.commitment);
64
+ const memberPublicKey = value.memberPublicKey === undefined ? undefined : decodeBase64Url(String(value.memberPublicKey)); if (memberPublicKey) requireLength(memberPublicKey, 32, 'Epoch member public key');
65
+ const keyEntry = value.keyEntry === undefined ? undefined : groupKeyEntryCodec.decode(value.keyEntry);
66
+ if (keyEntry && keyEntry.epoch !== value.epoch) throw new ProtocolError('invalid_storage', 'Stored group key entry belongs to another epoch.');
67
+ return { group, epoch: value.epoch, commitment: value.commitment, ...(memberPublicKey ? { memberPublicKey } : {}), ...(keyEntry ? { keyEntry } : {}) };
68
+ }
69
+
70
+ /** Commits verified group state, epoch authority, signed evidence, deduplication and cursor in one CAS batch. */
71
+ export class GroupRepository {
72
+ constructor(readonly store: MeshlineStore, readonly context: NetworkContext, readonly accountId: string) { validateAccountId(accountId); }
73
+ async get(group: GroupRef, signal?: AbortSignal): Promise<GroupProjection | undefined> { validateGroupRef(group); return recordProjection((await this.store.read([groupKey(group)], signal)).sets[0]![0]?.value, group); }
74
+ /** Pre-admission relay previews are presentation data and never replace verified history or establish epoch authority. */
75
+ async savePreview(group: GroupRef, source: ReturnType<typeof groupStateCodec.decode>, signal?: AbortSignal): Promise<void> {
76
+ group = { ...group }; validateGroupRef(group); const state = groupStateCodec.decode(groupStateCodec.encode(source)); validateGroupState(state);
77
+ if (state.groupId !== group.groupId) throw new ProtocolError('invalid_binding', 'Relay preview belongs to another group.');
78
+ await updateStore(this.store, [groupKey(group)], snapshot => {
79
+ const current = snapshot.sets[0]![0]?.value; recordProjection(current, group);
80
+ return { mutations: current?.projection ? [] : [put(groupKey(group), { ...current, relayId: group.relayId, preview: groupStateCodec.encode(state) })], result: undefined };
81
+ }, signal);
82
+ }
83
+ async event(group: GroupRef, sequence: number, signal?: AbortSignal): Promise<JsonObject | undefined> {
84
+ validateGroupRef(group); const snapshot = await this.store.read([groupKey(group), groupEventKey(group, sequence)], signal); recordProjection(snapshot.sets[0]![0]?.value, group); return snapshot.sets[1]![0]?.value;
85
+ }
86
+ async epochs(group: GroupRef, signal?: AbortSignal): Promise<readonly GroupEpochRecord[]> {
87
+ validateGroupRef(group); const snapshot = await this.store.read([groupKey(group), { collection: 'group_epochs', prefix: `${group.groupId}|` }], signal); recordProjection(snapshot.sets[0]![0]?.value, group); return snapshot.sets[1]!.map(row => decodeEpoch(row, group));
88
+ }
89
+ async keyProgress(group: GroupRef, signal?: AbortSignal): Promise<number> { const values = await this.epochs(group, signal); return values.reduce((after, entry) => entry.keyEntry ? Math.max(after, entry.epoch) : after, -1); }
90
+ async synchronizePage(group: GroupRef, source: GroupTimelineSource, signal?: AbortSignal): Promise<GroupPageResult> {
91
+ validateGroupRef(group); const initial = (await this.store.read([groupKey(group)], signal)).sets[0]![0]?.value; const before = recordProjection(initial, group);
92
+ const query = { groupId: group.groupId, after: before?.sequence ?? -1 };
93
+ const page = groupSyncPageCodec.decode(groupSyncPageCodec.encode(await source.read(query, signal))); validateGroupSyncPage(page, query, this.context);
94
+ const certificates = new Map(page.certificates.map(certificate => [certificateId(certificate, this.context), certificate]));
95
+ const verified: { entry: GroupEvent; result: GroupEventResult; epochChanged: boolean }[] = []; let projection = before;
96
+ for (const entry of page.events) {
97
+ const result = applyGroupEvent(projection, entry, entry.signerDeviceId === undefined ? undefined : certificates.get(entry.signerDeviceId), group, this.context);
98
+ verified.push({ entry, result, epochChanged: projection?.epoch !== result.projection.epoch }); projection = result.projection;
99
+ }
100
+ const keys = new Map<string, RecordKey>(); const addKey = (key: RecordKey): void => { keys.set(`${key.collection}|${key.key}`, key); };
101
+ addKey(groupKey(group));
102
+ for (const item of verified) {
103
+ addKey(groupEventKey(group, item.entry.sequence)); if (item.epochChanged) addKey(groupEpochKey(group, item.entry.epoch));
104
+ const message = item.result.message; if (message) addKey(messageKey(group, message.sender.account, message.envelope.messageId));
105
+ }
106
+ const queries = [...keys.values()];
107
+ return updateStore(this.store, queries, snapshot => {
108
+ const rows = new Map(queries.map((key, i) => [`${key.collection}|${key.key}`, snapshot.sets[i]![0]]));
109
+ const row = (key: RecordKey) => rows.get(`${key.collection}|${key.key}`);
110
+ if (fingerprint(row(groupKey(group))?.value) !== fingerprint(initial)) throw new StateConflictError('Group state changed while its timeline was being verified.');
111
+ const mutations: StoreMutation[] = []; const rejected: { sequence: number; error: ProtocolError }[] = []; const messageSequences: number[] = []; const newMessages = new Set<string>();
112
+ for (const { entry, result, epochChanged } of verified) {
113
+ const key = groupEventKey(group, entry.sequence); if (row(key)) throw new ProtocolError('conflicting_event', 'Group event already exists beyond the verified cursor.');
114
+ let rejection = result.rejection; const message = result.message; const identity = message && messageKey(group, message.sender.account, message.envelope.messageId);
115
+ if (identity && (row(identity) || newMessages.has(identity.key))) rejection = new ProtocolError('duplicate_message', 'Relay assigned a second sequence to an existing logical group message.');
116
+ const certificate = entry.signerDeviceId === undefined ? undefined : certificates.get(entry.signerDeviceId)!;
117
+ const value: JsonObject = { event: groupEventCodec.encode(entry), ...(certificate ? { certificate: deviceCertificateCodec.encode(certificate) } : {}) };
118
+ if (rejection) { value.rejection = { code: rejection.code, message: rejection.message }; rejected.push({ sequence: entry.sequence, error: rejection }); }
119
+ else if (message && identity) {
120
+ value.message = { sender: message.sender.account, messageId: message.envelope.messageId, senderDeviceId: message.signerDeviceId, createdAt: message.envelope.createdAt, joinedAtSequence: message.joinedAtSequence };
121
+ newMessages.add(identity.key); mutations.push(put(identity, { sequence: entry.sequence }), put(groupPendingMessageKey(group, entry.sequence), { sequence: entry.sequence })); messageSequences.push(entry.sequence);
122
+ }
123
+ mutations.push(put(key, value));
124
+ if (!rejection && entry.signerDeviceId) mutations.push(put(groupAcceptanceKey(group, entry.payload, this.context), { sequence: entry.sequence }));
125
+ if (epochChanged) {
126
+ const epochKey = groupEpochKey(group, entry.epoch); const old = row(epochKey)?.value; const memberPublicKey = result.projection.members.find(member => member.account === this.accountId)?.memberEncryptionPublicKey;
127
+ if (old?.commitment !== undefined && (old.commitment !== result.projection.clientSecretCommitment || old.memberPublicKey !== (memberPublicKey && encodeBase64Url(memberPublicKey)))) throw new ProtocolError('conflicting_epoch', 'Stored group epoch authority conflicts with verified management history.');
128
+ mutations.push(put(epochKey, { ...old, epoch: entry.epoch, commitment: result.projection.clientSecretCommitment, ...(memberPublicKey ? { memberPublicKey: encodeBase64Url(memberPublicKey) } : {}) }));
129
+ }
130
+ }
131
+ let change: GroupPageResult['change'];
132
+ if (verified.length) {
133
+ const ownerSequence = verified.filter(item => item.entry.payload.$type === 'meshline.group.owner.transfer').at(-1)?.entry.sequence ?? initial?.ownerSequence ?? 0;
134
+ const next: JsonObject = { ...initial, relayId: group.relayId, projection: encodeGroupProjection(projection!), ownerSequence };
135
+ if (projection!.members.some(member => member.account === this.accountId)) delete next.pendingApplication;
136
+ mutations.push(put(groupKey(group), next));
137
+ const kinds = groupChangeKinds.filter(kind => verified.some(item => item.result.changeKinds?.includes(kind)));
138
+ if (kinds.length) change = { record: next, kinds };
139
+ }
140
+ return { mutations, result: { ...(projection ? { projection } : {}), ...(change ? { change } : {}), hasMore: page.hasMore, rejected, messageSequences } };
141
+ }, signal);
142
+ }
143
+ /** Reuses an omitted client box only inside this page and only while both verified commitment and account key remain unchanged. */
144
+ async saveKeyPage(group: GroupRef, source: GroupKeyPage, after: number, signal?: AbortSignal): Promise<void> {
145
+ validateGroupRef(group); const page = groupKeyPageCodec.decode(groupKeyPageCodec.encode(source)); validateGroupKeyPage(page, after);
146
+ const keys = page.keys.map(entry => groupEpochKey(group, entry.epoch));
147
+ await updateStore(this.store, [groupKey(group), ...keys], snapshot => {
148
+ const projection = recordProjection(snapshot.sets[0]![0]?.value, group); const mutations: StoreMutation[] = []; let previous: GroupEpochRecord | undefined; let previousBox: GroupKeyEntry['clientSecretBox'];
149
+ for (let i = 0; i < page.keys.length; i++) {
150
+ const entry = page.keys[i]!; const row = snapshot.sets[i + 1]![0]; if (!projection || entry.epoch > projection.epoch || !row) throw new ProtocolError('unverified_epoch', 'Management history has not established this group key epoch.');
151
+ const epoch = decodeEpoch(row, group); if (!epoch.memberPublicKey) throw new ProtocolError('unauthorized_epoch', 'Group key epoch was not accessible to this account.');
152
+ const changed = !previous || previous.commitment !== epoch.commitment || !equalBytes(previous.memberPublicKey!, epoch.memberPublicKey);
153
+ if (changed && !entry.clientSecretBox) throw new ProtocolError('missing_secret_box', 'A changed verified commitment or member key requires a client secret box.');
154
+ const expanded = { ...entry, clientSecretBox: entry.clientSecretBox ?? previousBox! }; const wire = groupKeyEntryCodec.encode(expanded);
155
+ // Wrapping randomness is not an epoch identity. Derivation must compare any previously verified application secret before replacing it.
156
+ mutations.push(put(keys[i]!, { ...row.value, keyEntry: wire })); previous = epoch; previousBox = expanded.clientSecretBox;
157
+ }
158
+ return { mutations, result: undefined };
159
+ }, signal);
160
+ }
161
+ }
@@ -0,0 +1,256 @@
1
+ import { deriveGroupApplicationSecret, groupClientSecretBoxAad, groupClientSecretCommitment, groupRelaySecretBoxAad, GroupKeyAccessError, openGroupSecret } from '../crypto/groups.js';
2
+ import { agreeKey, encryptionPublicKey, requireLength } from '../crypto/primitives.js';
3
+ import type { MessageDecryptor } from '../crypto/messages.js';
4
+ import { ProtocolError, StateConflictError } from '../errors.js';
5
+ import { validateIdentifier } from '../identity/identifiers.js';
6
+ import { validateAccountId } from '../identity/neo.js';
7
+ import type { SecretProtector } from '../interactions.js';
8
+ import { contentHashBytes } from '../models/content.js';
9
+ import { groupKeyEntryCodec, validateGroupKeyEntry } from '../models/group-keys.js';
10
+ import { groupHistorySecretCodec, groupMemberPrivateStateCodec, validateAccountGroupHistorySecretSync, validateAccountGroupPrivateStateSync, validateGroupMemberPrivateState, validateGroupRef, type GroupHistorySecret, type GroupMemberPrivateState, type GroupRef } from '../models/groups.js';
11
+ import { certificateId, deviceCertificateCodec, validateCertificate } from '../models/identity.js';
12
+ import type { NetworkContext } from '../protocol/context.js';
13
+ import { decodeBase64Url, encodeBase64Url, equalBytes } from '../protocol/encoding.js';
14
+ import { canonicalJson, requireObject, requireSafeInteger, type JsonObject } from '../protocol/json.js';
15
+ import { throwIfAborted } from '../runtime/clock.js';
16
+ import type { MeshlineStore, RecordKey, RecordQuery, StoreMutation, StoreSnapshot } from '../storage/store.js';
17
+ import { updateStore } from '../storage/transaction.js';
18
+ import { decodeGroupProjection, groupEpochKey, groupKey, GroupRepository } from './repository.js';
19
+ import type { GroupApplicationSecrets } from './messages.js';
20
+
21
+ const fingerprint = (value: JsonObject | undefined): string => canonicalJson(value ?? null);
22
+ const put = (key: RecordKey, value: JsonObject): StoreMutation => ({ ...key, kind: 'put', value });
23
+ export const groupMemberSecretKey = (groupId: string, publicKey: Uint8Array): RecordKey => ({ collection: 'group_member_keys', key: `${groupId}|${encodeBase64Url(publicKey)}` });
24
+ export const groupApplicationSecretKey = (groupId: string, epoch: number): RecordKey => ({ collection: 'group_application_secrets', key: `${groupId}|${String(epoch).padStart(16, '0')}` });
25
+ export const groupRotationKey = (group: GroupRef): RecordKey => ({ collection: 'group_rotations', key: group.groupId });
26
+ const clientSecretKey = (groupId: string, commitment: string): RecordKey => ({ collection: 'group_client_secrets', key: `${groupId}|${commitment}` });
27
+ export interface PreparedGroupMemberKey { readonly group: GroupRef; readonly publicKey: Uint8Array; readonly protectedKey: string; readonly deviceId: string }
28
+ export interface GroupSecretEffect { readonly queries: readonly RecordQuery[]; plan(snapshot: StoreSnapshot): readonly StoreMutation[] }
29
+ export interface GroupSecretsOptions { readonly store: MeshlineStore; readonly context: NetworkContext; readonly accountId: string; readonly deviceId: () => string; readonly protector: SecretProtector }
30
+ export interface GroupKeyRecoveryResult { readonly derived: readonly number[]; readonly missing: readonly number[]; readonly rejected: readonly { readonly epoch: number; readonly error: ProtocolError }[] }
31
+ export interface GroupRotation { readonly group: GroupRef; readonly baseCommitment: string; readonly commitment: string; readonly ownerSequence: number; readonly deviceId: string; readonly ownerPublicKey?: Uint8Array; readonly expiresAt?: number }
32
+ function rotationRecord(value: JsonObject, group: GroupRef): GroupRotation {
33
+ validateGroupRef(group);
34
+ if (value.relayId !== group.relayId || typeof value.baseCommitment !== 'string' || typeof value.commitment !== 'string' || typeof value.deviceId !== 'string' || typeof value.protectedSecret !== 'string') throw new ProtocolError('invalid_storage', 'Stored rotation has inconsistent group, commitment or secret metadata.');
35
+ contentHashBytes(value.baseCommitment); contentHashBytes(value.commitment); validateIdentifier('device', value.deviceId); requireSafeInteger(value.ownerSequence, 0);
36
+ if (value.baseCommitment === value.commitment) throw new ProtocolError('invalid_storage', 'Stored rotation did not select a new secret.');
37
+ if (value.expiresAt !== undefined) requireSafeInteger(value.expiresAt, 1);
38
+ const ownerPublicKey = value.ownerPublicKey === undefined ? undefined : decodeBase64Url(String(value.ownerPublicKey)); if (ownerPublicKey) requireLength(ownerPublicKey, 32, 'Candidate owner public key');
39
+ return { group, baseCommitment: value.baseCommitment, commitment: value.commitment, ownerSequence: value.ownerSequence, deviceId: value.deviceId, ...(ownerPublicKey ? { ownerPublicKey } : {}), ...(value.expiresAt === undefined ? {} : { expiresAt: value.expiresAt }) };
40
+ }
41
+
42
+ /** Account-message authentication is the caller's responsibility; arriving keys never select current membership or advance verified history. */
43
+ export class GroupSecrets implements GroupApplicationSecrets {
44
+ constructor(readonly options: GroupSecretsOptions) { validateAccountId(options.accountId); }
45
+ #purpose(groupId: string, kind: string, key: string): string {
46
+ validateIdentifier('group', groupId); const deviceId = this.options.deviceId(); validateIdentifier('device', deviceId);
47
+ return `Meshline/${this.options.context}/${this.options.accountId}/${deviceId}/group/${groupId}/${kind}/${key}`;
48
+ }
49
+ async #protect(secret: Uint8Array, purpose: string, signal?: AbortSignal): Promise<string> {
50
+ try {
51
+ const protectedData = await this.options.protector.protect(secret, purpose, signal); throwIfAborted(signal);
52
+ if (!(protectedData instanceof Uint8Array) || !protectedData.length) throw new Error('Secret protector returned empty data.'); return encodeBase64Url(protectedData);
53
+ } catch (cause) { throwIfAborted(signal); throw new GroupKeyAccessError('Local group secret protection failed.', { cause }); }
54
+ }
55
+ async #unprotect(value: JsonObject, field: string, purpose: string, signal?: AbortSignal): Promise<Uint8Array> {
56
+ let result: Uint8Array | undefined;
57
+ try {
58
+ if (typeof value[field] !== 'string') throw new Error('Protected group secret is missing.');
59
+ result = await this.options.protector.unprotect(decodeBase64Url(value[field]), purpose, signal); throwIfAborted(signal); requireLength(result, 32, 'Protected group secret'); return result;
60
+ } catch (cause) { if (result instanceof Uint8Array) result.fill(0); throwIfAborted(signal); throw new GroupKeyAccessError('Local group secret could not be restored.', { cause }); }
61
+ }
62
+ async prepareMemberState(source: GroupMemberPrivateState, signal?: AbortSignal): Promise<PreparedGroupMemberKey> {
63
+ const value = groupMemberPrivateStateCodec.decode(groupMemberPrivateStateCodec.encode(source));
64
+ try {
65
+ validateGroupMemberPrivateState(value); const deviceId = this.options.deviceId();
66
+ const group = { groupId: value.groupId, relayId: value.relayId }; const publicKey = encryptionPublicKey(value.memberEncryptionPrivateKey);
67
+ const purpose = this.#purpose(group.groupId, 'member', encodeBase64Url(publicKey));
68
+ const protectedKey = await this.#protect(value.memberEncryptionPrivateKey, purpose, signal);
69
+ if (purpose !== this.#purpose(group.groupId, 'member', encodeBase64Url(publicKey))) throw new GroupKeyAccessError('Local device changed during group key protection.');
70
+ return { group, publicKey, protectedKey, deviceId };
71
+ } finally { value.memberEncryptionPrivateKey.fill(0); }
72
+ }
73
+ /** Current and historical private keys coexist by derived public key; arrival order has no authority. */
74
+ async saveMemberState(source: GroupMemberPrivateState, signal?: AbortSignal): Promise<void> {
75
+ const effect = await this.prepareMemberStates([source], signal);
76
+ await updateStore(this.options.store, effect.queries, snapshot => ({ mutations: effect.plan(snapshot), result: undefined }), signal);
77
+ }
78
+ /** Prepared mutations can join an account-message consumer cursor in one atomic commit. */
79
+ async prepareMemberStates(sources: readonly GroupMemberPrivateState[], signal?: AbortSignal): Promise<GroupSecretEffect> {
80
+ const states = sources.map(source => groupMemberPrivateStateCodec.decode(groupMemberPrivateStateCodec.encode(source)));
81
+ try {
82
+ validateAccountGroupPrivateStateSync({ states }); const prepared: PreparedGroupMemberKey[] = [];
83
+ for (const state of states) prepared.push(await this.prepareMemberState(state, signal));
84
+ const queries = prepared.flatMap(value => [groupKey(value.group), groupMemberSecretKey(value.group.groupId, value.publicKey)]);
85
+ return { queries, plan: snapshot => {
86
+ const mutations: StoreMutation[] = [];
87
+ for (const [index, value] of prepared.entries()) {
88
+ if (this.options.deviceId() !== value.deviceId) throw new GroupKeyAccessError('Local device changed before member key persistence.');
89
+ const record = snapshot.sets[index * 2]![0]?.value; const existing = snapshot.sets[index * 2 + 1]![0]?.value; const publicKey = encodeBase64Url(value.publicKey);
90
+ if (record && record.relayId !== value.group.relayId) throw new ProtocolError('invalid_binding', 'Private group state names another hosting relay.');
91
+ if (existing && existing.publicKey !== publicKey) throw new ProtocolError('invalid_storage', 'Stored group member key does not match its identity.');
92
+ if (!record) mutations.push(put(groupKey(value.group), { relayId: value.group.relayId }));
93
+ if (!existing) mutations.push(put(groupMemberSecretKey(value.group.groupId, value.publicKey), { publicKey, protectedKey: value.protectedKey, shared: false }));
94
+ }
95
+ return mutations;
96
+ } };
97
+ } finally { for (const state of states) state.memberEncryptionPrivateKey.fill(0); }
98
+ }
99
+ async readMemberKey(groupId: string, publicKey: Uint8Array, signal?: AbortSignal): Promise<Uint8Array | undefined> {
100
+ validateIdentifier('group', groupId); requireLength(publicKey, 32, 'Member public key'); const key = publicKey.slice();
101
+ const row = (await this.options.store.read([groupMemberSecretKey(groupId, key)], signal)).sets[0]![0]?.value; if (!row) return undefined;
102
+ const purpose = this.#purpose(groupId, 'member', encodeBase64Url(key)); const privateKey = await this.#unprotect(row, 'protectedKey', purpose, signal);
103
+ try { if (row.publicKey !== encodeBase64Url(key) || !equalBytes(encryptionPublicKey(privateKey), key) || purpose !== this.#purpose(groupId, 'member', encodeBase64Url(key))) throw new GroupKeyAccessError('Restored group member key differs from its public identity.'); return privateKey; }
104
+ catch (error) { privateKey.fill(0); throw error; }
105
+ }
106
+ async stageHistorySecret(source: GroupHistorySecret, signal?: AbortSignal): Promise<void> {
107
+ const effect = await this.prepareHistorySecrets([source], signal);
108
+ await updateStore(this.options.store, effect.queries, snapshot => ({ mutations: effect.plan(snapshot), result: undefined }), signal);
109
+ }
110
+ async prepareHistorySecrets(sources: readonly GroupHistorySecret[], signal?: AbortSignal): Promise<GroupSecretEffect> {
111
+ const secrets = sources.map(source => groupHistorySecretCodec.decode(groupHistorySecretCodec.encode(source)));
112
+ try {
113
+ validateAccountGroupHistorySecretSync({ secrets }); const deviceId = this.options.deviceId();
114
+ const queries = secrets.map(value => groupApplicationSecretKey(value.groupId, value.epoch)); const initial = await this.options.store.read(queries, signal); const mutations: StoreMutation[] = [];
115
+ for (const [index, value] of secrets.entries()) {
116
+ const row = initial.sets[index]![0]?.value; const purpose = this.#purpose(value.groupId, 'epoch', String(value.epoch));
117
+ if (row) {
118
+ if (row.epoch !== value.epoch) throw new ProtocolError('invalid_storage', 'Stored group secret belongs to another epoch.');
119
+ const existing = await this.#unprotect(row, 'protectedSecret', purpose, signal);
120
+ try { if (!equalBytes(existing, value.applicationSecret)) throw new ProtocolError('conflicting_epoch_secret', 'Account synchronization conflicts with a previously stored epoch secret.'); }
121
+ finally { existing.fill(0); }
122
+ } else {
123
+ const protectedSecret = await this.#protect(value.applicationSecret, purpose, signal);
124
+ mutations.push(put(queries[index]!, { epoch: value.epoch, protectedSecret, source: 'account', shared: false }));
125
+ }
126
+ if (deviceId !== this.options.deviceId()) throw new GroupKeyAccessError('Local device changed during epoch secret preparation.');
127
+ }
128
+ return { queries, plan: snapshot => {
129
+ if (deviceId !== this.options.deviceId()) throw new GroupKeyAccessError('Local device changed before epoch secret persistence.');
130
+ if (snapshot.sets.some((rows, index) => fingerprint(rows[0]?.value) !== fingerprint(initial.sets[index]![0]?.value))) throw new StateConflictError('Group epoch secret changed while synchronization was being prepared.');
131
+ return mutations;
132
+ } };
133
+ } finally { for (const value of secrets) value.applicationSecret.fill(0); }
134
+ }
135
+ async readApplicationSecret(group: GroupRef, epoch: number, signal?: AbortSignal): Promise<Uint8Array | undefined> {
136
+ validateGroupRef(group); requireSafeInteger(epoch, 0); const snapshot = await this.options.store.read([groupKey(group), groupEpochKey(group, epoch), groupApplicationSecretKey(group.groupId, epoch)], signal);
137
+ const association = snapshot.sets[0]![0]?.value; if (association && association.relayId !== group.relayId) throw new ProtocolError('invalid_binding', 'Group secret belongs to another hosting relay.');
138
+ if (!association?.projection || !snapshot.sets[1]![0]?.value.commitment) return undefined;
139
+ const projection = decodeGroupProjection(requireObject(association.projection), group); if (epoch > projection.epoch) return undefined;
140
+ const row = snapshot.sets[2]![0]?.value; if (!row) return undefined; if (row.epoch !== epoch) throw new ProtocolError('invalid_storage', 'Stored group secret belongs to another epoch.');
141
+ const purpose = this.#purpose(group.groupId, 'epoch', String(epoch)); const secret = await this.#unprotect(row, 'protectedSecret', purpose, signal);
142
+ if (purpose !== this.#purpose(group.groupId, 'epoch', String(epoch))) { secret.fill(0); throw new GroupKeyAccessError('Local device changed during group secret restoration.'); } return secret;
143
+ }
144
+ async readClientSecret(group: GroupRef, commitment: string, signal?: AbortSignal): Promise<Uint8Array | undefined> {
145
+ validateGroupRef(group); contentHashBytes(commitment); const epochs = await new GroupRepository(this.options.store, this.options.context, this.options.accountId).epochs(group, signal);
146
+ if (!epochs.some(epoch => epoch.commitment === commitment)) return undefined;
147
+ const row = (await this.options.store.read([clientSecretKey(group.groupId, commitment)], signal)).sets[0]![0]?.value; if (!row) return undefined;
148
+ const purpose = this.#purpose(group.groupId, 'client', commitment); const secret = await this.#unprotect(row, 'protectedSecret', purpose, signal);
149
+ if (groupClientSecretCommitment(group.groupId, secret, this.options.context) !== commitment || purpose !== this.#purpose(group.groupId, 'client', commitment)) { secret.fill(0); throw new GroupKeyAccessError('Stored client secret does not match its verified commitment or local device.'); } return secret;
150
+ }
151
+ async rotation(group: GroupRef, signal?: AbortSignal): Promise<GroupRotation | undefined> {
152
+ group = { ...group }; validateGroupRef(group); const snapshot = await this.options.store.read([groupKey(group), groupRotationKey(group)], signal);
153
+ if (snapshot.sets[0]![0]?.value.relayId !== group.relayId) throw new ProtocolError('invalid_binding', 'Rotation group has no matching hosting association.');
154
+ const row = snapshot.sets[1]![0]?.value; return row && rotationRecord(row, group);
155
+ }
156
+ async beginRotation(group: GroupRef, baseCommitment: string, source: Uint8Array, member: PreparedGroupMemberKey | undefined, signal?: AbortSignal): Promise<GroupRotation> {
157
+ group = { ...group }; validateGroupRef(group); contentHashBytes(baseCommitment); const secret = source.slice();
158
+ try {
159
+ const commitment = groupClientSecretCommitment(group.groupId, secret, this.options.context); if (commitment === baseCommitment) throw new ProtocolError('unchanged_secret', 'Rotation requires a fresh client group secret.');
160
+ const deviceId = this.options.deviceId(); const purpose = this.#purpose(group.groupId, 'rotation', commitment); const protectedSecret = await this.#protect(secret, purpose, signal);
161
+ if (member && (member.group.groupId !== group.groupId || member.group.relayId !== group.relayId || member.deviceId !== deviceId)) throw new ProtocolError('invalid_binding', 'Rotation member key belongs to another group or local device.');
162
+ const queries = [groupKey(group), groupRotationKey(group), ...(member ? [groupMemberSecretKey(group.groupId, member.publicKey)] : [])];
163
+ return await updateStore(this.options.store, queries, snapshot => {
164
+ if (purpose !== this.#purpose(group.groupId, 'rotation', commitment)) throw new GroupKeyAccessError('Local rotation protection identity changed.');
165
+ const row = snapshot.sets[0]![0]?.value; if (row?.relayId !== group.relayId || !row.projection) throw new ProtocolError('unverified_group', 'Rotation requires verified group history.');
166
+ const state = decodeGroupProjection(requireObject(row.projection), group);
167
+ if (state.clientSecretCommitment !== baseCommitment || state.state.owner !== this.options.accountId || state.state.status !== 'active') throw new StateConflictError('Verified group secret or owner changed before rotation staging.');
168
+ if (snapshot.sets[1]!.length) throw new StateConflictError('A group secret rotation is already staged.');
169
+ const value: JsonObject = { relayId: group.relayId, baseCommitment, commitment, protectedSecret, deviceId, ownerSequence: row.ownerSequence ?? 0, ...(member ? { ownerPublicKey: encodeBase64Url(member.publicKey) } : {}) };
170
+ const result = rotationRecord(value, group); const mutations: StoreMutation[] = [put(groupRotationKey(group), value)];
171
+ if (member) { if (snapshot.sets[2]!.length) throw new StateConflictError('Candidate owner key already exists.'); mutations.push(put(queries[2]!, { publicKey: encodeBase64Url(member.publicKey), protectedKey: member.protectedKey, shared: false })); }
172
+ return { mutations, result };
173
+ }, signal);
174
+ } finally { secret.fill(0); }
175
+ }
176
+ async readRotationSecret(rotation: GroupRotation, signal?: AbortSignal): Promise<Uint8Array> {
177
+ const row = (await this.options.store.read([groupRotationKey(rotation.group)], signal)).sets[0]![0]?.value;
178
+ if (!row || row.commitment !== rotation.commitment || row.baseCommitment !== rotation.baseCommitment) throw new StateConflictError('Candidate group secret changed before restoration.');
179
+ const current = rotationRecord(row, rotation.group); if (current.deviceId !== this.options.deviceId()) throw new GroupKeyAccessError('Rotation belongs to another local device.');
180
+ const purpose = this.#purpose(rotation.group.groupId, 'rotation', rotation.commitment); const secret = await this.#unprotect(row, 'protectedSecret', purpose, signal);
181
+ if (purpose !== this.#purpose(rotation.group.groupId, 'rotation', rotation.commitment) || groupClientSecretCommitment(rotation.group.groupId, secret, this.options.context) !== rotation.commitment) { secret.fill(0); throw new GroupKeyAccessError('Restored candidate secret differs from its commitment or local identity.'); } return secret;
182
+ }
183
+ async acknowledgeRotation(rotation: GroupRotation, expiresAt: number): Promise<GroupRotation> {
184
+ requireSafeInteger(expiresAt, 1);
185
+ return updateStore(this.options.store, [groupRotationKey(rotation.group)], snapshot => {
186
+ const value = snapshot.sets[0]![0]?.value; if (!value || value.commitment !== rotation.commitment) throw new StateConflictError('Candidate rotation changed before preparation was acknowledged.');
187
+ const current = rotationRecord(value, rotation.group); if (current.expiresAt !== undefined && current.expiresAt !== expiresAt) throw new ProtocolError('conflicting_expiry', 'Relay changed the original rotation preparation expiry.');
188
+ const next = { ...value, expiresAt }; return { mutations: [put(groupRotationKey(rotation.group), next)], result: rotationRecord(next, rotation.group) };
189
+ });
190
+ }
191
+ async discardRotation(rotation: GroupRotation, signal?: AbortSignal): Promise<void> {
192
+ await updateStore(this.options.store, [groupRotationKey(rotation.group)], snapshot => {
193
+ const row = snapshot.sets[0]![0]?.value; if (row && row.commitment !== rotation.commitment) throw new StateConflictError('Another rotation replaced the obsolete candidate.');
194
+ return { mutations: row ? [{ kind: 'delete', ...groupRotationKey(rotation.group) }] : [], result: undefined };
195
+ }, signal);
196
+ }
197
+ async derivePending(group: GroupRef, device: MessageDecryptor, signal?: AbortSignal): Promise<GroupKeyRecoveryResult> {
198
+ const epochs = await new GroupRepository(this.options.store, this.options.context, this.options.accountId).epochs(group, signal);
199
+ const derived: number[] = []; const missing: number[] = []; const rejected: { epoch: number; error: ProtocolError }[] = [];
200
+ for (const epoch of epochs) {
201
+ if (!epoch.keyEntry || !epoch.memberPublicKey) continue;
202
+ try { const result = await this.#deriveEpoch(group, epoch.epoch, device, signal); if (result === 'derived') derived.push(epoch.epoch); if (result === 'missing') missing.push(epoch.epoch); }
203
+ catch (error) {
204
+ throwIfAborted(signal);
205
+ if (!(error instanceof ProtocolError) || !['invalid_ciphertext', 'invalid_key', 'invalid_commitment', 'conflicting_epoch_secret'].includes(error.code)) throw error;
206
+ rejected.push({ epoch: epoch.epoch, error });
207
+ }
208
+ }
209
+ return { derived, missing, rejected };
210
+ }
211
+ async #deriveEpoch(group: GroupRef, epoch: number, device: MessageDecryptor, signal?: AbortSignal): Promise<'derived' | 'missing' | 'ready'> {
212
+ const { store, context, accountId } = this.options; const first = (await store.read([groupEpochKey(group, epoch)], signal)).sets[0]![0]?.value;
213
+ if (!first || typeof first.commitment !== 'string' || typeof first.memberPublicKey !== 'string') throw new ProtocolError('invalid_storage', 'Group epoch has no verified secret authority.');
214
+ const commitment = first.commitment; contentHashBytes(commitment); const publicKey = decodeBase64Url(first.memberPublicKey); requireLength(publicKey, 32, 'Verified member public key');
215
+ const queries = [groupKey(group), groupEpochKey(group, epoch), groupMemberSecretKey(group.groupId, publicKey), groupApplicationSecretKey(group.groupId, epoch), clientSecretKey(group.groupId, commitment)];
216
+ const snapshot = await store.read(queries, signal); const [association, epochRow, member, application, client] = snapshot.sets.map(rows => rows[0]?.value);
217
+ if (association?.relayId !== group.relayId || !association.projection || !epochRow || fingerprint(epochRow) !== fingerprint(first)) throw new StateConflictError('Group key authority changed during recovery.');
218
+ if (decodeGroupProjection(requireObject(association.projection), group).epoch < epoch) throw new ProtocolError('invalid_storage', 'Group epoch has not been reached by verified history.');
219
+ const certificate = deviceCertificateCodec.decode(deviceCertificateCodec.encode(device.certificate)); validateCertificate(certificate, context); const deviceId = certificateId(certificate, context);
220
+ if (certificate.account !== accountId || deviceId !== this.options.deviceId()) throw new GroupKeyAccessError('Group key receiver belongs to another local account or device.');
221
+ if (epochRow.materialVerified === true && application && client) return 'ready';
222
+ if (!member) return 'missing';
223
+ const entry = groupKeyEntryCodec.decode(epochRow.keyEntry!); validateGroupKeyEntry(entry);
224
+ if (entry.epoch !== epoch || !entry.clientSecretBox || member.publicKey !== encodeBase64Url(publicKey)) throw new ProtocolError('invalid_storage', 'Stored group key entry or member key has inconsistent metadata.');
225
+ const memberPurpose = this.#purpose(group.groupId, 'member', encodeBase64Url(publicKey)); const clientPurpose = this.#purpose(group.groupId, 'client', commitment); const epochPurpose = this.#purpose(group.groupId, 'epoch', String(epoch));
226
+ const privateKey = await this.#unprotect(member, 'protectedKey', memberPurpose, signal);
227
+ let shared: Uint8Array | undefined; let clientSecret: Uint8Array | undefined; let relaySecret: Uint8Array | undefined; let secret: Uint8Array | undefined; let oldApplication: Uint8Array | undefined; let oldClient: Uint8Array | undefined;
228
+ try {
229
+ if (!equalBytes(encryptionPublicKey(privateKey), publicKey)) throw new GroupKeyAccessError('Restored member key differs from its verified public key.');
230
+ shared = agreeKey(privateKey, entry.clientSecretBox.enc);
231
+ clientSecret = openGroupSecret(entry.clientSecretBox, shared, groupClientSecretBoxAad({ groupId: group.groupId, account: accountId, memberEncryptionPublicKey: publicKey, clientSecretCommitment: commitment }, context));
232
+ if (groupClientSecretCommitment(group.groupId, clientSecret, context) !== commitment) throw new ProtocolError('invalid_commitment', 'Unwrapped client secret differs from the verified commitment.');
233
+ shared.fill(0); shared = undefined;
234
+ const probe = agreeKey(new Uint8Array(32).fill(1), entry.relaySecretBox.enc); probe.fill(0);
235
+ try {
236
+ shared = await device.deriveSharedSecret(entry.relaySecretBox.enc.slice(), signal); throwIfAborted(signal); requireLength(shared, 32, 'Device shared secret');
237
+ if (shared.every(byte => byte === 0) || certificateId(device.certificate, context) !== deviceId) throw new Error('Device key agreement returned invalid identity or key material.');
238
+ } catch (cause) { throwIfAborted(signal); throw new GroupKeyAccessError('Local device could not open the group relay-secret box.', { cause }); }
239
+ relaySecret = openGroupSecret(entry.relaySecretBox, shared, groupRelaySecretBoxAad({ groupId: group.groupId, account: accountId, deviceId, epoch }, context));
240
+ secret = deriveGroupApplicationSecret(group.groupId, epoch, commitment, clientSecret, relaySecret, context);
241
+ if (application) { oldApplication = await this.#unprotect(application, 'protectedSecret', epochPurpose, signal); if (!equalBytes(oldApplication, secret)) throw new ProtocolError('conflicting_epoch_secret', 'Derived group secret conflicts with a previously synchronized epoch secret.'); }
242
+ if (client) { oldClient = await this.#unprotect(client, 'protectedSecret', clientPurpose, signal); if (!equalBytes(oldClient, clientSecret)) throw new GroupKeyAccessError('Stored client secret conflicts with its verified commitment.'); }
243
+ const protectedSecret = application?.protectedSecret ?? await this.#protect(secret, epochPurpose, signal);
244
+ const protectedClient = client?.protectedSecret ?? await this.#protect(clientSecret, clientPurpose, signal);
245
+ await updateStore(store, queries, current => {
246
+ if (this.options.deviceId() !== deviceId) throw new GroupKeyAccessError('Local device changed before epoch secret persistence.');
247
+ if (current.sets.some((rows, i) => fingerprint(rows[0]?.value) !== fingerprint(snapshot.sets[i]![0]?.value))) throw new StateConflictError('Group key material changed while recovery was being prepared.');
248
+ return { mutations: [put(queries[1]!, { ...epochRow, materialVerified: true }), put(queries[3]!, { ...application, epoch, protectedSecret, source: 'derived' }),
249
+ put(queries[4]!, { commitment, protectedSecret: protectedClient })], result: undefined };
250
+ }, signal);
251
+ return 'derived';
252
+ } finally {
253
+ privateKey.fill(0); if (shared instanceof Uint8Array) shared.fill(0); clientSecret?.fill(0); relaySecret?.fill(0); secret?.fill(0); oldApplication?.fill(0); oldClient?.fill(0);
254
+ }
255
+ }
256
+ }