@kubun/plugin-p2p 0.11.0 → 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/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 +56 -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/join-utils.js
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
1
2
|
import { HLC as HLCClass } from '@kubun/hlc';
|
|
2
3
|
import { getDelegationStore } from '@kubun/store-delegation';
|
|
4
|
+
import { getGraphStore } from '@kubun/store-graph';
|
|
3
5
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
6
|
+
import { canonicalStringify } from '@sozai/codec';
|
|
4
7
|
import { toISO } from '../context/types.js';
|
|
8
|
+
import { signAccessDefaultSet } from './access-default-token.js';
|
|
9
|
+
import { adoptHandleLedgerState, applyAccessDefaultSetFrame } from './broadcast.js';
|
|
10
|
+
import { verifyCatalog } from './catalog-token.js';
|
|
5
11
|
import { storeReceivedGrant } from './store-received-grant.js';
|
|
6
12
|
import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
13
|
+
/**
|
|
14
|
+
* Whether an invite's plaintext catalog record equals the creator-signed one,
|
|
15
|
+
* field by field. Guards the invite-seed apply so a tampered plaintext field is
|
|
16
|
+
* rejected rather than silently overwritten by the authentic value. Filter
|
|
17
|
+
* criteria compare order-independently by key (token signing re-sorts keys) but
|
|
18
|
+
* remain sensitive to values and array contents, so a spoofed model list is
|
|
19
|
+
* still caught.
|
|
20
|
+
*/ function catalogRecordsMatch(a, b) {
|
|
21
|
+
return a.id === b.id && a.ownerDID === b.ownerDID && a.name === b.name && a.description === b.description && a.hlc === b.hlc && // The SAME canonicalization token signing applies, so a criteria object that
|
|
22
|
+
// only differs in key order compares equal here for the same reason it signs
|
|
23
|
+
// identically. Array element order stays significant, which filter lists need.
|
|
24
|
+
canonicalStringify(a.filterCriteria) === canonicalStringify(b.filterCriteria);
|
|
25
|
+
}
|
|
7
26
|
export async function finalizeJoinedGroup(params) {
|
|
8
27
|
const { stores, emitter, invite, logger } = params;
|
|
9
28
|
const store = await getP2PStore(stores);
|
|
@@ -102,3 +121,246 @@ export async function finalizeJoinedGroup(params) {
|
|
|
102
121
|
})));
|
|
103
122
|
return results.reduce((count, applied)=>count + (applied ? 1 : 0), 0);
|
|
104
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Gather the group state an invite seeds into the joiner: the inviter's own
|
|
126
|
+
* model access-defaults (each signed so it self-authenticates), and the catalogs
|
|
127
|
+
* the group's circles reference. Access-default rows without an HLC anchor are
|
|
128
|
+
* skipped — they carry no LWW ordering and cannot be authenticated for
|
|
129
|
+
* replication.
|
|
130
|
+
*/ export async function collectInviteSeeds(params) {
|
|
131
|
+
const { stores, identity, ownerDID, groupID, logger } = params;
|
|
132
|
+
const [p2pStore, graphStore] = await Promise.all([
|
|
133
|
+
getP2PStore(stores),
|
|
134
|
+
getGraphStore(stores)
|
|
135
|
+
]);
|
|
136
|
+
// Circles of the group being joined. Removed circles still resolve in this
|
|
137
|
+
// group's ledger, so they count as this group's.
|
|
138
|
+
const circles = await p2pStore.listCirclesByGroup(groupID, {
|
|
139
|
+
includeRemoved: true
|
|
140
|
+
});
|
|
141
|
+
const groupCircleIDs = new Set(circles.map((circle)=>circle.id));
|
|
142
|
+
// Resolve the group's catalogs (the ones its circles reference) up front: the
|
|
143
|
+
// catalog seeds are built from this same map below, and the union of their
|
|
144
|
+
// model IDs bounds which circle-less access-defaults may travel. First circle
|
|
145
|
+
// that references a catalog wins as its provenance source.
|
|
146
|
+
const catalogSourceCircle = new Map();
|
|
147
|
+
for (const circle of circles){
|
|
148
|
+
for (const catalogID of circle.catalog_ids){
|
|
149
|
+
if (!catalogSourceCircle.has(catalogID)) {
|
|
150
|
+
catalogSourceCircle.set(catalogID, circle.id);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const catalogMap = await graphStore.getCatalogs(Array.from(catalogSourceCircle.keys()));
|
|
155
|
+
const catalogModelIDs = new Set();
|
|
156
|
+
for (const catalog of catalogMap.values()){
|
|
157
|
+
for (const modelID of catalog.filter_criteria.models ?? []){
|
|
158
|
+
catalogModelIDs.add(modelID);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Seed an access-default row iff it either names a circle of the joined group,
|
|
162
|
+
// or is circle-less AND its model is in the group's catalog model set. The
|
|
163
|
+
// invite must disclose nothing beyond what the group already carries by design
|
|
164
|
+
// — its circles and its catalogs' declared model set. A row naming only foreign
|
|
165
|
+
// circles would land as an orphan and disclose unrelated groups; a circle-less
|
|
166
|
+
// row for an unrelated model (anyone / only_owner / groups-only) would leak that
|
|
167
|
+
// model's existence and the owner's posture to a joiner with no reason to know
|
|
168
|
+
// it. Catalogs scope RELEVANCE here, never authorization.
|
|
169
|
+
const rows = await graphStore.listUserModelAccessDefaults(ownerDID);
|
|
170
|
+
const accessDefaults = [];
|
|
171
|
+
for (const row of rows){
|
|
172
|
+
const circleIDs = row.allowedCircles ?? [];
|
|
173
|
+
if (circleIDs.length > 0) {
|
|
174
|
+
if (!circleIDs.some((circleID)=>groupCircleIDs.has(circleID))) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
} else if (!catalogModelIDs.has(row.modelID)) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const hlc = await graphStore.getUserModelAccessDefaultHLC(ownerDID, row.modelID, row.permissionType);
|
|
181
|
+
if (hlc == null) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const rule = {
|
|
185
|
+
level: row.level,
|
|
186
|
+
allowedDIDs: row.allowedDIDs,
|
|
187
|
+
allowedCircles: row.allowedCircles,
|
|
188
|
+
allowedGroups: row.allowedGroups
|
|
189
|
+
};
|
|
190
|
+
const auth = await signAccessDefaultSet(identity, {
|
|
191
|
+
modelID: row.modelID,
|
|
192
|
+
permissionType: row.permissionType,
|
|
193
|
+
rule,
|
|
194
|
+
hlc
|
|
195
|
+
});
|
|
196
|
+
accessDefaults.push({
|
|
197
|
+
ownerDID,
|
|
198
|
+
modelID: row.modelID,
|
|
199
|
+
permissionType: row.permissionType,
|
|
200
|
+
rule,
|
|
201
|
+
hlc,
|
|
202
|
+
auth
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const catalogs = [];
|
|
206
|
+
for (const [catalogID, sourceCircleID] of catalogSourceCircle){
|
|
207
|
+
const catalog = catalogMap.get(catalogID);
|
|
208
|
+
if (catalog == null) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// Advisory data: a catalog with no captured creator token cannot be
|
|
212
|
+
// authenticated on the joiner, so skip it rather than seed an unverifiable
|
|
213
|
+
// record. Post-change every catalog is minted with a token, so this only
|
|
214
|
+
// drops pre-existing tokenless rows.
|
|
215
|
+
if (catalog.signed_token == null) {
|
|
216
|
+
logger?.debug('invite catalog seed skipped: no signed token', {
|
|
217
|
+
catalogID,
|
|
218
|
+
groupID
|
|
219
|
+
});
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
catalogs.push({
|
|
223
|
+
catalog: {
|
|
224
|
+
id: catalog.id,
|
|
225
|
+
ownerDID: catalog.owner_did,
|
|
226
|
+
name: catalog.name,
|
|
227
|
+
description: catalog.description,
|
|
228
|
+
filterCriteria: catalog.filter_criteria,
|
|
229
|
+
hlc: catalog.hlc
|
|
230
|
+
},
|
|
231
|
+
sourceCircleID,
|
|
232
|
+
token: catalog.signed_token
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
accessDefaults,
|
|
237
|
+
catalogs
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Apply the control state a join produced, after the MLS join completes.
|
|
242
|
+
*
|
|
243
|
+
* The control ledger is NOT among the parameters, deliberately: it arrived on
|
|
244
|
+
* the MLS invite and `processWelcome` has already checked it against the
|
|
245
|
+
* authenticated head and folded it into the handle, which is what every
|
|
246
|
+
* projection reads. All that is left is to adopt what the handle now holds. A
|
|
247
|
+
* second copy carried beside it could only agree or disagree, and a joiner has
|
|
248
|
+
* no way to tell which.
|
|
249
|
+
*
|
|
250
|
+
* The rest authenticates independently: access-defaults apply sender-bound + LWW
|
|
251
|
+
* via their owner-signed tokens, and catalogs upsert as plain sync-scope records
|
|
252
|
+
* trusted from the inviter. A third-party access-default row is dropped without
|
|
253
|
+
* blocking the rest.
|
|
254
|
+
*/ export async function applyInviteControlState(params) {
|
|
255
|
+
const { stores, groupID, accessDefaults, catalogs, hlc, getGroupAnchor, getGroupLedger, logger } = params;
|
|
256
|
+
// Only a caller that can resolve the group's anchor and ledger can adopt
|
|
257
|
+
// anything: without both, the projections defer and the clock has nothing to
|
|
258
|
+
// observe, so the whole step would be a no-op that still demanded a p2p store.
|
|
259
|
+
if (getGroupAnchor != null && getGroupLedger != null) {
|
|
260
|
+
const p2pStore = await getP2PStore(stores);
|
|
261
|
+
await adoptHandleLedgerState({
|
|
262
|
+
p2pStore,
|
|
263
|
+
hlc,
|
|
264
|
+
getGroupAnchor,
|
|
265
|
+
getGroupLedger,
|
|
266
|
+
// Only the projection rebuild is transactional. The adopt reads the
|
|
267
|
+
// ledger through the registry first, and holding a transaction across
|
|
268
|
+
// that mutex would deadlock kubun's single SQLite connection.
|
|
269
|
+
withProjectionTransaction: (run)=>stores.withTransaction(async (tx)=>await run(await getP2PStore(tx))),
|
|
270
|
+
...logger != null ? {
|
|
271
|
+
logger
|
|
272
|
+
} : {}
|
|
273
|
+
}, groupID);
|
|
274
|
+
}
|
|
275
|
+
if (accessDefaults != null && accessDefaults.length > 0 || catalogs != null && catalogs.length > 0) {
|
|
276
|
+
const graphStore = await getGraphStore(stores);
|
|
277
|
+
for (const seed of accessDefaults ?? []){
|
|
278
|
+
await applyAccessDefaultSetFrame({
|
|
279
|
+
graphStore,
|
|
280
|
+
frame: seed,
|
|
281
|
+
...logger != null ? {
|
|
282
|
+
logger
|
|
283
|
+
} : {},
|
|
284
|
+
groupID
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
for (const seed of catalogs ?? []){
|
|
288
|
+
const { catalog, sourceCircleID, token } = seed;
|
|
289
|
+
// Authenticate the seed with the SAME verification the broadcast
|
|
290
|
+
// `catalog:create` receive path uses: an absent, forged, or unsigned token
|
|
291
|
+
// verifies to null and is dropped. The inviter cannot mint a token for
|
|
292
|
+
// another member's catalog, so a spoofed seed cannot pass this gate.
|
|
293
|
+
const verified = token != null ? await verifyCatalog(token) : null;
|
|
294
|
+
if (verified == null || verified.claim.op !== 'set') {
|
|
295
|
+
logger?.warn('invite catalog seed dropped: token verification failed', {
|
|
296
|
+
catalogID: catalog.id,
|
|
297
|
+
groupID
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const authentic = verified.claim.catalog;
|
|
302
|
+
// Creator-binding: the catalog's owner must be the authenticated author.
|
|
303
|
+
// `verified.issuer` is already canonicalized (did:peer:4 short form), so
|
|
304
|
+
// normalize the record owner before comparing — the stored owner DID may
|
|
305
|
+
// be the long form the signer's identity carries.
|
|
306
|
+
if (normalizeDID(authentic.ownerDID) !== verified.issuer) {
|
|
307
|
+
logger?.warn('invite catalog seed dropped: owner is not the authenticated author', {
|
|
308
|
+
catalogID: catalog.id,
|
|
309
|
+
groupID
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
// Payload-matches-record: the plaintext seed must equal the signed record,
|
|
314
|
+
// so a tampered field (name, filter, hlc, …) is rejected outright rather
|
|
315
|
+
// than silently healed to the authentic value.
|
|
316
|
+
if (!catalogRecordsMatch(catalog, authentic)) {
|
|
317
|
+
logger?.warn('invite catalog seed dropped: record does not match signed token', {
|
|
318
|
+
catalogID: catalog.id,
|
|
319
|
+
groupID
|
|
320
|
+
});
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const existing = await graphStore.getCatalog(authentic.id);
|
|
324
|
+
if (existing == null) {
|
|
325
|
+
// Seeded catalogs arrive KNOWN (not active): join surfaces them, but
|
|
326
|
+
// activation stays an explicit local decision. Provenance records the
|
|
327
|
+
// joined group + the circle that referenced the catalog. The verified
|
|
328
|
+
// token is persisted so the joiner can later forward it verbatim.
|
|
329
|
+
await graphStore.upsertDiscoveredCatalog({
|
|
330
|
+
catalog: {
|
|
331
|
+
id: authentic.id,
|
|
332
|
+
owner_did: authentic.ownerDID,
|
|
333
|
+
name: authentic.name,
|
|
334
|
+
description: authentic.description,
|
|
335
|
+
filter_criteria: authentic.filterCriteria,
|
|
336
|
+
hlc: authentic.hlc,
|
|
337
|
+
signed_token: token
|
|
338
|
+
},
|
|
339
|
+
sourceGroupID: groupID,
|
|
340
|
+
sourceCircleID
|
|
341
|
+
});
|
|
342
|
+
} else if (normalizeDID(existing.owner_did) === verified.issuer && authentic.hlc > existing.hlc) {
|
|
343
|
+
// Owner-binding on the STORED row, which the creator check above does
|
|
344
|
+
// not give: that one proves the incoming record's owner is its issuer,
|
|
345
|
+
// and says nothing about whose row already sits under that id. Without
|
|
346
|
+
// this, an inviter validly signs a catalog for itself under an id that
|
|
347
|
+
// already exists locally under a different owner and rewrites that
|
|
348
|
+
// row's name, description and filter — `updateCatalog` touches neither
|
|
349
|
+
// `owner_did` nor `active`, so this is the one way a remote frame
|
|
350
|
+
// reaches an ACTIVE catalog's content. The ids are not secret:
|
|
351
|
+
// `shareRowID('catalog', groupID, ownerDID)` derives from two known
|
|
352
|
+
// values. Both live broadcast arms already bind this way.
|
|
353
|
+
//
|
|
354
|
+
// Content LWW only — a re-discovery never overwrites the local `active`
|
|
355
|
+
// flag or the first-discovery source columns.
|
|
356
|
+
await graphStore.updateCatalog(authentic.id, {
|
|
357
|
+
name: authentic.name,
|
|
358
|
+
description: authentic.description,
|
|
359
|
+
filter_criteria: authentic.filterCriteria,
|
|
360
|
+
hlc: authentic.hlc,
|
|
361
|
+
signed_token: token
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -5,11 +5,16 @@ import type { P2PEventEmitter } from './events.js';
|
|
|
5
5
|
* entry that a receiving peer just applied, so GraphQL subscribers see
|
|
6
6
|
* remote circle/group changes live — the same events a local producer fires.
|
|
7
7
|
*
|
|
8
|
-
* Only
|
|
9
|
-
* message (delegation grant/revoke, advisory leave)
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* Only control-ledger projections surface as `affected`; every other applied
|
|
9
|
+
* message (delegation grant/revoke, advisory leave) emits its own event from
|
|
10
|
+
* inside `processBroadcast`. So this covers exactly the circle/group projection
|
|
11
|
+
* rows the apply core returns rather than emits.
|
|
12
|
+
*
|
|
13
|
+
* Every payload comes from `affected.row`, the POST-state in all six cases,
|
|
14
|
+
* delete and remove included: a tombstone sets only `removed_at_hlc` and leaves
|
|
15
|
+
* the other columns standing, so the removed-event payload still carries the
|
|
16
|
+
* last active `role` / `name` a before-state snapshot would have. The apply core
|
|
17
|
+
* reads pre-state for one purpose — deciding create-vs-change and whether
|
|
18
|
+
* catalogs moved — and it never becomes an event payload.
|
|
14
19
|
*/
|
|
15
20
|
export declare function emitLedgerAffectedEvents(emitter: P2PEventEmitter, result: ProcessBroadcastResult): Promise<void>;
|
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import { toISO } from '../context/types.js';
|
|
1
|
+
import { toCircleData, toISO } from '../context/types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Emit the local `P2PEventEmitter` events for a remote-driven control-ledger
|
|
4
4
|
* entry that a receiving peer just applied, so GraphQL subscribers see
|
|
5
5
|
* remote circle/group changes live — the same events a local producer fires.
|
|
6
6
|
*
|
|
7
|
-
* Only
|
|
8
|
-
* message (delegation grant/revoke, advisory leave)
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* Only control-ledger projections surface as `affected`; every other applied
|
|
8
|
+
* message (delegation grant/revoke, advisory leave) emits its own event from
|
|
9
|
+
* inside `processBroadcast`. So this covers exactly the circle/group projection
|
|
10
|
+
* rows the apply core returns rather than emits.
|
|
11
|
+
*
|
|
12
|
+
* Every payload comes from `affected.row`, the POST-state in all six cases,
|
|
13
|
+
* delete and remove included: a tombstone sets only `removed_at_hlc` and leaves
|
|
14
|
+
* the other columns standing, so the removed-event payload still carries the
|
|
15
|
+
* last active `role` / `name` a before-state snapshot would have. The apply core
|
|
16
|
+
* reads pre-state for one purpose — deciding create-vs-change and whether
|
|
17
|
+
* catalogs moved — and it never becomes an event payload.
|
|
13
18
|
*/ export async function emitLedgerAffectedEvents(emitter, result) {
|
|
14
19
|
if (!result.applied || result.affected == null || result.affected.kind !== 'ledger') {
|
|
15
20
|
return;
|
|
@@ -23,22 +28,12 @@ import { toISO } from '../context/types.js';
|
|
|
23
28
|
// producer: a catalogs-only change emits `circleCatalogsChanged`, any other
|
|
24
29
|
// change emits `circleDataChanged`. The `*Changed` payloads carry `circleID`.
|
|
25
30
|
if (!ledger.preExisted) {
|
|
26
|
-
await emitter.emit('circleCreated',
|
|
27
|
-
id: row.id,
|
|
28
|
-
groupID: row.group_id,
|
|
29
|
-
name: row.name,
|
|
30
|
-
description: row.description,
|
|
31
|
-
createdAt: toISO(row.created_at)
|
|
32
|
-
});
|
|
31
|
+
await emitter.emit('circleCreated', toCircleData(row));
|
|
33
32
|
return;
|
|
34
33
|
}
|
|
35
34
|
const eventName = ledger.catalogsChanged ? 'circleCatalogsChanged' : 'circleDataChanged';
|
|
36
35
|
await emitter.emit(eventName, {
|
|
37
|
-
|
|
38
|
-
groupID: row.group_id,
|
|
39
|
-
name: row.name,
|
|
40
|
-
description: row.description,
|
|
41
|
-
createdAt: toISO(row.created_at),
|
|
36
|
+
...toCircleData(row),
|
|
42
37
|
circleID: row.id
|
|
43
38
|
});
|
|
44
39
|
return;
|
|
@@ -46,13 +41,7 @@ import { toISO } from '../context/types.js';
|
|
|
46
41
|
case 'circle.def.removed':
|
|
47
42
|
{
|
|
48
43
|
const { row } = ledger;
|
|
49
|
-
await emitter.emit('circleDeleted',
|
|
50
|
-
id: row.id,
|
|
51
|
-
groupID: row.group_id,
|
|
52
|
-
name: row.name,
|
|
53
|
-
description: row.description,
|
|
54
|
-
createdAt: toISO(row.created_at)
|
|
55
|
-
});
|
|
44
|
+
await emitter.emit('circleDeleted', toCircleData(row));
|
|
56
45
|
return;
|
|
57
46
|
}
|
|
58
47
|
case 'circle.member':
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { HLC } from '@kubun/hlc';
|
|
2
|
+
import type { Logger } from '@kubun/logger';
|
|
3
|
+
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
4
|
+
import { type GroupHandle, type VerifiedLedgerEntry } from '@kumiai/mls';
|
|
5
|
+
import { type LedgerAffected } from './ledger-ingest.js';
|
|
6
|
+
export type CommitFoldParams = {
|
|
7
|
+
/**
|
|
8
|
+
* The handle that ACCEPTED the commit — post-advance. Two things are read from
|
|
9
|
+
* it: the genesis anchor the projections fold against, and the ledger log the
|
|
10
|
+
* surfaced entries' signed tokens live in.
|
|
11
|
+
*/
|
|
12
|
+
handle: GroupHandle;
|
|
13
|
+
/**
|
|
14
|
+
* The entries the commit surfaced, collected by the handle's sink. Already
|
|
15
|
+
* notarized by the handle that accepted the commit (verified, admin-authored
|
|
16
|
+
* at their own position, group-scoped), so they are NEVER re-verified here.
|
|
17
|
+
*/
|
|
18
|
+
entries: Array<VerifiedLedgerEntry>;
|
|
19
|
+
/**
|
|
20
|
+
* Transaction-scoped p2p store on the remote path, the lane's own short
|
|
21
|
+
* transaction on the local one. Never a bare store read inside a caller's
|
|
22
|
+
* transaction — that deadlocks single-connection SQLite against the tx's own
|
|
23
|
+
* write lock.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The entries the commit enacted that kubun does not PROJECT but must RETAIN
|
|
27
|
+
* — see {@link entriesRetainedByCommit}. Already carried by the same accepted
|
|
28
|
+
* commit as `entries`, so equally notarized and equally never re-verified.
|
|
29
|
+
* Ingested BEFORE `entries`, matching the enactment order the handle applied
|
|
30
|
+
* them in: a commit of `[promote Bob, entry-issued-by-Bob]` must have Bob's
|
|
31
|
+
* grant durable before the entry it authorizes is folded, or that entry is
|
|
32
|
+
* dropped as unauthorized and never revisits the verdict.
|
|
33
|
+
*/
|
|
34
|
+
retained?: Array<RetainedCommitEntry>;
|
|
35
|
+
p2pStore: P2PStoreAPI;
|
|
36
|
+
groupID: string;
|
|
37
|
+
hlc?: HLC;
|
|
38
|
+
logger?: Logger;
|
|
39
|
+
};
|
|
40
|
+
/** A signed token plus its notarized entry, as the handle's ledger log holds it. */
|
|
41
|
+
export type RetainedCommitEntry = {
|
|
42
|
+
verified: VerifiedLedgerEntry;
|
|
43
|
+
token: string;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Whether an entry type is one kubun owns, and therefore one kubun's fold
|
|
47
|
+
* projects. Everything outside `kubun.*` is foreign: the handle consumes it and
|
|
48
|
+
* surfaces nothing to kubun, so nothing folds it.
|
|
49
|
+
*
|
|
50
|
+
* Anchored on KUBUN's namespace, not the one `@kumiai/mls` reserves, so an
|
|
51
|
+
* upstream rename (`group.role` → `kumiai.role`) cannot silently change which
|
|
52
|
+
* entries kubun projects.
|
|
53
|
+
*
|
|
54
|
+
* A producer must write rows only for FOREIGN entries: an entry the fold surfaces
|
|
55
|
+
* is appended and projected by ingest, which reads the row's pre-state to decide
|
|
56
|
+
* which domain event it stands for. A pre-written row reads as pre-existing, so
|
|
57
|
+
* the author alone sees a change event where every other device sees a create.
|
|
58
|
+
*
|
|
59
|
+
* This answers what kubun PROJECTS, not what it must OBSERVE — see
|
|
60
|
+
* {@link entriesRetainedByCommit}, whose answer for the role entry is the opposite.
|
|
61
|
+
*/
|
|
62
|
+
export declare function isKubunLedgerEntryType(type: string): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* The entries a landed commit added to the handle's ledger that kubun does not
|
|
65
|
+
* project but must still OBSERVE. Everything outside `kubun.*`: the handle
|
|
66
|
+
* surfaces none of it, so no kubun fold builds a row from it, yet these entries
|
|
67
|
+
* carry HLCs this device has to merge — a role change stamped by another member
|
|
68
|
+
* is a timestamp this device must sort after.
|
|
69
|
+
*
|
|
70
|
+
* PROJECTS and OBSERVES are different questions — reusing one predicate for both
|
|
71
|
+
* drops the role entry's clock, which reaches this device through no other path.
|
|
72
|
+
*
|
|
73
|
+
* The delta is taken BY INDEX, never by comparing tokens: the handle's ledger is a
|
|
74
|
+
* log, not a set, so a token it already held can legitimately be enacted again.
|
|
75
|
+
* `ledgerLengthBeforeCommit` is the log's length read before `processMessage`.
|
|
76
|
+
*/
|
|
77
|
+
export declare function entriesRetainedByCommit(handle: GroupHandle, ledgerLengthBeforeCommit: number): Array<RetainedCommitEntry>;
|
|
78
|
+
/**
|
|
79
|
+
* The entries a device's OWN commit would surface, recovered from the
|
|
80
|
+
* post-commit handle's ledger. The sink only fires for a commit a handle
|
|
81
|
+
* PROCESSES — a device merges its own commit instead of processing it, so the
|
|
82
|
+
* local path derives the same list from the tokens it committed, keeping the
|
|
83
|
+
* entries kubun owns and dropping the rest. Both paths then fold identical
|
|
84
|
+
* entries through the same ingest.
|
|
85
|
+
*
|
|
86
|
+
* The receive side surfaces by kumiai's rule (everything kumiai does not reserve
|
|
87
|
+
* for itself) and this side surfaces by kubun's. The two agree on every type
|
|
88
|
+
* kubun commits, which is `kubun.*` plus the role entry kumiai interprets — a
|
|
89
|
+
* commit carrying some third party's namespace would surface remotely and not
|
|
90
|
+
* locally, and kubun mints no such entry.
|
|
91
|
+
*/
|
|
92
|
+
export declare function entriesSurfacedByOwnCommit(handle: GroupHandle, tokens: Array<string>): Array<VerifiedLedgerEntry>;
|
|
93
|
+
/**
|
|
94
|
+
* Fold a landed commit's surfaced entries into the same projection ingest a
|
|
95
|
+
* broadcast entry folds through, so the two sources converge on identical rows.
|
|
96
|
+
* Returns one descriptor per entry that moved a projection, for the caller to
|
|
97
|
+
* emit as domain events.
|
|
98
|
+
*
|
|
99
|
+
* NEVER throws. The MLS advance is the load-bearing state and a projection is a
|
|
100
|
+
* cache derivable from the ledger: a fold fault must not roll back a good
|
|
101
|
+
* commit, because a lane that refuses to advance past a frame re-reads that same
|
|
102
|
+
* frame forever. A faulted entry is logged and skipped; the projection heals on
|
|
103
|
+
* the next reproject (an admin entry's arrival rebuilds every dependent, and a
|
|
104
|
+
* ledger catch-up rebuilds all four from the whole ledger). Each entry folds
|
|
105
|
+
* independently, so one fault does not skip the rest.
|
|
106
|
+
*/
|
|
107
|
+
export declare function foldCommittedLedgerEntries(params: CommitFoldParams): Promise<Array<LedgerAffected>>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { readGroupAnchor } from '@kumiai/mls';
|
|
2
|
+
import { ingestVerifiedLedgerEntry } from './ledger-ingest.js';
|
|
3
|
+
/**
|
|
4
|
+
* The namespace kubun owns on the control ledger. Every entry type kubun mints
|
|
5
|
+
* and projects lives under it: `kubun.circle.member`, `kubun.circle.def`,
|
|
6
|
+
* `kubun.group.settings`. Note the last one — it CONTAINS `group.` without
|
|
7
|
+
* starting with it, which is why this stays a prefix test and must never be
|
|
8
|
+
* loosened to a substring test.
|
|
9
|
+
*/ const KUBUN_ENTRY_TYPE_PREFIX = 'kubun.';
|
|
10
|
+
/**
|
|
11
|
+
* Whether an entry type is one kubun owns, and therefore one kubun's fold
|
|
12
|
+
* projects. Everything outside `kubun.*` is foreign: the handle consumes it and
|
|
13
|
+
* surfaces nothing to kubun, so nothing folds it.
|
|
14
|
+
*
|
|
15
|
+
* Anchored on KUBUN's namespace, not the one `@kumiai/mls` reserves, so an
|
|
16
|
+
* upstream rename (`group.role` → `kumiai.role`) cannot silently change which
|
|
17
|
+
* entries kubun projects.
|
|
18
|
+
*
|
|
19
|
+
* A producer must write rows only for FOREIGN entries: an entry the fold surfaces
|
|
20
|
+
* is appended and projected by ingest, which reads the row's pre-state to decide
|
|
21
|
+
* which domain event it stands for. A pre-written row reads as pre-existing, so
|
|
22
|
+
* the author alone sees a change event where every other device sees a create.
|
|
23
|
+
*
|
|
24
|
+
* This answers what kubun PROJECTS, not what it must OBSERVE — see
|
|
25
|
+
* {@link entriesRetainedByCommit}, whose answer for the role entry is the opposite.
|
|
26
|
+
*/ export function isKubunLedgerEntryType(type) {
|
|
27
|
+
return type.startsWith(KUBUN_ENTRY_TYPE_PREFIX);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The entries a landed commit added to the handle's ledger that kubun does not
|
|
31
|
+
* project but must still OBSERVE. Everything outside `kubun.*`: the handle
|
|
32
|
+
* surfaces none of it, so no kubun fold builds a row from it, yet these entries
|
|
33
|
+
* carry HLCs this device has to merge — a role change stamped by another member
|
|
34
|
+
* is a timestamp this device must sort after.
|
|
35
|
+
*
|
|
36
|
+
* PROJECTS and OBSERVES are different questions — reusing one predicate for both
|
|
37
|
+
* drops the role entry's clock, which reaches this device through no other path.
|
|
38
|
+
*
|
|
39
|
+
* The delta is taken BY INDEX, never by comparing tokens: the handle's ledger is a
|
|
40
|
+
* log, not a set, so a token it already held can legitimately be enacted again.
|
|
41
|
+
* `ledgerLengthBeforeCommit` is the log's length read before `processMessage`.
|
|
42
|
+
*/ export function entriesRetainedByCommit(handle, ledgerLengthBeforeCommit) {
|
|
43
|
+
const retained = [];
|
|
44
|
+
const log = handle.ledger;
|
|
45
|
+
for(let index = Math.max(ledgerLengthBeforeCommit, 0); index < log.length; index++){
|
|
46
|
+
const held = log[index];
|
|
47
|
+
if (held == null || isKubunLedgerEntryType(held.verified.entry.type)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
retained.push({
|
|
51
|
+
verified: held.verified,
|
|
52
|
+
token: held.token
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return retained;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The entries a device's OWN commit would surface, recovered from the
|
|
59
|
+
* post-commit handle's ledger. The sink only fires for a commit a handle
|
|
60
|
+
* PROCESSES — a device merges its own commit instead of processing it, so the
|
|
61
|
+
* local path derives the same list from the tokens it committed, keeping the
|
|
62
|
+
* entries kubun owns and dropping the rest. Both paths then fold identical
|
|
63
|
+
* entries through the same ingest.
|
|
64
|
+
*
|
|
65
|
+
* The receive side surfaces by kumiai's rule (everything kumiai does not reserve
|
|
66
|
+
* for itself) and this side surfaces by kubun's. The two agree on every type
|
|
67
|
+
* kubun commits, which is `kubun.*` plus the role entry kumiai interprets — a
|
|
68
|
+
* commit carrying some third party's namespace would surface remotely and not
|
|
69
|
+
* locally, and kubun mints no such entry.
|
|
70
|
+
*/ export function entriesSurfacedByOwnCommit(handle, tokens) {
|
|
71
|
+
const entries = [];
|
|
72
|
+
for (const token of tokens){
|
|
73
|
+
const held = handle.ledger.find((candidate)=>candidate.token === token);
|
|
74
|
+
if (held == null || !isKubunLedgerEntryType(held.verified.entry.type)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
entries.push(held.verified);
|
|
78
|
+
}
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Recover the signed token a surfaced entry was carried by. The handle's ledger
|
|
83
|
+
* log holds `{ token, verified }` pairs, and the entry a commit surfaces IS the
|
|
84
|
+
* `verified` the log stores for it, so the pair is recovered by identity rather
|
|
85
|
+
* than by re-deriving a digest or re-verifying a signature.
|
|
86
|
+
*/ function tokenForEntry(handle, entry) {
|
|
87
|
+
for (const held of handle.ledger){
|
|
88
|
+
if (held.verified === entry) {
|
|
89
|
+
return held.token;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Fold a landed commit's surfaced entries into the same projection ingest a
|
|
96
|
+
* broadcast entry folds through, so the two sources converge on identical rows.
|
|
97
|
+
* Returns one descriptor per entry that moved a projection, for the caller to
|
|
98
|
+
* emit as domain events.
|
|
99
|
+
*
|
|
100
|
+
* NEVER throws. The MLS advance is the load-bearing state and a projection is a
|
|
101
|
+
* cache derivable from the ledger: a fold fault must not roll back a good
|
|
102
|
+
* commit, because a lane that refuses to advance past a frame re-reads that same
|
|
103
|
+
* frame forever. A faulted entry is logged and skipped; the projection heals on
|
|
104
|
+
* the next reproject (an admin entry's arrival rebuilds every dependent, and a
|
|
105
|
+
* ledger catch-up rebuilds all four from the whole ledger). Each entry folds
|
|
106
|
+
* independently, so one fault does not skip the rest.
|
|
107
|
+
*/ export async function foldCommittedLedgerEntries(params) {
|
|
108
|
+
const { handle, entries, p2pStore, groupID, logger } = params;
|
|
109
|
+
const retained = params.retained ?? [];
|
|
110
|
+
if (entries.length === 0 && retained.length === 0) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
// Retained first, projected second — the enactment order the handle applied
|
|
114
|
+
// them in, which is the order their clocks were stamped in.
|
|
115
|
+
const pending = [
|
|
116
|
+
...retained
|
|
117
|
+
];
|
|
118
|
+
for (const entry of entries){
|
|
119
|
+
const token = tokenForEntry(handle, entry);
|
|
120
|
+
if (token == null) {
|
|
121
|
+
// The ledger log is the only source of an entry's signed token, and an
|
|
122
|
+
// entry the accepting handle surfaced is in it. Nothing to append without
|
|
123
|
+
// one, so log rather than guess.
|
|
124
|
+
logger?.warn('committed ledger entry skipped: no signed token in the handle ledger', {
|
|
125
|
+
groupID,
|
|
126
|
+
type: entry.entry.type
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
pending.push({
|
|
131
|
+
verified: entry,
|
|
132
|
+
token
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const anchor = readGroupAnchor(handle);
|
|
136
|
+
const affected = [];
|
|
137
|
+
for (const { verified } of pending){
|
|
138
|
+
try {
|
|
139
|
+
const result = await ingestVerifiedLedgerEntry({
|
|
140
|
+
p2pStore,
|
|
141
|
+
...params.hlc != null ? {
|
|
142
|
+
hlc: params.hlc
|
|
143
|
+
} : {},
|
|
144
|
+
...anchor != null ? {
|
|
145
|
+
getGroupAnchor: async ()=>anchor
|
|
146
|
+
} : {},
|
|
147
|
+
// The accepting handle is already in hand, so its log is read straight
|
|
148
|
+
// off it. Going back through the registry here would re-acquire a lock
|
|
149
|
+
// this path may already hold.
|
|
150
|
+
getGroupLedger: async ()=>handle.ledger,
|
|
151
|
+
...logger != null ? {
|
|
152
|
+
logger
|
|
153
|
+
} : {}
|
|
154
|
+
}, verified, groupID);
|
|
155
|
+
if (result.ledger != null) {
|
|
156
|
+
affected.push(result.ledger);
|
|
157
|
+
}
|
|
158
|
+
} catch (error) {
|
|
159
|
+
logger?.warn('committed ledger entry projection failed, skipping', {
|
|
160
|
+
groupID,
|
|
161
|
+
type: verified.entry.type,
|
|
162
|
+
error
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return affected;
|
|
167
|
+
}
|