@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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +12 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +122 -38
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +7 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +69 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +56 -0
- package/lib/groups/group-health-monitor.js +139 -0
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +29 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +4 -8
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +28 -41
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +29 -13
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +8 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +471 -2
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +73 -0
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +12 -0
- package/lib/sync/errors.js +19 -0
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.d.ts +3 -22
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +19 -0
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +8 -1
- package/lib/sync/peer-registry.js +107 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +5 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +41 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +21 -0
- package/package.json +48 -41
- package/lib/groups/broadcast-service.d.ts +0 -189
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -32
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -67
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -33
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -127
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
package/lib/groups/broadcast.js
CHANGED
|
@@ -1 +1,627 @@
|
|
|
1
|
-
import{HLC
|
|
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
|
+
}
|