@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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +55 -0
- package/lib/groups/admin-roster.js +69 -0
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +155 -1
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -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 +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -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 +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +704 -22
- package/lib/types.js +1 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.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 -89
- 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 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- 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,702 @@
|
|
|
1
|
-
import{
|
|
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
|
+
}
|