@kubun/plugin-p2p 0.10.1 → 0.12.0

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 (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,702 @@
1
- import{HLC as e}from"@kubun/hlc";import{evaluateAndForward as r,rethrowIfProgrammerError as a}from"../sync/forwarder.js";import{createReceiveAccessGate as l}from"../sync/receive-access-gate.js";import{storeReceivedGrant as t}from"./store-received-grant.js";import{storeReceivedRevocation as c}from"./store-received-revocation.js";function i(r,a){return e.compare(e.parse(r),e.parse(a))>0}export async function processBroadcast(e,o,d){let{p2pStore:p,graphStore:n}=e;switch(o.type){case"circle:create":{let e=await p.getCircle(o.circle.id,{includeRemoved:!0});if(null!=e){let r=e.removed_at_hlc??e.hlc;if(!i(o.circle.hlc,r))return{applied:!1};await p.updateCircle(o.circle.id,{name:o.circle.name,description:o.circle.description,catalog_ids:o.circle.catalogIDs,hlc:o.circle.hlc});let a=await p.getCircle(o.circle.id);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}await p.createCircle({id:o.circle.id,group_id:o.circle.groupID,name:o.circle.name,description:o.circle.description,catalog_ids:o.circle.catalogIDs,hlc:o.circle.hlc});let r=await p.getCircle(o.circle.id);return null!=r?{applied:!0,affected:{kind:"circle",row:r}}:{applied:!0}}case"circle:update":{let e=await p.getCircle(o.circleID,{includeRemoved:!0});if(null==e)return{applied:!1};let r=e.removed_at_hlc??e.hlc;if(!i(o.update.hlc,r))return{applied:!1};await p.updateCircle(o.circleID,{name:o.update.name,description:o.update.description,catalog_ids:o.update.catalogIDs,hlc:o.update.hlc});let a=await p.getCircle(o.circleID);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}case"circle:delete":{let e=await p.getCircle(o.circleID,{includeRemoved:!0});if(null==e||!i(o.hlc,e.hlc))return{applied:!1};return await p.markCircleRemoved(o.circleID,o.hlc),{applied:!0,affected:{kind:"circle",row:e}}}case"member:add":{let e=await p.getCircleMember(o.member.circleID,o.member.memberDID);if(null!=e){let r=e.removed_at_hlc??e.hlc;if(!i(o.member.hlc,r))return{applied:!1}}await p.addCircleMember({circle_id:o.member.circleID,member_did:o.member.memberDID,role:o.member.role,hlc:o.member.hlc});let r=await p.getCircleMember(o.member.circleID,o.member.memberDID);return null!=r?{applied:!0,affected:{kind:"member",row:r}}:{applied:!0}}case"member:remove":{let e=await p.getCircleMember(o.circleID,o.memberDID);return await p.removeCircleMember(o.circleID,o.memberDID,o.hlc),null!=e?{applied:!0,affected:{kind:"member",row:e}}:{applied:!0}}case"catalog:create":{let e=await n.getCatalog(o.catalog.id);if(null!=e){if(!i(o.catalog.hlc,e.hlc))return{applied:!1};await n.updateCatalog(o.catalog.id,{name:o.catalog.name,description:o.catalog.description,filter_criteria:o.catalog.filterCriteria,hlc:o.catalog.hlc});let r=await n.getCatalog(o.catalog.id);return null!=r?{applied:!0,affected:{kind:"catalog",row:r}}:{applied:!0}}await n.createCatalog({id:o.catalog.id,owner_did:o.catalog.ownerDID,name:o.catalog.name,description:o.catalog.description,filter_criteria:o.catalog.filterCriteria,hlc:o.catalog.hlc});let r=await n.getCatalog(o.catalog.id);return null!=r?{applied:!0,affected:{kind:"catalog",row:r}}:{applied:!0}}case"catalog:update":{let e=await n.getCatalog(o.catalogID);if(null==e||!i(o.update.hlc,e.hlc))return{applied:!1};await n.updateCatalog(o.catalogID,{name:o.update.name,description:o.update.description,filter_criteria:o.update.filterCriteria,hlc:o.update.hlc});let r=await n.getCatalog(o.catalogID);return null!=r?{applied:!0,affected:{kind:"catalog",row:r}}:{applied:!0}}case"catalog:delete":{let e=await n.getCatalog(o.catalogID);if(null==e||!i(o.hlc,e.hlc))return{applied:!1};return await n.deleteCatalog(o.catalogID),{applied:!0,affected:{kind:"catalog",row:e}}}case"delegation:share":return{applied:await t({p2pStore:p,token:o.token,groupID:o.groupID,hlc:o.hlc,selfDID:e.selfDID,...null!=e.emitter?{emitter:e.emitter}:{},...null!=e.logger?{logger:e.logger}:{}})};case"delegation:revoke":return{applied:await c({p2pStore:p,token:o.token,groupID:o.groupID,hlc:o.hlc,...null!=e.selfDID?{selfDID:e.selfDID}:{},...null!=e.emitter?{emitter:e.emitter}:{},...null!=e.logger?{logger:e.logger}:{}})};case"group:update":{let e=await p.getGroup(o.groupID);if(null==e||!i(o.update.hlc,e.hlc))return{applied:!1};await p.updateGroup(o.groupID,{name:o.update.name,description:o.update.description,hlc:o.update.hlc});let r=await p.getGroup(o.groupID);return null!=r?{applied:!0,affected:{kind:"group",row:r}}:{applied:!0}}case"group:leaveRequest":return await e.emitter?.emit("groupLeaveRequested",{groupID:o.groupID,memberDID:o.memberDID,role:"",createdAt:new Date().toISOString()}),{applied:!0};case"mutation:apply":{if(o.senderPeerDID===e.selfDID)return{applied:!1};let t="drop"===(e.storeUnreadable??"persist")?l({selfDID:e.selfDID,db:{getUserModelAccessDefault:(e,r,a)=>n.getUserModelAccessDefault(e,r,a),isMemberOfAnyCircle:(e,r)=>p.isMemberOfAnyCircle(e,r),isMemberOfAnyGroup:(e,r)=>p.isMemberOfAnyGroup(e,r),getModelInterfaces:e=>n.getModelInterfaces(e)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}}):void 0,c=null!=e.forwarding&&!1!==e.forwarding&&null!=e.scheduleBroadcast,i=!1,u=0,s=0,m=0;for(let l of o.entries)try{let o=await e.graph.applyVerifiedMutation({token:l.mutationJWT,origin:"peer",arrivalGroupID:d,...null!=t?{accessGate:t}:{}});if(o.dropped){u++;continue}let g=!1;try{let e=await p.recordBroadcast({docID:l.docID,version:l.version,mlsGroupID:d});g=!0,!e&&m++}catch(r){a(r),e.logger?.warn("mutation:apply recordBroadcast failed, skipping forward",{docID:l.docID,version:l.version,groupID:d,error:r})}if(i=!0,!g)continue;if(c&&null!=e.forwarding&&null!=e.scheduleBroadcast&&null!=o.document)try{let a=await r({entry:l,postState:o.document,sourceGroupID:d,selfDID:e.selfDID,p2pStore:p,graphStore:n,defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"},forwarding:e.forwarding,scheduleBroadcast:e.scheduleBroadcast,logger:e.logger});s+=a.forwarded}catch(r){a(r),e.logger?.warn("mutation:apply forwarding failed, skipping entry",{docID:l.docID,version:l.version,groupID:d,error:r})}}catch(r){a(r),e.logger?.warn("mutation:apply entry failed, skipping",{docID:l.docID,version:l.version,modelID:l.modelID,groupID:d,error:r})}let g={applied:i,dropped:u,duplicates:m};return c&&(g.forwarded=s),g}case"circle-catchup:request":case"circle-catchup:reply":return{applied:!1}}}export async function applyCatchupReply(e,r,a){let l=0,t=0;for(let t of r.circles){let r={type:"circle:create",circle:{id:t.id,groupID:t.groupID,name:t.name,description:t.description,catalogIDs:t.catalogIDs,hlc:t.hlc}},c=(await processBroadcast(e,r,a)).applied;if(null!=t.removedAtHLC){let r={type:"circle:delete",circleID:t.id,hlc:t.removedAtHLC},l=await processBroadcast(e,r,a);c=c||l.applied}c&&l++}for(let l of r.circleMembers){let r={type:"member:add",member:{circleID:l.circleID,memberDID:l.memberDID,role:l.role,hlc:l.hlc}},c=(await processBroadcast(e,r,a)).applied;if(null!=l.removedAtHLC){let r={type:"member:remove",circleID:l.circleID,memberDID:l.memberDID,hlc:l.removedAtHLC},t=await processBroadcast(e,r,a);c=c||t.applied}c&&t++}return{circlesApplied:l,membersApplied:t}}
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { HLC } from '@kubun/hlc';
3
+ import { toISO } from '../context/types.js';
4
+ import { evaluateAndForward, rethrowIfProgrammerError } from '../sync/forwarder.js';
5
+ import { createReceiveAccessGate } from '../sync/receive-access-gate.js';
6
+ import { verifyAccessDefault } from './access-default-token.js';
7
+ import { verifyCatalog } from './catalog-token.js';
8
+ import { reprojectCircleDefs, reprojectCircleMembers, reprojectGroupSettings } from './circle-projection.js';
9
+ import { entryOrd } from './ledger.js';
10
+ import { resolveGroupAnchor } from './ledger-ingest.js';
11
+ import { mirrorRosterRoles } from './roster-projection.js';
12
+ import { storeReceivedGrant } from './store-received-grant.js';
13
+ import { storeReceivedRevocation } from './store-received-revocation.js';
14
+ /**
15
+ * Process a received broadcast message, applying it to the local database
16
+ * with HLC-based last-write-wins conflict resolution.
17
+ *
18
+ * Returns `{ applied, affected? }`. `applied` is true when the message
19
+ * mutated local state. `affected` carries the row snapshot needed by
20
+ * downstream event mapping: after-state for create / update, before-state
21
+ * for delete / remove.
22
+ *
23
+ * Readback note: for create / update the post-apply row is fetched with a
24
+ * separate `get*` call rather than a txn-scoped read. If an unrelated
25
+ * concurrent write to the same row lands between apply and readback, the
26
+ * emitted `affected.row` reflects that later write. `BroadcastService`
27
+ * serializes receives, so the realistic exposure is only a same-row local
28
+ * mutation between apply and readback, which is acceptable for event
29
+ * payload fidelity.
30
+ */ export async function processBroadcast(params, message, groupID) {
31
+ const { p2pStore, graphStore } = params;
32
+ switch(message.type){
33
+ case 'catalog:create':
34
+ {
35
+ // Authenticate via the signed token: an absent, forged, or unsigned token
36
+ // verifies to null and is dropped. The authoritative catalog comes from
37
+ // the verified payload, never the surrounding plaintext frame.
38
+ const verified = message.auth != null ? await verifyCatalog(message.auth) : null;
39
+ if (verified == null || verified.claim.op !== 'set') {
40
+ params.logger?.warn('catalog:create dropped: token verification failed', {
41
+ groupID
42
+ });
43
+ return {
44
+ applied: false
45
+ };
46
+ }
47
+ const catalog = verified.claim.catalog;
48
+ // Sender-bound: a catalog is applied only when its owner is the
49
+ // authenticated author. Nobody can publish a catalog under another DID.
50
+ // `verified.issuer` is already canonicalized (short form), so normalize
51
+ // the record owner to compare — it may carry the signer's long form.
52
+ if (normalizeDID(catalog.ownerDID) !== verified.issuer) {
53
+ params.logger?.warn('catalog:create dropped: owner is not the authenticated author', {
54
+ groupID
55
+ });
56
+ return {
57
+ applied: false
58
+ };
59
+ }
60
+ // Discovery is advisory: an absent catalog is stored KNOWN (not active) so
61
+ // it applies no sync until an explicit local activation. LWW by the
62
+ // catalog's own `hlc`: an existing row is replaced only by a strictly-newer
63
+ // write, and only its content — the local `active` flag and first-discovery
64
+ // source columns are never touched by a remote frame.
65
+ const existing = await graphStore.getCatalog(catalog.id);
66
+ if (existing == null) {
67
+ // Persist the verified set token so this discovered catalog can later be
68
+ // forwarded verbatim (e.g. relayed in an invite seed) and re-verified.
69
+ await graphStore.upsertDiscoveredCatalog({
70
+ catalog: {
71
+ id: catalog.id,
72
+ owner_did: catalog.ownerDID,
73
+ name: catalog.name,
74
+ description: catalog.description,
75
+ filter_criteria: catalog.filterCriteria,
76
+ hlc: catalog.hlc,
77
+ signed_token: message.auth
78
+ },
79
+ sourceGroupID: groupID,
80
+ sourceCircleID: null
81
+ });
82
+ return {
83
+ applied: true
84
+ };
85
+ }
86
+ if (normalizeDID(existing.owner_did) !== verified.issuer || catalog.hlc <= existing.hlc) {
87
+ return {
88
+ applied: false
89
+ };
90
+ }
91
+ await graphStore.updateCatalog(catalog.id, {
92
+ name: catalog.name,
93
+ description: catalog.description,
94
+ filter_criteria: catalog.filterCriteria,
95
+ hlc: catalog.hlc,
96
+ signed_token: message.auth
97
+ });
98
+ return {
99
+ applied: true
100
+ };
101
+ }
102
+ case 'catalog:update':
103
+ {
104
+ const verified = message.auth != null ? await verifyCatalog(message.auth) : null;
105
+ if (verified == null || verified.claim.op !== 'update') {
106
+ params.logger?.warn('catalog:update dropped: token verification failed', {
107
+ groupID
108
+ });
109
+ return {
110
+ applied: false
111
+ };
112
+ }
113
+ const claim = verified.claim;
114
+ const existing = await graphStore.getCatalog(claim.catalogID);
115
+ // Only the catalog's owner may update it, and only with a strictly-newer
116
+ // write. An unknown catalog cannot be created by an update (no owner/name).
117
+ if (existing == null || normalizeDID(existing.owner_did) !== verified.issuer || claim.hlc <= existing.hlc) {
118
+ return {
119
+ applied: false
120
+ };
121
+ }
122
+ await graphStore.updateCatalog(claim.catalogID, {
123
+ ...claim.update.name != null ? {
124
+ name: claim.update.name
125
+ } : {},
126
+ ...claim.update.description != null ? {
127
+ description: claim.update.description
128
+ } : {},
129
+ ...claim.update.filterCriteria != null ? {
130
+ filter_criteria: claim.update.filterCriteria
131
+ } : {},
132
+ hlc: claim.hlc
133
+ });
134
+ return {
135
+ applied: true
136
+ };
137
+ }
138
+ case 'catalog:delete':
139
+ {
140
+ const verified = message.auth != null ? await verifyCatalog(message.auth) : null;
141
+ if (verified == null || verified.claim.op !== 'remove') {
142
+ params.logger?.warn('catalog:delete dropped: token verification failed', {
143
+ groupID
144
+ });
145
+ return {
146
+ applied: false
147
+ };
148
+ }
149
+ const existing = await graphStore.getCatalog(verified.claim.catalogID);
150
+ // Only the owner may delete. An owner retracting a catalog suggestion must
151
+ // not silently break a member who activated it: a locally-activated row is
152
+ // left completely untouched, and only a known (not-active) row is removed.
153
+ // No tombstone is needed — a remote frame can never overwrite local
154
+ // activation, so a stale set reviving a removed known catalog is harmless.
155
+ if (existing == null || normalizeDID(existing.owner_did) !== verified.issuer || existing.active === 1) {
156
+ return {
157
+ applied: false
158
+ };
159
+ }
160
+ await graphStore.deleteCatalog(verified.claim.catalogID);
161
+ return {
162
+ applied: true
163
+ };
164
+ }
165
+ case 'access-default:set':
166
+ {
167
+ const applied = await applyAccessDefaultSetFrame({
168
+ graphStore,
169
+ frame: message,
170
+ ...params.logger != null ? {
171
+ logger: params.logger
172
+ } : {},
173
+ groupID
174
+ });
175
+ return {
176
+ applied
177
+ };
178
+ }
179
+ case 'access-default:remove':
180
+ {
181
+ const verified = await verifyAccessDefault(message.auth);
182
+ if (verified == null || verified.claim.op !== 'remove') {
183
+ params.logger?.warn('access-default:remove dropped: token verification failed', {
184
+ groupID
185
+ });
186
+ return {
187
+ applied: false
188
+ };
189
+ }
190
+ // Normalized on both sides, as the `:set` arm is. `verified.issuer` is
191
+ // always the canonical short form and tokens sign with
192
+ // `embedLongForm: true`, so a raw comparison is fail-closed today only
193
+ // because every identity is a `did:key`. The first `did:peer:4` identity
194
+ // would silently drop every removal group-wide while the matching `set`
195
+ // still applied — leaving a stale grant standing.
196
+ if (normalizeDID(message.ownerDID) !== verified.issuer) {
197
+ params.logger?.warn('access-default:remove dropped: owner is not the authenticated author', {
198
+ groupID
199
+ });
200
+ return {
201
+ applied: false
202
+ };
203
+ }
204
+ const claim = verified.claim;
205
+ // LWW per permission type: remove only the types whose stored anchor is
206
+ // older than this removal. A null anchor sorts as oldest, so it is removed.
207
+ const removable = [];
208
+ for (const permissionType of claim.permissionTypes){
209
+ const storedHLC = await graphStore.getUserModelAccessDefaultHLC(verified.issuer, claim.modelID, permissionType);
210
+ if (storedHLC == null || claim.hlc > storedHLC) {
211
+ removable.push(permissionType);
212
+ }
213
+ }
214
+ if (removable.length === 0) {
215
+ return {
216
+ applied: false
217
+ };
218
+ }
219
+ await graphStore.removeUserModelAccessDefaults(verified.issuer, claim.modelID, removable, claim.hlc);
220
+ return {
221
+ applied: true
222
+ };
223
+ }
224
+ case 'delegation:share':
225
+ {
226
+ // Co-members relay the same broadcast but only the addressed device
227
+ // stores a held row. The grantor stamps the HLC, mirroring its own
228
+ // issued row for LWW consistency across devices.
229
+ const stored = await storeReceivedGrant({
230
+ delegationStore: params.delegationStore,
231
+ p2pStore,
232
+ token: message.token,
233
+ groupID: message.groupID,
234
+ hlc: message.hlc,
235
+ selfDID: params.selfDID,
236
+ ...params.emitter != null ? {
237
+ emitter: params.emitter
238
+ } : {},
239
+ ...params.logger != null ? {
240
+ logger: params.logger
241
+ } : {}
242
+ });
243
+ return {
244
+ applied: stored
245
+ };
246
+ }
247
+ case 'delegation:revoke':
248
+ {
249
+ const stored = await storeReceivedRevocation({
250
+ delegationStore: params.delegationStore,
251
+ token: message.token,
252
+ groupID: message.groupID,
253
+ hlc: message.hlc,
254
+ ...params.selfDID != null ? {
255
+ selfDID: params.selfDID
256
+ } : {},
257
+ ...params.emitter != null ? {
258
+ emitter: params.emitter
259
+ } : {},
260
+ ...params.logger != null ? {
261
+ logger: params.logger
262
+ } : {}
263
+ });
264
+ return {
265
+ applied: stored
266
+ };
267
+ }
268
+ case 'group:leaveRequest':
269
+ {
270
+ // Advisory notification only — surface it for admin apps to act on. No
271
+ // store mutation: this is not an authoritative removal (a forged request
272
+ // must not evict anyone), and MLS forbids self-removal anyway.
273
+ // The role is read from the announcer's live membership row, not taken
274
+ // from the message: the request is unauthenticated, so a sender must not
275
+ // be able to state its own role. A request from a DID this peer holds no
276
+ // row for is reported as the bare membership role rather than dropped —
277
+ // the notification is advisory and carries no authority either way.
278
+ const memberRow = await params.p2pStore.getGroupMember(message.groupID, message.memberDID);
279
+ await params.emitter?.emit('groupLeaveRequested', {
280
+ groupID: message.groupID,
281
+ memberDID: message.memberDID,
282
+ role: memberRow?.role ?? 'member',
283
+ createdAt: memberRow == null ? new Date().toISOString() : toISO(memberRow.created_at)
284
+ });
285
+ return {
286
+ applied: true
287
+ };
288
+ }
289
+ case 'peer:announce':
290
+ {
291
+ // Unattributable, so nothing is written. The DID is the row's KEY, and a
292
+ // capability claim filed under a placeholder would be a claim nobody made
293
+ // sitting where a claim someone made belongs.
294
+ //
295
+ // Unreachable through the lane as kubun wires it — `mlsDecryptFramed`
296
+ // throws rather than returning a nameless sender, so an unattributable
297
+ // frame never opens. This is the guard for the layer above changing its
298
+ // mind, and it fails closed.
299
+ if (message.announcerDID == null) {
300
+ params.logger?.warn('peer:announce dropped: frame has no authenticated sender', {
301
+ groupID
302
+ });
303
+ return {
304
+ applied: false
305
+ };
306
+ }
307
+ // Loopback drop. The hub already fans a publish to subscribers minus the
308
+ // sender, so this is redundancy — but the device's own row is seeded
309
+ // locally, and a self-announce arriving here would overwrite it with the
310
+ // remote-observed shape.
311
+ if (message.announcerDID === params.selfDID) {
312
+ return {
313
+ applied: false
314
+ };
315
+ }
316
+ // LWW by the announcer's own stamp, compared exactly as the access-default
317
+ // rules are: an announce is idempotent, so a replayed older frame — a
318
+ // retained one drained after a live one, say — must not revert the row.
319
+ const stored = await p2pStore.getPeerAnnouncement({
320
+ groupID,
321
+ peerDID: message.announcerDID
322
+ });
323
+ if (stored != null && message.hlc <= stored.hlc) {
324
+ return {
325
+ applied: false
326
+ };
327
+ }
328
+ await p2pStore.upsertPeerAnnouncement({
329
+ groupID,
330
+ peerDID: message.announcerDID,
331
+ label: message.label,
332
+ availability: message.availability,
333
+ capabilities: message.capabilities,
334
+ hlc: message.hlc,
335
+ epoch: params.getGroupEpoch?.(groupID) ?? 0
336
+ });
337
+ return {
338
+ applied: true
339
+ };
340
+ }
341
+ case 'mutation:apply':
342
+ {
343
+ // Loopback drop: never re-apply our own broadcast.
344
+ //
345
+ // An ABSENT sender does not drop. It is not "someone else" and it is not
346
+ // us — it is "unknown", and unknown must apply:
347
+ // - the sender is the immediate frame sender, so for a forwarded mutation
348
+ // it names the forwarder, not the author. Dropping on it would discard
349
+ // validly-authored, validly-forwarded mutations.
350
+ // - authorization does not rest on it. Every entry carries its own signed
351
+ // `mutationJWT`, verified independently by `applyVerifiedMutation`.
352
+ // This guard is redundancy, not protection: the hub already fans a publish
353
+ // to subscribers minus the sender.
354
+ if (message.senderPeerDID != null && message.senderPeerDID === params.selfDID) {
355
+ return {
356
+ applied: false
357
+ };
358
+ }
359
+ const storeUnreadable = params.storeUnreadable ?? 'persist';
360
+ // Build the receive-time access gate per `'drop'` broadcast, as a factory
361
+ // over the apply's transaction-scoped stores so its access reads run
362
+ // inside the apply transaction (a main-connection read there deadlocks on
363
+ // single-connection SQLite). Under `'persist'` the gate is omitted —
364
+ // `applyVerifiedMutation` takes its zero-overhead no-gate path.
365
+ const accessGate = storeUnreadable === 'drop' ? ({ graphStore: txGraphStore, p2pStore: txP2PStore })=>{
366
+ if (txP2PStore == null) {
367
+ return undefined;
368
+ }
369
+ return createReceiveAccessGate({
370
+ selfDID: params.selfDID,
371
+ db: {
372
+ getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>txGraphStore.getUserModelAccessDefault(ownerDID, modelID, permissionType),
373
+ isMemberOfAnyCircle: (viewerDID, circleIDs)=>txP2PStore.isMemberOfAnyCircle(viewerDID, circleIDs),
374
+ isMemberOfAnyGroup: (viewerDID, groupIDs)=>txP2PStore.isMemberOfAnyGroup(viewerDID, groupIDs),
375
+ getModelInterfaces: (modelID)=>txGraphStore.getModelInterfaces(modelID)
376
+ },
377
+ defaultAccessLevel: params.defaultAccessLevel ?? {
378
+ read: 'only_owner',
379
+ write: 'only_owner'
380
+ }
381
+ });
382
+ } : undefined;
383
+ // Forwarding is enabled when both `forwarding` is set + truthy AND a
384
+ // `scheduleBroadcast` hook is supplied. Either alone is a no-op.
385
+ const forwardingEnabled = params.forwarding != null && params.forwarding !== false && params.scheduleBroadcast != null;
386
+ let anyApplied = false;
387
+ let dropped = 0;
388
+ let forwarded = 0;
389
+ let duplicates = 0;
390
+ for (const entry of message.entries){
391
+ try {
392
+ const applyResult = await params.graph.applyVerifiedMutation({
393
+ token: entry.mutationJWT,
394
+ origin: 'peer',
395
+ arrivalGroupID: groupID,
396
+ ...accessGate != null ? {
397
+ accessGate
398
+ } : {}
399
+ });
400
+ if (applyResult.dropped) {
401
+ dropped++;
402
+ continue;
403
+ }
404
+ // recordBroadcast wrapped in its own try/catch: if the dedup row
405
+ // insert fails (DB error), skip forwarding for THIS entry to avoid
406
+ // propagating a broadcast that has no corresponding broadcast_log
407
+ // row (next receive would re-apply + re-forward). The mutation
408
+ // itself was applied successfully so we still mark anyApplied.
409
+ // Continue the per-entry loop — don't unwind for the rest.
410
+ let recordedOK = false;
411
+ try {
412
+ const recorded = await p2pStore.recordBroadcast({
413
+ docID: entry.docID,
414
+ version: entry.version,
415
+ mlsGroupID: groupID
416
+ });
417
+ recordedOK = true;
418
+ if (!recorded) {
419
+ duplicates++;
420
+ }
421
+ } catch (error) {
422
+ rethrowIfProgrammerError(error);
423
+ params.logger?.warn('mutation:apply recordBroadcast failed, skipping forward', {
424
+ docID: entry.docID,
425
+ version: entry.version,
426
+ groupID,
427
+ error
428
+ });
429
+ }
430
+ anyApplied = true;
431
+ if (!recordedOK) {
432
+ continue;
433
+ }
434
+ // Forward AFTER recordBroadcast success so the source-group dedup
435
+ // entry is in place before any downstream peer can echo back.
436
+ if (forwardingEnabled && params.forwarding != null && params.scheduleBroadcast != null && applyResult.document != null) {
437
+ try {
438
+ const fwdResult = await evaluateAndForward({
439
+ entry,
440
+ postState: applyResult.document,
441
+ sourceGroupID: groupID,
442
+ selfDID: params.selfDID,
443
+ p2pStore,
444
+ graphStore,
445
+ defaultAccessLevel: params.defaultAccessLevel ?? {
446
+ read: 'only_owner',
447
+ write: 'only_owner'
448
+ },
449
+ forwarding: params.forwarding,
450
+ scheduleBroadcast: params.scheduleBroadcast,
451
+ logger: params.logger
452
+ });
453
+ forwarded += fwdResult.forwarded;
454
+ } catch (error) {
455
+ rethrowIfProgrammerError(error);
456
+ params.logger?.warn('mutation:apply forwarding failed, skipping entry', {
457
+ docID: entry.docID,
458
+ version: entry.version,
459
+ groupID,
460
+ error
461
+ });
462
+ }
463
+ }
464
+ } catch (error) {
465
+ rethrowIfProgrammerError(error);
466
+ // Partial-apply tolerance: log and skip this entry, continue with rest.
467
+ params.logger?.warn('mutation:apply entry failed, skipping', {
468
+ docID: entry.docID,
469
+ version: entry.version,
470
+ modelID: entry.modelID,
471
+ groupID,
472
+ error
473
+ });
474
+ }
475
+ }
476
+ // `affected` stays undefined — document mutation consumers listen to
477
+ // engine events (document:saved, engine:document:*, engine:mutation:received)
478
+ // which fire via the engine-routed apply path. The broadcast event stream
479
+ // remains control-plane-centric.
480
+ const result = {
481
+ applied: anyApplied,
482
+ dropped,
483
+ duplicates
484
+ };
485
+ if (forwardingEnabled) {
486
+ result.forwarded = forwarded;
487
+ }
488
+ return result;
489
+ }
490
+ default:
491
+ // Version skew: a newer peer shipped a `type` outside our union. Return an
492
+ // explicit not-applied outcome (never a silent `undefined`) so no consumer
493
+ // dereferences undefined, and log it.
494
+ params.logger?.warn('unknown broadcast message type', {
495
+ type: message.type,
496
+ groupID
497
+ });
498
+ return {
499
+ applied: false,
500
+ reason: 'unknown-type'
501
+ };
502
+ }
503
+ }
504
+ export async function applyAccessDefaultSetFrame(params) {
505
+ const { graphStore, frame, logger, groupID } = params;
506
+ // Authenticate via the signed token: a forged or unsigned token verifies to
507
+ // null and is dropped. Every authoritative field comes from the verified
508
+ // payload, never the surrounding plaintext frame.
509
+ const verified = await verifyAccessDefault(frame.auth);
510
+ if (verified == null || !isSetClaim(verified)) {
511
+ logger?.warn('access-default:set dropped: token verification failed', {
512
+ groupID
513
+ });
514
+ return false;
515
+ }
516
+ // Sender-bound: a member states only its OWN sharing policy. The row owner is
517
+ // the authenticated issuer; a frame claiming a foreign owner is dropped. The
518
+ // issuer is already normalized, so the plaintext owner must be too — an
519
+ // equivalent DID in another form is the same principal.
520
+ if (normalizeDID(frame.ownerDID) !== verified.issuer) {
521
+ logger?.warn('access-default:set dropped: owner is not the authenticated author', {
522
+ groupID
523
+ });
524
+ return false;
525
+ }
526
+ return await applyVerifiedAccessDefaultSet(graphStore, verified);
527
+ }
528
+ /**
529
+ * Apply one signed access-default set token, with no surrounding frame.
530
+ *
531
+ * What a catch-up reply carries: a token and nothing else, so there is no
532
+ * plaintext mirror to cross-check and none is needed — the frame's mirror
533
+ * fields exist for a receiver rebuilding what was signed, and the authoritative
534
+ * values were always the claim's. Every other rule is the live path's: the owner
535
+ * is the authenticated issuer, and the write happens only if strictly newer than
536
+ * what is stored.
537
+ */ export async function applyAccessDefaultSetToken(params) {
538
+ const verified = await verifyAccessDefault(params.token);
539
+ if (verified == null || !isSetClaim(verified)) {
540
+ params.logger?.warn('access-default catch-up entry dropped: verification failed', {
541
+ groupID: params.groupID
542
+ });
543
+ return false;
544
+ }
545
+ return await applyVerifiedAccessDefaultSet(params.graphStore, verified);
546
+ }
547
+ /**
548
+ * A verified access-default that states a rule rather than withdrawing one.
549
+ * Written as a guard because the union discriminates on a NESTED field, which a
550
+ * `verified.claim.op !== 'set'` check does not narrow the parent by.
551
+ */ function isSetClaim(verified) {
552
+ return verified.claim.op === 'set';
553
+ }
554
+ async function applyVerifiedAccessDefaultSet(graphStore, verified) {
555
+ const claim = verified.claim;
556
+ // LWW: apply only when strictly newer than the stored rule's anchor. An
557
+ // unstamped stored rule (null hlc) is treated as oldest, so any stamped
558
+ // incoming rule wins.
559
+ const storedHLC = await graphStore.getUserModelAccessDefaultHLC(verified.issuer, claim.modelID, claim.permissionType);
560
+ if (storedHLC != null && claim.hlc <= storedHLC) {
561
+ return false;
562
+ }
563
+ await graphStore.setUserModelAccessDefault({
564
+ ownerDID: verified.issuer,
565
+ modelID: claim.modelID,
566
+ permissionType: claim.permissionType,
567
+ // Narrowed by `isRule` at verification, which refuses any level outside the
568
+ // three the read predicate is written for.
569
+ accessLevel: claim.rule.level,
570
+ allowedDIDs: claim.rule.allowedDIDs,
571
+ allowedCircles: claim.rule.allowedCircles,
572
+ allowedGroups: claim.rule.allowedGroups,
573
+ hlc: claim.hlc
574
+ });
575
+ return true;
576
+ }
577
+ /**
578
+ * Adopt the control state a handle already holds: merge the HLCs its entries
579
+ * carry into this device's clock, then rebuild every projection from them.
580
+ *
581
+ * For a peer whose handle was filled by something other than a landed commit —
582
+ * a joiner, whose Welcome folded the group's whole ledger and checked it against
583
+ * the authenticated head before this device had any say. Those entries never
584
+ * passed through the receive path, so nothing else has observed their clocks or
585
+ * projected them.
586
+ *
587
+ * The merge keeps this device's later writes sorting after what the group did
588
+ * before it arrived. It decides no authority — that reads the enacted position —
589
+ * but the LWW comparisons that are not ledger folds (removal timestamps,
590
+ * access-default rules) still depend on a clock that has seen the group's.
591
+ */ export async function adoptHandleLedgerState(params, groupID) {
592
+ const { hlc, logger } = params;
593
+ if (hlc != null && params.getGroupLedger != null) {
594
+ let ledger;
595
+ try {
596
+ ledger = await params.getGroupLedger(groupID);
597
+ } catch (error) {
598
+ // The reproject below reads the ledger through the same resolver and
599
+ // defers on its own when it cannot; losing the merge is not worth failing
600
+ // a join over.
601
+ logger?.warn('ledger clock merge skipped: ledger unreadable', {
602
+ groupID,
603
+ error
604
+ });
605
+ ledger = [];
606
+ }
607
+ for (const { verified } of ledger){
608
+ // An entry `@kumiai/mls` minted itself carries no `ord` — it orders by the
609
+ // epoch chain and never stamps kubun's clock. Nothing to merge.
610
+ const ord = entryOrd(verified.entry);
611
+ if (ord == null) {
612
+ continue;
613
+ }
614
+ try {
615
+ hlc.receive(HLC.parse(ord));
616
+ } catch (error) {
617
+ logger?.warn('ledger entry HLC merge failed', {
618
+ groupID,
619
+ hlc: ord,
620
+ error
621
+ });
622
+ }
623
+ }
624
+ }
625
+ await reprojectControlProjections(params, groupID);
626
+ }
627
+ /**
628
+ * Rebuild every control projection from the group's whole ledger.
629
+ *
630
+ * A batch may carry any mix of entry types, so all three rebuild unconditionally
631
+ * rather than the caller working out which were touched; reprojecting a type the
632
+ * batch didn't move is a no-op that re-confirms the current rows.
633
+ */ async function reprojectControlProjections(params, groupID) {
634
+ const { p2pStore, logger } = params;
635
+ const resolution = await resolveGroupAnchor(params, groupID);
636
+ if (resolution.status !== 'anchored') {
637
+ logger?.debug('ledger batch applied; projection deferred', {
638
+ groupID,
639
+ reason: resolution.status === 'unknown' ? 'anchor unavailable' : 'no genesis anchor'
640
+ });
641
+ return;
642
+ }
643
+ const { anchor, ledger } = resolution;
644
+ // Sequential, and in this order: the circle rebuilds upsert each row's `open`
645
+ // from the circle fold, then the settings rebuild overwrites `open` from the
646
+ // authoritative open set. Running them concurrently, or settings first, lets
647
+ // the circle upsert win and delists nothing.
648
+ const rebuild = async (store)=>{
649
+ await reprojectCircleDefs({
650
+ store,
651
+ groupID,
652
+ anchor,
653
+ ledger,
654
+ logger
655
+ });
656
+ await reprojectCircleMembers({
657
+ store,
658
+ groupID,
659
+ anchor,
660
+ ledger,
661
+ logger
662
+ });
663
+ await reprojectGroupSettings({
664
+ store,
665
+ groupID,
666
+ anchor,
667
+ ledger,
668
+ logger
669
+ });
670
+ };
671
+ if (params.withProjectionTransaction != null) {
672
+ await params.withProjectionTransaction(rebuild);
673
+ } else {
674
+ await rebuild(p2pStore);
675
+ }
676
+ }
677
+ /**
678
+ * Adopt a whole ledger gathered from one untrusted responder.
679
+ *
680
+ * Signatures do NOT cover omission or reordering — a responder can hand back
681
+ * genuine tokens with a demotion missing — so the list is installed against the
682
+ * authenticated head FIRST and rejected whole on mismatch, before anything is
683
+ * projected. Roles are reconciled against the roster the install folded, not the
684
+ * ledger, because nothing later revisits a stale `admin` row.
685
+ *
686
+ * Throws whatever the install throws — a rejected ledger changes nothing.
687
+ */ export async function bootstrapGroupLedger(params, tokens, groupID) {
688
+ const roster = await params.installGroupLedger(groupID, tokens);
689
+ await reprojectControlProjections(params, groupID);
690
+ const members = await params.p2pStore.listGroupMembers(groupID);
691
+ await mirrorRosterRoles({
692
+ store: params.p2pStore,
693
+ groupID,
694
+ roster,
695
+ memberDIDs: members.map((member)=>member.member_did),
696
+ members
697
+ });
698
+ return {
699
+ applied: tokens.length,
700
+ rejected: 0
701
+ };
702
+ }