@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.
Files changed (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +59 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -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 `ledger:entry` projections surface as `affected`; every other applied
9
- * message (delegation grant/revoke, advisory leave) already emits its own event
10
- * from inside `processBroadcast`, so this covers exactly the circle/group
11
- * projection rows the apply core returns rather than emits. The payloads are
12
- * built from `affected.row`, which `processBroadcast` snapshots at the right
13
- * moment (after-state for create/change, before-state for delete/remove).
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 `ledger:entry` projections surface as `affected`; every other applied
8
- * message (delegation grant/revoke, advisory leave) already emits its own event
9
- * from inside `processBroadcast`, so this covers exactly the circle/group
10
- * projection rows the apply core returns rather than emits. The payloads are
11
- * built from `affected.row`, which `processBroadcast` snapshots at the right
12
- * moment (after-state for create/change, before-state for delete/remove).
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
- id: row.id,
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
+ }