@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.
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 +56 -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,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 { ADMIN_ROLE_ENTRY_TYPE, ADMIN_ROSTER_PROJECTION, applyAdminEntryIncremental, reprojectAdminRoster } from './admin-roster.js';
5
- import { applyCircleDefEntryIncremental, applyCircleMemberEntryIncremental, applyGroupSettingsEntryIncremental, CIRCLE_DEF_PROJECTION, CIRCLE_MEMBER_PROJECTION, GROUP_SETTINGS_PROJECTION, reprojectCircleDefs, reprojectCircleMembers, reprojectGroupSettings } from './circle-projection.js';
6
- import { CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, decodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
7
- import { ledgerEntryDigest, verifyLedgerEntry } from './ledger-entry.js';
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
- // No authorized broadcast path exists for catalog control today, so a
59
- // received catalog control frame cannot be attributed to an admin author.
60
- // Drop it rather than apply an unauthenticated mutation.
61
- params.logger?.warn('dropping catalog control broadcast: no authorized path', {
62
- type: message.type
63
- });
64
- return {
65
- applied: false
66
- };
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 'ledger:entry':
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
- // The token is self-authenticating, so verify it directly instead of the
114
- // admin gate. A forged, unsigned, or garbage token verifies to null and
115
- // is dropped store untouched, never thrown.
116
- const verified = await verifyLedgerEntry(message.token);
117
- if (verified == null) {
118
- params.logger?.warn('ledger entry dropped: token verification failed', {
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
- const entryID = ledgerEntryDigest(message.token);
126
- const appended = await p2pStore.appendLedgerEntry({
127
- group_id: groupID,
128
- entry_id: entryID,
129
- type: verified.entry.type,
130
- subject: verified.entry.subject,
131
- value: verified.entry.value,
132
- hlc: verified.entry.hlc,
133
- issuer_did: verified.issuer,
134
- signed_token: message.token
135
- });
136
- if (!appended) {
137
- // A duplicate (already-stored token): no clock advance, no re-projection.
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
- if (params.hlc != null) {
143
- try {
144
- params.hlc.receive(HLC.parse(verified.entry.hlc));
145
- } catch (error) {
146
- params.logger?.warn('ledger entry HLC merge failed', {
147
- groupID,
148
- hlc: verified.entry.hlc,
149
- error
150
- });
151
- }
152
- }
153
- // The entry is durable now; the role overlay can only be projected once the
154
- // authenticated epoch-0 root is known. Defer projection (without dropping
155
- // the entry) when there is no anchor or it can't be read right now — a later
156
- // entry or catch-up reproject heals the overlay.
157
- const resolution = await resolveGroupAnchor(params, groupID);
158
- if (resolution.status !== 'anchored') {
159
- params.logger?.debug('ledger entry appended; role projection deferred', {
160
- groupID,
161
- type: verified.entry.type,
162
- reason: resolution.status === 'unknown' ? 'anchor unavailable' : 'no genesis anchor'
163
- });
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: true
325
+ applied: false
166
326
  };
167
327
  }
168
- const anchor = resolution.anchor;
169
- // Local-event descriptor for this applied entry — populated by the
170
- // projected control branches below so `forwardRemoteBroadcast` can emit the
171
- // same local event a producer fires, for a remote-driven change. The
172
- // pre-state each branch reads BEFORE reprojecting decides create-vs-change /
173
- // add-vs-remove; the post-state is the projected row the event carries.
174
- let ledgerAffected;
175
- // Route by entry type to the matching projection. For each: an absent
176
- // watermark (first projection) or an out-of-order / catch-up entry at or
177
- // before what we've already folded means the cached projection is no
178
- // longer a correct fold-through-this-entry, so rebuild authoritatively
179
- // from the whole ledger. In-order means the entry is strictly newer than
180
- // everything projected, so the current projection is exactly the
181
- // fold-so-far: apply it incrementally and advance the watermark (the
182
- // entry is accounted for even when its issuer lacked authority and the
183
- // projection was unchanged). An unprojected type falls through; the entry
184
- // is already durable.
185
- switch(verified.entry.type){
186
- case ADMIN_ROLE_ENTRY_TYPE:
187
- {
188
- const adminEntry = verified;
189
- const watermark = await p2pStore.getProjectionWatermark(groupID, ADMIN_ROSTER_PROJECTION);
190
- if (watermark == null || adminEntry.entry.hlc <= watermark) {
191
- await reprojectAdminRoster({
192
- store: p2pStore,
193
- groupID,
194
- anchor,
195
- logger: params.logger
196
- });
197
- } else {
198
- await applyAdminEntryIncremental({
199
- store: p2pStore,
200
- groupID,
201
- verified: adminEntry,
202
- logger: params.logger
203
- });
204
- await p2pStore.setProjectionWatermark(groupID, ADMIN_ROSTER_PROJECTION, adminEntry.entry.hlc);
205
- }
206
- break;
207
- }
208
- case CIRCLE_DEF_ENTRY_TYPE:
209
- {
210
- const defEntry = verified;
211
- // Pre-state (including a tombstoned row) read before reprojecting: an
212
- // active row before this entry means the circle existed (a change, not a
213
- // create); its `catalog_ids` is the baseline for catalogs-changed.
214
- const before = await p2pStore.getCircle(defEntry.entry.subject, {
215
- includeRemoved: true
216
- });
217
- const preActive = before != null && before.removed_at_hlc == null;
218
- const watermark = await p2pStore.getProjectionWatermark(groupID, CIRCLE_DEF_PROJECTION);
219
- if (watermark == null || defEntry.entry.hlc <= watermark) {
220
- await reprojectCircleDefs({
221
- store: p2pStore,
222
- groupID,
223
- anchor,
224
- logger: params.logger
225
- });
226
- } else {
227
- await applyCircleDefEntryIncremental({
228
- store: p2pStore,
229
- groupID,
230
- anchor,
231
- verified: defEntry,
232
- logger: params.logger
233
- });
234
- await p2pStore.setProjectionWatermark(groupID, CIRCLE_DEF_PROJECTION, defEntry.entry.hlc);
235
- }
236
- // Post-state decides which local event to emit. A now-tombstoned row maps
237
- // to a delete; an active row maps to create (no active pre-state) or
238
- // change (active pre-state). A null post-state (a non-admin entry the
239
- // projection dropped, or a tombstone with no prior row) emits nothing.
240
- const after = await p2pStore.getCircle(defEntry.entry.subject, {
241
- includeRemoved: true
242
- });
243
- if (after != null) {
244
- if (after.removed_at_hlc != null) {
245
- ledgerAffected = {
246
- entry: 'circle.def.removed',
247
- row: after
248
- };
249
- } else {
250
- const catalogsChanged = preActive && before != null && JSON.stringify(before.catalog_ids) !== JSON.stringify(after.catalog_ids);
251
- ledgerAffected = {
252
- entry: 'circle.def',
253
- row: after,
254
- preExisted: preActive,
255
- catalogsChanged
256
- };
257
- }
258
- }
259
- break;
260
- }
261
- case CIRCLE_MEMBER_ENTRY_TYPE:
262
- {
263
- const memberEntry = verified;
264
- const decoded = decodeCircleMemberSubject(memberEntry.entry.subject);
265
- const watermark = await p2pStore.getProjectionWatermark(groupID, CIRCLE_MEMBER_PROJECTION);
266
- if (watermark == null || memberEntry.entry.hlc <= watermark) {
267
- await reprojectCircleMembers({
268
- store: p2pStore,
269
- groupID,
270
- anchor,
271
- logger: params.logger
272
- });
273
- } else {
274
- await applyCircleMemberEntryIncremental({
275
- store: p2pStore,
276
- groupID,
277
- anchor,
278
- verified: memberEntry,
279
- logger: params.logger
280
- });
281
- await p2pStore.setProjectionWatermark(groupID, CIRCLE_MEMBER_PROJECTION, memberEntry.entry.hlc);
282
- }
283
- // Post-state decides the local event: a now-tombstoned row maps to a
284
- // remove, an active row to an add (an add and a role-change both map to
285
- // `circleMemberAdded` — there is no distinct role-change event). A null
286
- // post-state (a malformed subject, a non-admin entry the projection
287
- // dropped, or a tombstone with no prior row) emits nothing.
288
- if (decoded != null) {
289
- const after = await p2pStore.getCircleMember(decoded.circleID, decoded.memberDID);
290
- if (after != null) {
291
- ledgerAffected = after.removed_at_hlc != null ? {
292
- entry: 'circle.member.removed',
293
- row: after
294
- } : {
295
- entry: 'circle.member',
296
- row: after
297
- };
298
- }
299
- }
300
- break;
301
- }
302
- case GROUP_SETTINGS_ENTRY_TYPE:
303
- {
304
- const settingsEntry = verified;
305
- const watermark = await p2pStore.getProjectionWatermark(groupID, GROUP_SETTINGS_PROJECTION);
306
- if (watermark == null || settingsEntry.entry.hlc <= watermark) {
307
- await reprojectGroupSettings({
308
- store: p2pStore,
309
- groupID,
310
- anchor,
311
- logger: params.logger
312
- });
313
- } else {
314
- await applyGroupSettingsEntryIncremental({
315
- store: p2pStore,
316
- groupID,
317
- anchor,
318
- verified: settingsEntry,
319
- logger: params.logger
320
- });
321
- await p2pStore.setProjectionWatermark(groupID, GROUP_SETTINGS_PROJECTION, settingsEntry.entry.hlc);
322
- }
323
- // Group settings are partial-merge with no tombstone and no
324
- // create-vs-change distinction — the producer always emits
325
- // `groupDataChanged` — so the post-state group row alone is the event.
326
- const after = await p2pStore.getGroup(groupID);
327
- if (after != null) {
328
- ledgerAffected = {
329
- entry: 'group.settings',
330
- row: after
331
- };
332
- }
333
- break;
334
- }
335
- }
336
- return ledgerAffected != null ? {
337
- applied: true,
338
- affected: {
339
- kind: 'ledger',
340
- ledger: ledgerAffected
341
- }
342
- } : {
343
- applied: true
344
- };
345
- }
346
- case 'group:leaveRequest':
347
- {
348
- // Advisory notification only — surface it for admin apps to act on. No
349
- // store mutation: this is not an authoritative removal (a forged request
350
- // must not evict anyone), and MLS forbids self-removal anyway.
351
- await params.emitter?.emit('groupLeaveRequested', {
352
- groupID: message.groupID,
353
- memberDID: message.memberDID,
354
- role: '',
355
- createdAt: new Date().toISOString()
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
- if (message.senderPeerDID === params.selfDID) {
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
- * Apply a batched ledger catch-up reply from an UNTRUSTED responder. Each entry
524
- * carries its own signed token, so the responder's identity is irrelevant: every
525
- * token is verified independently and a forged or unsigned one is dropped without
526
- * aborting the batch. Surviving entries are appended idempotently on the token
527
- * digest, so a replayed or duplicated reply is a harmless no-op.
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
- * A catch-up reply delivers the ledger's history out of order by construction, so
530
- * after any new entry lands the projection is rebuilt authoritatively from the
531
- * whole ledger (a full reproject) rather than applied incrementally. All four
532
- * control projections admin roster, circle defs, circle members, group
533
- * settings — rebuild here, so a circle or settings entry delivered via catch-up
534
- * materializes immediately rather than waiting for a later live entry. Projection
535
- * is deferred when no genesis anchor is available yet — the entries are still
536
- * stored durably.
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
- * Never throws out of the per-token loop: one bad token must not abort the batch.
539
- */ export async function applyLedgerCatchupReply(params, reply, groupID) {
540
- const { p2pStore, logger } = params;
541
- let applied = 0;
542
- let rejected = 0;
543
- for (const token of reply.entries){
544
- // The untrusted check: a forged, unsigned, or garbage token verifies to
545
- // null and is dropped here. The responder is never trusted — this is the
546
- // sole authority over what enters the local ledger.
547
- const verified = await verifyLedgerEntry(token);
548
- if (verified == null) {
549
- rejected++;
550
- logger?.warn('ledger catch-up entry dropped: token verification failed', {
551
- groupID
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
- continue;
605
+ ledger = [];
554
606
  }
555
- const entryID = ledgerEntryDigest(token);
556
- const appended = await p2pStore.appendLedgerEntry({
557
- group_id: groupID,
558
- entry_id: entryID,
559
- type: verified.entry.type,
560
- subject: verified.entry.subject,
561
- value: verified.entry.value,
562
- hlc: verified.entry.hlc,
563
- issuer_did: verified.issuer,
564
- signed_token: token
565
- });
566
- if (!appended) {
567
- continue;
568
- }
569
- applied++;
570
- if (params.hlc != null) {
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
- params.hlc.receive(HLC.parse(verified.entry.hlc));
615
+ hlc.receive(HLC.parse(ord));
573
616
  } catch (error) {
574
- logger?.warn('ledger catch-up entry HLC merge failed', {
617
+ logger?.warn('ledger entry HLC merge failed', {
575
618
  groupID,
576
- hlc: verified.entry.hlc,
619
+ hlc: ord,
577
620
  error
578
621
  });
579
622
  }
580
623
  }
581
624
  }
582
- if (applied > 0) {
583
- const resolution = await resolveGroupAnchor(params, groupID);
584
- if (resolution.status === 'anchored') {
585
- // Full authoritative rebuild a catch-up batch delivers history out of
586
- // order, so the cached projection can no longer be trusted as a
587
- // fold-through-watermark. This also resets each watermark to the max folded
588
- // HLC. A batch may carry any mix of entry types, so all control
589
- // projections rebuild unconditionally; reprojecting a type the batch
590
- // didn't touch is a harmless no-op that just re-confirms the current rows.
591
- await reprojectAdminRoster({
592
- store: p2pStore,
593
- groupID,
594
- anchor: resolution.anchor,
595
- logger
596
- });
597
- await reprojectCircleDefs({
598
- store: p2pStore,
599
- groupID,
600
- anchor: resolution.anchor,
601
- logger
602
- });
603
- await reprojectCircleMembers({
604
- store: p2pStore,
605
- groupID,
606
- anchor: resolution.anchor,
607
- logger
608
- });
609
- await reprojectGroupSettings({
610
- store: p2pStore,
611
- groupID,
612
- anchor: resolution.anchor,
613
- logger
614
- });
615
- } else {
616
- logger?.debug('ledger catch-up applied; projection deferred', {
617
- groupID,
618
- applied,
619
- reason: resolution.status === 'unknown' ? 'anchor unavailable' : 'no genesis anchor'
620
- });
621
- }
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
  }