@kubun/plugin-p2p 0.10.0 → 0.11.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 (136) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +12 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +122 -38
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +7 -2
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +69 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +29 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +4 -8
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,627 @@
1
- import{HLC as e}from"@kubun/hlc";import{evaluateAndForward as a,rethrowIfProgrammerError as r}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 i}from"./store-received-revocation.js";function c(a,r){return e.compare(e.parse(a),e.parse(r))>0}export async function processBroadcast(e,o,d){let{p2pStore:n,graphStore:p}=e;switch(o.type){case"circle:create":{let e=await n.getCircle(o.circle.id);if(null!=e){if(!c(o.circle.hlc,e.hlc))return{applied:!1};await n.updateCircle(o.circle.id,{name:o.circle.name,description:o.circle.description,catalog_ids:o.circle.catalogIDs,hlc:o.circle.hlc});let a=await n.getCircle(o.circle.id);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}await n.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 a=await n.getCircle(o.circle.id);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}case"circle:update":{let e=await n.getCircle(o.circleID);if(null==e||!c(o.update.hlc,e.hlc))return{applied:!1};await n.updateCircle(o.circleID,{name:o.update.name,description:o.update.description,catalog_ids:o.update.catalogIDs,hlc:o.update.hlc});let a=await n.getCircle(o.circleID);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}case"circle:delete":{let e=await n.getCircle(o.circleID);if(null==e||!c(o.hlc,e.hlc))return{applied:!1};return await n.deleteCircle(o.circleID),{applied:!0,affected:{kind:"circle",row:e}}}case"member:add":{await n.addCircleMember({circle_id:o.member.circleID,member_did:o.member.memberDID,role:o.member.role,hlc:o.member.hlc});let e=await n.getCircleMember(o.member.circleID,o.member.memberDID);return null!=e?{applied:!0,affected:{kind:"member",row:e}}:{applied:!0}}case"member:remove":{let e=await n.getCircleMember(o.circleID,o.memberDID);return await n.removeCircleMember(o.circleID,o.memberDID),null!=e?{applied:!0,affected:{kind:"member",row:e}}:{applied:!0}}case"catalog:create":{let e=await p.getCatalog(o.catalog.id);if(null!=e){if(!c(o.catalog.hlc,e.hlc))return{applied:!1};await p.updateCatalog(o.catalog.id,{name:o.catalog.name,description:o.catalog.description,filter_criteria:o.catalog.filterCriteria,hlc:o.catalog.hlc});let a=await p.getCatalog(o.catalog.id);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}await p.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 a=await p.getCatalog(o.catalog.id);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}case"catalog:update":{let e=await p.getCatalog(o.catalogID);if(null==e||!c(o.update.hlc,e.hlc))return{applied:!1};await p.updateCatalog(o.catalogID,{name:o.update.name,description:o.update.description,filter_criteria:o.update.filterCriteria,hlc:o.update.hlc});let a=await p.getCatalog(o.catalogID);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}case"catalog:delete":{let e=await p.getCatalog(o.catalogID);if(null==e||!c(o.hlc,e.hlc))return{applied:!1};return await p.deleteCatalog(o.catalogID),{applied:!0,affected:{kind:"catalog",row:e}}}case"delegation:share":return{applied:await t({p2pStore:n,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 i({p2pStore:n,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 n.getGroup(o.groupID);if(null==e||!c(o.update.hlc,e.hlc))return{applied:!1};await n.updateGroup(o.groupID,{name:o.update.name,description:o.update.description,hlc:o.update.hlc});let a=await n.getGroup(o.groupID);return null!=a?{applied:!0,affected:{kind:"group",row:a}}:{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,a,r)=>p.getUserModelAccessDefault(e,a,r),isMemberOfAnyCircle:(e,a)=>n.isMemberOfAnyCircle(e,a),isMemberOfAnyGroup:(e,a)=>n.isMemberOfAnyGroup(e,a),getModelInterfaces:e=>p.getModelInterfaces(e)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}}):void 0,i=null!=e.forwarding&&!1!==e.forwarding&&null!=e.scheduleBroadcast,c=!1,u=0,g=0,s=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 m=!1;try{let e=await n.recordBroadcast({docID:l.docID,version:l.version,mlsGroupID:d});m=!0,!e&&s++}catch(a){r(a),e.logger?.warn("mutation:apply recordBroadcast failed, skipping forward",{docID:l.docID,version:l.version,groupID:d,error:a})}if(c=!0,!m)continue;if(i&&null!=e.forwarding&&null!=e.scheduleBroadcast&&null!=o.document)try{let r=await a({entry:l,postState:o.document,sourceGroupID:d,selfDID:e.selfDID,p2pStore:n,graphStore:p,defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"},forwarding:e.forwarding,scheduleBroadcast:e.scheduleBroadcast,logger:e.logger});g+=r.forwarded}catch(a){r(a),e.logger?.warn("mutation:apply forwarding failed, skipping entry",{docID:l.docID,version:l.version,groupID:d,error:a})}}catch(a){r(a),e.logger?.warn("mutation:apply entry failed, skipping",{docID:l.docID,version:l.version,modelID:l.modelID,groupID:d,error:a})}let m={applied:c,dropped:u,duplicates:s};return i&&(m.forwarded=g),m}}}
1
+ import { HLC } from '@kubun/hlc';
2
+ import { evaluateAndForward, rethrowIfProgrammerError } from '../sync/forwarder.js';
3
+ import { createReceiveAccessGate } from '../sync/receive-access-gate.js';
4
+ import { ADMIN_ROLE_ENTRY_TYPE, ADMIN_ROSTER_PROJECTION, applyAdminEntryIncremental, reprojectAdminRoster } from './admin-roster.js';
5
+ import { applyCircleDefEntryIncremental, applyCircleMemberEntryIncremental, applyGroupSettingsEntryIncremental, CIRCLE_DEF_PROJECTION, CIRCLE_MEMBER_PROJECTION, GROUP_SETTINGS_PROJECTION, reprojectCircleDefs, reprojectCircleMembers, reprojectGroupSettings } from './circle-projection.js';
6
+ import { CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, decodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
7
+ import { ledgerEntryDigest, verifyLedgerEntry } from './ledger-entry.js';
8
+ import { storeReceivedGrant } from './store-received-grant.js';
9
+ import { storeReceivedRevocation } from './store-received-revocation.js';
10
+ /**
11
+ * Resolve a group's anchor without ever throwing into the receive loop. A
12
+ * missing `getGroupAnchor` (callers that never project) is treated as
13
+ * `unanchored`; a read that throws is `unknown` (fail closed), distinct from a
14
+ * read that succeeds with no anchor (`unanchored`).
15
+ */ async function resolveGroupAnchor(params, groupID) {
16
+ if (params.getGroupAnchor == null) {
17
+ return {
18
+ status: 'unanchored'
19
+ };
20
+ }
21
+ let anchor;
22
+ try {
23
+ anchor = await params.getGroupAnchor(groupID);
24
+ } catch {
25
+ return {
26
+ status: 'unknown'
27
+ };
28
+ }
29
+ return anchor != null ? {
30
+ status: 'anchored',
31
+ anchor
32
+ } : {
33
+ status: 'unanchored'
34
+ };
35
+ }
36
+ /**
37
+ * Process a received broadcast message, applying it to the local database
38
+ * with HLC-based last-write-wins conflict resolution.
39
+ *
40
+ * Returns `{ applied, affected? }`. `applied` is true when the message
41
+ * mutated local state. `affected` carries the row snapshot needed by
42
+ * downstream event mapping: after-state for create / update, before-state
43
+ * for delete / remove.
44
+ *
45
+ * Readback note: for create / update the post-apply row is fetched with a
46
+ * separate `get*` call rather than a txn-scoped read. If an unrelated
47
+ * concurrent write to the same row lands between apply and readback, the
48
+ * emitted `affected.row` reflects that later write. `BroadcastService`
49
+ * serializes receives, so the realistic exposure is only a same-row local
50
+ * mutation between apply and readback, which is acceptable for event
51
+ * payload fidelity.
52
+ */ export async function processBroadcast(params, message, groupID) {
53
+ const { p2pStore, graphStore } = params;
54
+ switch(message.type){
55
+ case 'catalog:create':
56
+ case 'catalog:update':
57
+ case 'catalog:delete':
58
+ // No authorized broadcast path exists for catalog control today, so a
59
+ // received catalog control frame cannot be attributed to an admin author.
60
+ // Drop it rather than apply an unauthenticated mutation.
61
+ params.logger?.warn('dropping catalog control broadcast: no authorized path', {
62
+ type: message.type
63
+ });
64
+ return {
65
+ applied: false
66
+ };
67
+ case 'delegation:share':
68
+ {
69
+ // Co-members relay the same broadcast but only the addressed device
70
+ // stores a held row. The grantor stamps the HLC, mirroring its own
71
+ // issued row for LWW consistency across devices.
72
+ const stored = await storeReceivedGrant({
73
+ delegationStore: params.delegationStore,
74
+ p2pStore,
75
+ token: message.token,
76
+ groupID: message.groupID,
77
+ hlc: message.hlc,
78
+ selfDID: params.selfDID,
79
+ ...params.emitter != null ? {
80
+ emitter: params.emitter
81
+ } : {},
82
+ ...params.logger != null ? {
83
+ logger: params.logger
84
+ } : {}
85
+ });
86
+ return {
87
+ applied: stored
88
+ };
89
+ }
90
+ case 'delegation:revoke':
91
+ {
92
+ const stored = await storeReceivedRevocation({
93
+ delegationStore: params.delegationStore,
94
+ token: message.token,
95
+ groupID: message.groupID,
96
+ hlc: message.hlc,
97
+ ...params.selfDID != null ? {
98
+ selfDID: params.selfDID
99
+ } : {},
100
+ ...params.emitter != null ? {
101
+ emitter: params.emitter
102
+ } : {},
103
+ ...params.logger != null ? {
104
+ logger: params.logger
105
+ } : {}
106
+ });
107
+ return {
108
+ applied: stored
109
+ };
110
+ }
111
+ case 'ledger:entry':
112
+ {
113
+ // The token is self-authenticating, so verify it directly instead of the
114
+ // admin gate. A forged, unsigned, or garbage token verifies to null and
115
+ // is dropped — store untouched, never thrown.
116
+ const verified = await verifyLedgerEntry(message.token);
117
+ if (verified == null) {
118
+ params.logger?.warn('ledger entry dropped: token verification failed', {
119
+ groupID
120
+ });
121
+ return {
122
+ applied: false
123
+ };
124
+ }
125
+ const entryID = ledgerEntryDigest(message.token);
126
+ const appended = await p2pStore.appendLedgerEntry({
127
+ group_id: groupID,
128
+ entry_id: entryID,
129
+ type: verified.entry.type,
130
+ subject: verified.entry.subject,
131
+ value: verified.entry.value,
132
+ hlc: verified.entry.hlc,
133
+ issuer_did: verified.issuer,
134
+ signed_token: message.token
135
+ });
136
+ if (!appended) {
137
+ // A duplicate (already-stored token): no clock advance, no re-projection.
138
+ return {
139
+ applied: false
140
+ };
141
+ }
142
+ if (params.hlc != null) {
143
+ try {
144
+ params.hlc.receive(HLC.parse(verified.entry.hlc));
145
+ } catch (error) {
146
+ params.logger?.warn('ledger entry HLC merge failed', {
147
+ groupID,
148
+ hlc: verified.entry.hlc,
149
+ error
150
+ });
151
+ }
152
+ }
153
+ // The entry is durable now; the role overlay can only be projected once the
154
+ // authenticated epoch-0 root is known. Defer projection (without dropping
155
+ // the entry) when there is no anchor or it can't be read right now — a later
156
+ // entry or catch-up reproject heals the overlay.
157
+ const resolution = await resolveGroupAnchor(params, groupID);
158
+ if (resolution.status !== 'anchored') {
159
+ params.logger?.debug('ledger entry appended; role projection deferred', {
160
+ groupID,
161
+ type: verified.entry.type,
162
+ reason: resolution.status === 'unknown' ? 'anchor unavailable' : 'no genesis anchor'
163
+ });
164
+ return {
165
+ applied: true
166
+ };
167
+ }
168
+ const anchor = resolution.anchor;
169
+ // Local-event descriptor for this applied entry — populated by the
170
+ // projected control branches below so `forwardRemoteBroadcast` can emit the
171
+ // same local event a producer fires, for a remote-driven change. The
172
+ // pre-state each branch reads BEFORE reprojecting decides create-vs-change /
173
+ // add-vs-remove; the post-state is the projected row the event carries.
174
+ let ledgerAffected;
175
+ // Route by entry type to the matching projection. For each: an absent
176
+ // watermark (first projection) or an out-of-order / catch-up entry at or
177
+ // before what we've already folded means the cached projection is no
178
+ // longer a correct fold-through-this-entry, so rebuild authoritatively
179
+ // from the whole ledger. In-order means the entry is strictly newer than
180
+ // everything projected, so the current projection is exactly the
181
+ // fold-so-far: apply it incrementally and advance the watermark (the
182
+ // entry is accounted for even when its issuer lacked authority and the
183
+ // projection was unchanged). An unprojected type falls through; the entry
184
+ // is already durable.
185
+ switch(verified.entry.type){
186
+ case ADMIN_ROLE_ENTRY_TYPE:
187
+ {
188
+ const adminEntry = verified;
189
+ const watermark = await p2pStore.getProjectionWatermark(groupID, ADMIN_ROSTER_PROJECTION);
190
+ if (watermark == null || adminEntry.entry.hlc <= watermark) {
191
+ await reprojectAdminRoster({
192
+ store: p2pStore,
193
+ groupID,
194
+ anchor,
195
+ logger: params.logger
196
+ });
197
+ } else {
198
+ await applyAdminEntryIncremental({
199
+ store: p2pStore,
200
+ groupID,
201
+ verified: adminEntry,
202
+ logger: params.logger
203
+ });
204
+ await p2pStore.setProjectionWatermark(groupID, ADMIN_ROSTER_PROJECTION, adminEntry.entry.hlc);
205
+ }
206
+ break;
207
+ }
208
+ case CIRCLE_DEF_ENTRY_TYPE:
209
+ {
210
+ const defEntry = verified;
211
+ // Pre-state (including a tombstoned row) read before reprojecting: an
212
+ // active row before this entry means the circle existed (a change, not a
213
+ // create); its `catalog_ids` is the baseline for catalogs-changed.
214
+ const before = await p2pStore.getCircle(defEntry.entry.subject, {
215
+ includeRemoved: true
216
+ });
217
+ const preActive = before != null && before.removed_at_hlc == null;
218
+ const watermark = await p2pStore.getProjectionWatermark(groupID, CIRCLE_DEF_PROJECTION);
219
+ if (watermark == null || defEntry.entry.hlc <= watermark) {
220
+ await reprojectCircleDefs({
221
+ store: p2pStore,
222
+ groupID,
223
+ anchor,
224
+ logger: params.logger
225
+ });
226
+ } else {
227
+ await applyCircleDefEntryIncremental({
228
+ store: p2pStore,
229
+ groupID,
230
+ anchor,
231
+ verified: defEntry,
232
+ logger: params.logger
233
+ });
234
+ await p2pStore.setProjectionWatermark(groupID, CIRCLE_DEF_PROJECTION, defEntry.entry.hlc);
235
+ }
236
+ // Post-state decides which local event to emit. A now-tombstoned row maps
237
+ // to a delete; an active row maps to create (no active pre-state) or
238
+ // change (active pre-state). A null post-state (a non-admin entry the
239
+ // projection dropped, or a tombstone with no prior row) emits nothing.
240
+ const after = await p2pStore.getCircle(defEntry.entry.subject, {
241
+ includeRemoved: true
242
+ });
243
+ if (after != null) {
244
+ if (after.removed_at_hlc != null) {
245
+ ledgerAffected = {
246
+ entry: 'circle.def.removed',
247
+ row: after
248
+ };
249
+ } else {
250
+ const catalogsChanged = preActive && before != null && JSON.stringify(before.catalog_ids) !== JSON.stringify(after.catalog_ids);
251
+ ledgerAffected = {
252
+ entry: 'circle.def',
253
+ row: after,
254
+ preExisted: preActive,
255
+ catalogsChanged
256
+ };
257
+ }
258
+ }
259
+ break;
260
+ }
261
+ case CIRCLE_MEMBER_ENTRY_TYPE:
262
+ {
263
+ const memberEntry = verified;
264
+ const decoded = decodeCircleMemberSubject(memberEntry.entry.subject);
265
+ const watermark = await p2pStore.getProjectionWatermark(groupID, CIRCLE_MEMBER_PROJECTION);
266
+ if (watermark == null || memberEntry.entry.hlc <= watermark) {
267
+ await reprojectCircleMembers({
268
+ store: p2pStore,
269
+ groupID,
270
+ anchor,
271
+ logger: params.logger
272
+ });
273
+ } else {
274
+ await applyCircleMemberEntryIncremental({
275
+ store: p2pStore,
276
+ groupID,
277
+ anchor,
278
+ verified: memberEntry,
279
+ logger: params.logger
280
+ });
281
+ await p2pStore.setProjectionWatermark(groupID, CIRCLE_MEMBER_PROJECTION, memberEntry.entry.hlc);
282
+ }
283
+ // Post-state decides the local event: a now-tombstoned row maps to a
284
+ // remove, an active row to an add (an add and a role-change both map to
285
+ // `circleMemberAdded` — there is no distinct role-change event). A null
286
+ // post-state (a malformed subject, a non-admin entry the projection
287
+ // dropped, or a tombstone with no prior row) emits nothing.
288
+ if (decoded != null) {
289
+ const after = await p2pStore.getCircleMember(decoded.circleID, decoded.memberDID);
290
+ if (after != null) {
291
+ ledgerAffected = after.removed_at_hlc != null ? {
292
+ entry: 'circle.member.removed',
293
+ row: after
294
+ } : {
295
+ entry: 'circle.member',
296
+ row: after
297
+ };
298
+ }
299
+ }
300
+ break;
301
+ }
302
+ case GROUP_SETTINGS_ENTRY_TYPE:
303
+ {
304
+ const settingsEntry = verified;
305
+ const watermark = await p2pStore.getProjectionWatermark(groupID, GROUP_SETTINGS_PROJECTION);
306
+ if (watermark == null || settingsEntry.entry.hlc <= watermark) {
307
+ await reprojectGroupSettings({
308
+ store: p2pStore,
309
+ groupID,
310
+ anchor,
311
+ logger: params.logger
312
+ });
313
+ } else {
314
+ await applyGroupSettingsEntryIncremental({
315
+ store: p2pStore,
316
+ groupID,
317
+ anchor,
318
+ verified: settingsEntry,
319
+ logger: params.logger
320
+ });
321
+ await p2pStore.setProjectionWatermark(groupID, GROUP_SETTINGS_PROJECTION, settingsEntry.entry.hlc);
322
+ }
323
+ // Group settings are partial-merge with no tombstone and no
324
+ // create-vs-change distinction — the producer always emits
325
+ // `groupDataChanged` — so the post-state group row alone is the event.
326
+ const after = await p2pStore.getGroup(groupID);
327
+ if (after != null) {
328
+ ledgerAffected = {
329
+ entry: 'group.settings',
330
+ row: after
331
+ };
332
+ }
333
+ break;
334
+ }
335
+ }
336
+ return ledgerAffected != null ? {
337
+ applied: true,
338
+ affected: {
339
+ kind: 'ledger',
340
+ ledger: ledgerAffected
341
+ }
342
+ } : {
343
+ applied: true
344
+ };
345
+ }
346
+ case 'group:leaveRequest':
347
+ {
348
+ // Advisory notification only — surface it for admin apps to act on. No
349
+ // store mutation: this is not an authoritative removal (a forged request
350
+ // must not evict anyone), and MLS forbids self-removal anyway.
351
+ await params.emitter?.emit('groupLeaveRequested', {
352
+ groupID: message.groupID,
353
+ memberDID: message.memberDID,
354
+ role: '',
355
+ createdAt: new Date().toISOString()
356
+ });
357
+ return {
358
+ applied: true
359
+ };
360
+ }
361
+ case 'mutation:apply':
362
+ {
363
+ // Loopback drop: never re-apply our own broadcast.
364
+ if (message.senderPeerDID === params.selfDID) {
365
+ return {
366
+ applied: false
367
+ };
368
+ }
369
+ const storeUnreadable = params.storeUnreadable ?? 'persist';
370
+ // Build the receive-time access gate per `'drop'` broadcast, as a factory
371
+ // over the apply's transaction-scoped stores so its access reads run
372
+ // inside the apply transaction (a main-connection read there deadlocks on
373
+ // single-connection SQLite). Under `'persist'` the gate is omitted —
374
+ // `applyVerifiedMutation` takes its zero-overhead no-gate path.
375
+ const accessGate = storeUnreadable === 'drop' ? ({ graphStore: txGraphStore, p2pStore: txP2PStore })=>{
376
+ if (txP2PStore == null) {
377
+ return undefined;
378
+ }
379
+ return createReceiveAccessGate({
380
+ selfDID: params.selfDID,
381
+ db: {
382
+ getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>txGraphStore.getUserModelAccessDefault(ownerDID, modelID, permissionType),
383
+ isMemberOfAnyCircle: (viewerDID, circleIDs)=>txP2PStore.isMemberOfAnyCircle(viewerDID, circleIDs),
384
+ isMemberOfAnyGroup: (viewerDID, groupIDs)=>txP2PStore.isMemberOfAnyGroup(viewerDID, groupIDs),
385
+ getModelInterfaces: (modelID)=>txGraphStore.getModelInterfaces(modelID)
386
+ },
387
+ defaultAccessLevel: params.defaultAccessLevel ?? {
388
+ read: 'only_owner',
389
+ write: 'only_owner'
390
+ }
391
+ });
392
+ } : undefined;
393
+ // Forwarding is enabled when both `forwarding` is set + truthy AND a
394
+ // `scheduleBroadcast` hook is supplied. Either alone is a no-op.
395
+ const forwardingEnabled = params.forwarding != null && params.forwarding !== false && params.scheduleBroadcast != null;
396
+ let anyApplied = false;
397
+ let dropped = 0;
398
+ let forwarded = 0;
399
+ let duplicates = 0;
400
+ for (const entry of message.entries){
401
+ try {
402
+ const applyResult = await params.graph.applyVerifiedMutation({
403
+ token: entry.mutationJWT,
404
+ origin: 'peer',
405
+ arrivalGroupID: groupID,
406
+ ...accessGate != null ? {
407
+ accessGate
408
+ } : {}
409
+ });
410
+ if (applyResult.dropped) {
411
+ dropped++;
412
+ continue;
413
+ }
414
+ // recordBroadcast wrapped in its own try/catch: if the dedup row
415
+ // insert fails (DB error), skip forwarding for THIS entry to avoid
416
+ // propagating a broadcast that has no corresponding broadcast_log
417
+ // row (next receive would re-apply + re-forward). The mutation
418
+ // itself was applied successfully so we still mark anyApplied.
419
+ // Continue the per-entry loop — don't unwind for the rest.
420
+ let recordedOK = false;
421
+ try {
422
+ const recorded = await p2pStore.recordBroadcast({
423
+ docID: entry.docID,
424
+ version: entry.version,
425
+ mlsGroupID: groupID
426
+ });
427
+ recordedOK = true;
428
+ if (!recorded) {
429
+ duplicates++;
430
+ }
431
+ } catch (error) {
432
+ rethrowIfProgrammerError(error);
433
+ params.logger?.warn('mutation:apply recordBroadcast failed, skipping forward', {
434
+ docID: entry.docID,
435
+ version: entry.version,
436
+ groupID,
437
+ error
438
+ });
439
+ }
440
+ anyApplied = true;
441
+ if (!recordedOK) {
442
+ continue;
443
+ }
444
+ // Forward AFTER recordBroadcast success so the source-group dedup
445
+ // entry is in place before any downstream peer can echo back.
446
+ if (forwardingEnabled && params.forwarding != null && params.scheduleBroadcast != null && applyResult.document != null) {
447
+ try {
448
+ const fwdResult = await evaluateAndForward({
449
+ entry,
450
+ postState: applyResult.document,
451
+ sourceGroupID: groupID,
452
+ selfDID: params.selfDID,
453
+ p2pStore,
454
+ graphStore,
455
+ defaultAccessLevel: params.defaultAccessLevel ?? {
456
+ read: 'only_owner',
457
+ write: 'only_owner'
458
+ },
459
+ forwarding: params.forwarding,
460
+ scheduleBroadcast: params.scheduleBroadcast,
461
+ logger: params.logger
462
+ });
463
+ forwarded += fwdResult.forwarded;
464
+ } catch (error) {
465
+ rethrowIfProgrammerError(error);
466
+ params.logger?.warn('mutation:apply forwarding failed, skipping entry', {
467
+ docID: entry.docID,
468
+ version: entry.version,
469
+ groupID,
470
+ error
471
+ });
472
+ }
473
+ }
474
+ } catch (error) {
475
+ rethrowIfProgrammerError(error);
476
+ // Partial-apply tolerance: log and skip this entry, continue with rest.
477
+ params.logger?.warn('mutation:apply entry failed, skipping', {
478
+ docID: entry.docID,
479
+ version: entry.version,
480
+ modelID: entry.modelID,
481
+ groupID,
482
+ error
483
+ });
484
+ }
485
+ }
486
+ // `affected` stays undefined — document mutation consumers listen to
487
+ // engine events (document:saved, engine:document:*, engine:mutation:received)
488
+ // which fire via the engine-routed apply path. The broadcast event stream
489
+ // remains control-plane-centric.
490
+ const result = {
491
+ applied: anyApplied,
492
+ dropped,
493
+ duplicates
494
+ };
495
+ if (forwardingEnabled) {
496
+ result.forwarded = forwarded;
497
+ }
498
+ return result;
499
+ }
500
+ case 'ledger-catchup:request':
501
+ case 'ledger-catchup:reply':
502
+ // Catch-up request/reply are dispatched by the broadcast service's
503
+ // catch-up path, not the LWW apply switch. Treated as no-ops here so the
504
+ // generic broadcast handler stays exhaustive.
505
+ return {
506
+ applied: false
507
+ };
508
+ default:
509
+ // Version skew: a newer peer shipped a `type` outside our union. Return an
510
+ // explicit not-applied outcome (never a silent `undefined`) so no consumer
511
+ // dereferences undefined, and log it.
512
+ params.logger?.warn('unknown broadcast message type', {
513
+ type: message.type,
514
+ groupID
515
+ });
516
+ return {
517
+ applied: false,
518
+ reason: 'unknown-type'
519
+ };
520
+ }
521
+ }
522
+ /**
523
+ * Apply a batched ledger catch-up reply from an UNTRUSTED responder. Each entry
524
+ * carries its own signed token, so the responder's identity is irrelevant: every
525
+ * token is verified independently and a forged or unsigned one is dropped without
526
+ * aborting the batch. Surviving entries are appended idempotently on the token
527
+ * digest, so a replayed or duplicated reply is a harmless no-op.
528
+ *
529
+ * A catch-up reply delivers the ledger's history out of order by construction, so
530
+ * after any new entry lands the projection is rebuilt authoritatively from the
531
+ * whole ledger (a full reproject) rather than applied incrementally. All four
532
+ * control projections — admin roster, circle defs, circle members, group
533
+ * settings — rebuild here, so a circle or settings entry delivered via catch-up
534
+ * materializes immediately rather than waiting for a later live entry. Projection
535
+ * is deferred when no genesis anchor is available yet — the entries are still
536
+ * stored durably.
537
+ *
538
+ * Never throws out of the per-token loop: one bad token must not abort the batch.
539
+ */ export async function applyLedgerCatchupReply(params, reply, groupID) {
540
+ const { p2pStore, logger } = params;
541
+ let applied = 0;
542
+ let rejected = 0;
543
+ for (const token of reply.entries){
544
+ // The untrusted check: a forged, unsigned, or garbage token verifies to
545
+ // null and is dropped here. The responder is never trusted — this is the
546
+ // sole authority over what enters the local ledger.
547
+ const verified = await verifyLedgerEntry(token);
548
+ if (verified == null) {
549
+ rejected++;
550
+ logger?.warn('ledger catch-up entry dropped: token verification failed', {
551
+ groupID
552
+ });
553
+ continue;
554
+ }
555
+ const entryID = ledgerEntryDigest(token);
556
+ const appended = await p2pStore.appendLedgerEntry({
557
+ group_id: groupID,
558
+ entry_id: entryID,
559
+ type: verified.entry.type,
560
+ subject: verified.entry.subject,
561
+ value: verified.entry.value,
562
+ hlc: verified.entry.hlc,
563
+ issuer_did: verified.issuer,
564
+ signed_token: token
565
+ });
566
+ if (!appended) {
567
+ continue;
568
+ }
569
+ applied++;
570
+ if (params.hlc != null) {
571
+ try {
572
+ params.hlc.receive(HLC.parse(verified.entry.hlc));
573
+ } catch (error) {
574
+ logger?.warn('ledger catch-up entry HLC merge failed', {
575
+ groupID,
576
+ hlc: verified.entry.hlc,
577
+ error
578
+ });
579
+ }
580
+ }
581
+ }
582
+ if (applied > 0) {
583
+ const resolution = await resolveGroupAnchor(params, groupID);
584
+ if (resolution.status === 'anchored') {
585
+ // Full authoritative rebuild — a catch-up batch delivers history out of
586
+ // order, so the cached projection can no longer be trusted as a
587
+ // fold-through-watermark. This also resets each watermark to the max folded
588
+ // HLC. A batch may carry any mix of entry types, so all control
589
+ // projections rebuild unconditionally; reprojecting a type the batch
590
+ // didn't touch is a harmless no-op that just re-confirms the current rows.
591
+ await reprojectAdminRoster({
592
+ store: p2pStore,
593
+ groupID,
594
+ anchor: resolution.anchor,
595
+ logger
596
+ });
597
+ await reprojectCircleDefs({
598
+ store: p2pStore,
599
+ groupID,
600
+ anchor: resolution.anchor,
601
+ logger
602
+ });
603
+ await reprojectCircleMembers({
604
+ store: p2pStore,
605
+ groupID,
606
+ anchor: resolution.anchor,
607
+ logger
608
+ });
609
+ await reprojectGroupSettings({
610
+ store: p2pStore,
611
+ groupID,
612
+ anchor: resolution.anchor,
613
+ logger
614
+ });
615
+ } else {
616
+ logger?.debug('ledger catch-up applied; projection deferred', {
617
+ groupID,
618
+ applied,
619
+ reason: resolution.status === 'unknown' ? 'anchor unavailable' : 'no genesis anchor'
620
+ });
621
+ }
622
+ }
623
+ return {
624
+ applied,
625
+ rejected
626
+ };
627
+ }