@kubun/plugin-p2p 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/context/group.js +1073 -746
- package/lib/context/hub.js +2 -1
- package/lib/context/join.js +50 -15
- 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 +234 -1
- package/lib/context/types.d.ts +56 -20
- package/lib/context/types.js +23 -3
- 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 +42 -117
- package/lib/groups/admin-roster.js +53 -168
- 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.d.ts +199 -81
- package/lib/groups/broadcast.js +443 -368
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +32 -64
- package/lib/groups/circle-projection.js +95 -181
- package/lib/groups/circle-reducers.d.ts +71 -110
- package/lib/groups/circle-reducers.js +57 -127
- 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 +14 -1
- package/lib/groups/group-anchor.d.ts +18 -57
- package/lib/groups/group-anchor.js +15 -100
- package/lib/groups/group-crypto.d.ts +36 -13
- package/lib/groups/group-crypto.js +124 -50
- package/lib/groups/group-handle-registry.d.ts +106 -36
- package/lib/groups/group-handle-registry.js +351 -61
- package/lib/groups/group-handlers.d.ts +5 -4
- package/lib/groups/group-handlers.js +155 -29
- package/lib/groups/group-health-monitor.d.ts +1 -1
- package/lib/groups/group-health-monitor.js +1 -1
- package/lib/groups/group-mls.d.ts +35 -4
- package/lib/groups/group-mls.js +594 -62
- package/lib/groups/group-peer-manager.d.ts +212 -19
- package/lib/groups/group-peer-manager.js +1061 -69
- package/lib/groups/group-protocols.d.ts +485 -28
- package/lib/groups/group-protocols.js +289 -15
- package/lib/groups/invite-payload.d.ts +48 -0
- 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 +60 -0
- package/lib/groups/join-utils.js +262 -0
- package/lib/groups/ledger-affected-events.d.ts +11 -6
- package/lib/groups/ledger-affected-events.js +15 -26
- 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 +202 -19
- package/lib/groups/manager.js +623 -106
- package/lib/groups/mls-codec.d.ts +27 -6
- package/lib/groups/mls-codec.js +30 -15
- package/lib/groups/mls-encryptor.js +9 -6
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +12 -4
- package/lib/groups/mls-receive-errors.d.ts +0 -2
- package/lib/groups/mls-receive-errors.js +3 -6
- package/lib/groups/mls-state.d.ts +5 -3
- package/lib/groups/mls-state.js +27 -4
- 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 +4 -3
- package/lib/groups/store-received-grant.js +80 -35
- package/lib/groups/store-received-revocation.d.ts +16 -13
- package/lib/groups/store-received-revocation.js +23 -22
- package/lib/hub/http-client.d.ts +10 -27
- package/lib/hub/http-client.js +2 -19
- package/lib/hub/hub-like.d.ts +57 -6
- package/lib/hub/hub-like.js +539 -52
- 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 +51 -94
- 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 +71 -14
- package/lib/hub/wiring.js +80 -23
- package/lib/index.d.ts +48 -13
- package/lib/index.js +257 -86
- 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 +213 -0
- package/lib/protocol.js +268 -0
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +805 -131
- 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 +57 -3
- package/lib/sync/authorize.js +45 -4
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +2 -3
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +55 -39
- package/lib/sync/errors.d.ts +9 -0
- package/lib/sync/errors.js +10 -0
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +13 -19
- package/lib/sync/handlers.js +54 -14
- package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
- package/lib/sync/hub-tunnel-sync-listener.js +126 -37
- package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
- package/lib/sync/hub-tunnel-sync-provider.js +68 -19
- package/lib/sync/merkle-channel.d.ts +9 -1
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/peer-registry.d.ts +14 -10
- package/lib/sync/peer-registry.js +0 -25
- package/lib/sync/sync-client.d.ts +11 -1
- package/lib/sync/sync-client.js +72 -10
- package/lib/sync/sync-manager.d.ts +22 -4
- package/lib/sync/sync-manager.js +39 -10
- 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 +699 -28
- package/lib/types.js +0 -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 +37 -3
- package/package.json +59 -52
- package/lib/groups/ledger-entry.d.ts +0 -37
- package/lib/groups/ledger-entry.js +0 -56
- package/lib/groups/ledger-fold.d.ts +0 -56
- package/lib/groups/ledger-fold.js +0 -53
- package/lib/hub/did-observing-transport.d.ts +0 -75
- package/lib/hub/did-observing-transport.js +0 -126
- package/lib/hub/errors.d.ts +0 -24
- package/lib/hub/errors.js +0 -33
package/lib/groups/broadcast.js
CHANGED
|
@@ -1,38 +1,16 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
1
2
|
import { HLC } from '@kubun/hlc';
|
|
3
|
+
import { toISO } from '../context/types.js';
|
|
2
4
|
import { evaluateAndForward, rethrowIfProgrammerError } from '../sync/forwarder.js';
|
|
3
5
|
import { createReceiveAccessGate } from '../sync/receive-access-gate.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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';
|
|
8
12
|
import { storeReceivedGrant } from './store-received-grant.js';
|
|
9
13
|
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
14
|
/**
|
|
37
15
|
* Process a received broadcast message, applying it to the local database
|
|
38
16
|
* with HLC-based last-write-wins conflict resolution.
|
|
@@ -53,17 +31,196 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
|
53
31
|
const { p2pStore, graphStore } = params;
|
|
54
32
|
switch(message.type){
|
|
55
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
|
+
}
|
|
56
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
|
+
}
|
|
57
138
|
case 'catalog:delete':
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|
|
67
224
|
case 'delegation:share':
|
|
68
225
|
{
|
|
69
226
|
// Co-members relay the same broadcast but only the addressed device
|
|
@@ -108,251 +265,74 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
|
108
265
|
applied: stored
|
|
109
266
|
};
|
|
110
267
|
}
|
|
111
|
-
case '
|
|
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':
|
|
112
290
|
{
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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', {
|
|
119
301
|
groupID
|
|
120
302
|
});
|
|
121
303
|
return {
|
|
122
304
|
applied: false
|
|
123
305
|
};
|
|
124
306
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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) {
|
|
138
312
|
return {
|
|
139
313
|
applied: false
|
|
140
314
|
};
|
|
141
315
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
});
|
|
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) {
|
|
164
324
|
return {
|
|
165
|
-
applied:
|
|
325
|
+
applied: false
|
|
166
326
|
};
|
|
167
327
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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()
|
|
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
|
|
356
336
|
});
|
|
357
337
|
return {
|
|
358
338
|
applied: true
|
|
@@ -361,7 +341,17 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
|
361
341
|
case 'mutation:apply':
|
|
362
342
|
{
|
|
363
343
|
// Loopback drop: never re-apply our own broadcast.
|
|
364
|
-
|
|
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) {
|
|
365
355
|
return {
|
|
366
356
|
applied: false
|
|
367
357
|
};
|
|
@@ -497,14 +487,6 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
|
497
487
|
}
|
|
498
488
|
return result;
|
|
499
489
|
}
|
|
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
490
|
default:
|
|
509
491
|
// Version skew: a newer peer shipped a `type` outside our union. Return an
|
|
510
492
|
// explicit not-applied outcome (never a silent `undefined`) so no consumer
|
|
@@ -519,109 +501,202 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
|
519
501
|
};
|
|
520
502
|
}
|
|
521
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
|
+
}
|
|
522
547
|
/**
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
|
|
527
|
-
|
|
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.
|
|
528
580
|
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
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.
|
|
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.
|
|
537
586
|
*
|
|
538
|
-
*
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
552
604
|
});
|
|
553
|
-
|
|
605
|
+
ledger = [];
|
|
554
606
|
}
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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) {
|
|
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
|
+
}
|
|
571
614
|
try {
|
|
572
|
-
|
|
615
|
+
hlc.receive(HLC.parse(ord));
|
|
573
616
|
} catch (error) {
|
|
574
|
-
logger?.warn('ledger
|
|
617
|
+
logger?.warn('ledger entry HLC merge failed', {
|
|
575
618
|
groupID,
|
|
576
|
-
hlc:
|
|
619
|
+
hlc: ord,
|
|
577
620
|
error
|
|
578
621
|
});
|
|
579
622
|
}
|
|
580
623
|
}
|
|
581
624
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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);
|
|
622
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
|
+
});
|
|
623
698
|
return {
|
|
624
|
-
applied,
|
|
625
|
-
rejected
|
|
699
|
+
applied: tokens.length,
|
|
700
|
+
rejected: 0
|
|
626
701
|
};
|
|
627
702
|
}
|