@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,6 +1,35 @@
1
- import { isSignedToken } from '@kokuin/token';
1
+ import { signAccessDefaultSet } from './access-default-token.js';
2
2
  import { processBroadcast } from './broadcast.js';
3
- import { emitLedgerAffectedEvents } from './ledger-affected-events.js';
3
+ /**
4
+ * Why this device is not answering a `peer/query`.
5
+ *
6
+ * Declining is a THROW because that is the only silence the lane offers: the
7
+ * broadcast responder writes a reply for whatever a handler returns, and the
8
+ * requester's gather drops replies that carried an error and keeps the rest. So a
9
+ * decline bounds the RESULT, not the traffic — every member still publishes one
10
+ * reply frame — and the spec's "only matching devices reply" is true of what a
11
+ * gather reports, not of what crosses the hub.
12
+ */ class PeerQueryDeclined extends Error {
13
+ constructor(reason){
14
+ super(`peer/query declined: ${reason}`);
15
+ this.name = 'PeerQueryDeclined';
16
+ }
17
+ }
18
+ /**
19
+ * The MLS-authenticated sender of a frame, which group-rpc surfaces at
20
+ * `message.payload.iss`. It builds that context message itself from the sender
21
+ * `unwrap` recovered, so `iss` is an authenticated DID and not a transport-claimed
22
+ * one.
23
+ *
24
+ * Absent when the frame opened at a leaf that could not be named. That is
25
+ * `undefined`, never a sentinel: a placeholder DID would be an identity claim
26
+ * nothing vouched for, and a guard comparing against it would be comparing
27
+ * against a value chosen for never matching rather than one meaning "unknown".
28
+ */ function authenticatedSender(message) {
29
+ const payload = message.payload;
30
+ const iss = payload.iss;
31
+ return typeof iss === 'string' ? iss : undefined;
32
+ }
4
33
  /**
5
34
  * Build the control + sync procedure handlers for a single group.
6
35
  *
@@ -33,19 +62,6 @@ import { emitLedgerAffectedEvents } from './ledger-affected-events.js';
33
62
  // contract types them `=> void`, so the server ignores the return; returning
34
63
  // the promise is purely so a caller (e.g. a test) that holds the handler
35
64
  // directly can await the apply completing.
36
- 'control/ledgerEntry': ({ data })=>safeApply(async ()=>{
37
- // A remote-applied ledger entry returns its circle/group projection row
38
- // as `affected` rather than emitting; surface the matching local events
39
- // so this receiver's GraphQL subscribers see the change live.
40
- const result = await processBroadcast(params, {
41
- type: 'ledger:entry',
42
- groupID,
43
- token: data.token
44
- }, groupID);
45
- if (params.emitter != null) {
46
- await emitLedgerAffectedEvents(params.emitter, result);
47
- }
48
- }),
49
65
  'control/delegationShare': ({ data })=>safeApply(()=>processBroadcast(params, {
50
66
  type: 'delegation:share',
51
67
  token: data.token,
@@ -58,34 +74,144 @@ import { emitLedgerAffectedEvents } from './ledger-affected-events.js';
58
74
  groupID,
59
75
  hlc: data.hlc
60
76
  }, groupID)),
77
+ // Every field the sender put on the frame is carried back verbatim. The
78
+ // signature covers the operation, not just the author, so a handler that
79
+ // filled a field in from context — or dropped one — would hand
80
+ // `processBroadcast` a frame whose token no longer describes it.
81
+ 'control/accessDefaultSet': ({ data })=>safeApply(()=>processBroadcast(params, {
82
+ type: 'access-default:set',
83
+ modelID: data.modelID,
84
+ permissionType: data.permissionType,
85
+ rule: {
86
+ level: data.rule.level,
87
+ allowedDIDs: data.rule.allowedDIDs,
88
+ allowedCircles: data.rule.allowedCircles,
89
+ allowedGroups: data.rule.allowedGroups
90
+ },
91
+ ownerDID: data.ownerDID,
92
+ hlc: data.hlc,
93
+ auth: data.auth
94
+ }, groupID)),
95
+ 'control/accessDefaultRemove': ({ data })=>safeApply(()=>processBroadcast(params, {
96
+ type: 'access-default:remove',
97
+ modelID: data.modelID,
98
+ permissionTypes: data.permissionTypes,
99
+ ownerDID: data.ownerDID,
100
+ hlc: data.hlc,
101
+ auth: data.auth
102
+ }, groupID)),
61
103
  'control/groupLeaveRequest': ({ data })=>safeApply(()=>processBroadcast(params, {
62
104
  type: 'group:leaveRequest',
63
105
  groupID,
64
106
  memberDID: data.memberDID,
65
107
  hlc: data.hlc
66
108
  }, groupID)),
109
+ // Restate this device's OWN rules for a member that fell below the hub's
110
+ // retention floor and can no longer read the frames that carried them.
111
+ // Signed fresh, but stamped with each row's STORED hlc: the anchor is when
112
+ // the owner decided the rule, so re-stamping here would let a repair win
113
+ // an ordering race against a newer rule the requester already holds.
114
+ 'control/policyCatchup': async ()=>{
115
+ const identity = params.identity;
116
+ if (identity == null) {
117
+ params.logger?.warn('policy catch-up served empty: no signing identity', {
118
+ groupID
119
+ });
120
+ return {
121
+ defaults: []
122
+ };
123
+ }
124
+ const rows = await params.graphStore.listUserModelAccessDefaults(identity.id);
125
+ const defaults = await Promise.all(rows// An unstamped row cannot be ordered against what the requester
126
+ // holds, so restating it could only overwrite by luck.
127
+ .filter((row)=>row.hlc != null).map((row)=>signAccessDefaultSet(identity, {
128
+ modelID: row.modelID,
129
+ permissionType: row.permissionType,
130
+ rule: {
131
+ level: row.level,
132
+ allowedDIDs: row.allowedDIDs,
133
+ allowedCircles: row.allowedCircles,
134
+ allowedGroups: row.allowedGroups
135
+ },
136
+ hlc: row.hlc
137
+ })));
138
+ return {
139
+ defaults
140
+ };
141
+ },
67
142
  'control/ledgerCatchup': async ()=>{
68
- // Untrusted responder: hand back every stored signed entry token. The
69
- // requester re-verifies each one independently, so authority lives in the
70
- // token, not in this reply.
71
- const rows = await params.p2pStore.getLedgerEntries(groupID);
143
+ // The whole ledger this device holds, in ENACTMENT order. Order is
144
+ // load-bearing: the requester checks the list against the authenticated
145
+ // head, which is a chain digest, so a permuted list folds to a different
146
+ // head and is rejected. Only the handle's log records that order — the
147
+ // store's is insertion order, which is why it cannot answer this.
148
+ //
149
+ // A device with no ledger reader serves nothing rather than something
150
+ // unordered: an empty reply fails the requester's head check (unless the
151
+ // ledger genuinely is empty) and costs it only this responder.
152
+ if (params.getGroupLedger == null) {
153
+ params.logger?.warn('ledger catch-up served empty: no ledger reader', {
154
+ groupID
155
+ });
156
+ return {
157
+ entries: []
158
+ };
159
+ }
160
+ const ledger = await params.getGroupLedger(groupID);
72
161
  return {
73
- entries: rows.map((row)=>row.signed_token)
162
+ entries: ledger.map((held)=>held.token)
74
163
  };
75
164
  }
76
165
  },
77
166
  sync: {
78
- 'sync/mutationApply': ({ data, message })=>{
79
- // The authenticated transport sender is the signed frame's issuer. It is
80
- // only a belt-and-suspenders loopback guard — the hub already fans a
81
- // publish to subscribers minus the sender, so a peer never receives its
82
- // own broadcast. An unsigned frame yields '' (harmless: no self-match).
83
- const senderPeerDID = isSignedToken(message) ? message.payload.iss : '';
84
- return safeApply(()=>processBroadcast(params, {
167
+ 'sync/mutationApply': ({ data, message })=>safeApply(()=>processBroadcast(params, {
85
168
  type: 'mutation:apply',
86
169
  entries: data.entries,
87
- senderPeerDID
88
- }, groupID));
170
+ senderPeerDID: authenticatedSender(message)
171
+ }, groupID))
172
+ },
173
+ peer: {
174
+ // The announcer comes from the LANE, never the body — `peer/announce`
175
+ // carries no DID field, and the projection row is keyed on what arrives
176
+ // here. Adding a convenience `peerDID` to the body and reading it instead
177
+ // would let any member write any other member's row.
178
+ 'peer/announce': ({ data, message })=>safeApply(()=>processBroadcast(params, {
179
+ type: 'peer:announce',
180
+ label: data.label,
181
+ availability: data.availability,
182
+ capabilities: data.capabilities,
183
+ hlc: data.hlc,
184
+ announcerDID: authenticatedSender(message)
185
+ }, groupID)),
186
+ // A reply IS an announcement: this device's OWN projection row, read back
187
+ // verbatim under the stamp it was announced with. So the gathered copy and
188
+ // the broadcast copy of one announcement are the same row on the receiver,
189
+ // and a reply cannot become the one path where a body-carried identity
190
+ // sneaks in — the row holds no DID field to send.
191
+ 'peer/query': async ({ param })=>{
192
+ const own = await params.p2pStore.getPeerAnnouncement({
193
+ groupID,
194
+ peerDID: params.selfDID
195
+ });
196
+ if (own == null) {
197
+ // Never announced in this group, so there is nothing true to say. A
198
+ // fabricated reply would put a capability claim on the wire that no
199
+ // announcement backs.
200
+ throw new PeerQueryDeclined('this device has not announced in this group');
201
+ }
202
+ const requested = param.protocols;
203
+ if (requested != null && !own.capabilities.some((capability)=>requested.includes(capability.protocol))) {
204
+ throw new PeerQueryDeclined('no capability matches the requested protocols');
205
+ }
206
+ return {
207
+ label: own.label,
208
+ // Widened by the store column, narrowed back here: the row was written
209
+ // from a schema-validated frame (or from this device's own profile,
210
+ // validated the same way), so the value is one of the three.
211
+ availability: own.availability,
212
+ capabilities: own.capabilities,
213
+ hlc: own.hlc
214
+ };
89
215
  }
90
216
  }
91
217
  };
@@ -18,7 +18,7 @@ export type GroupHealthMonitorParams = {
18
18
  * so `getHealth` always reports `healthy` and `groupHealthChanged` never fires.
19
19
  * This is the front-end of the epoch-stale-recovery feature, deliberately
20
20
  * dormant until tier-2 Welcome re-admission exists — see
21
- * docs/agents/plans/backlog/welcome-based-deep-recovery.md. Kept (not deleted)
21
+ * docs/agents/plans/backlog/recovery-rendezvous-transport.md. Kept (not deleted)
22
22
  * because that feature is roadmapped and this scaffolding is design-aligned.
23
23
  *
24
24
  * Per-group health state machine. Counts/latches failure signals per
@@ -5,7 +5,7 @@ const DEFAULT_EPOCH_STALE_THRESHOLD = 3;
5
5
  * so `getHealth` always reports `healthy` and `groupHealthChanged` never fires.
6
6
  * This is the front-end of the epoch-stale-recovery feature, deliberately
7
7
  * dormant until tier-2 Welcome re-admission exists — see
8
- * docs/agents/plans/backlog/welcome-based-deep-recovery.md. Kept (not deleted)
8
+ * docs/agents/plans/backlog/recovery-rendezvous-transport.md. Kept (not deleted)
9
9
  * because that feature is roadmapped and this scaffolding is design-aligned.
10
10
  *
11
11
  * Per-group health state machine. Counts/latches failure signals per
@@ -1,11 +1,26 @@
1
+ import { type OwnIdentity } from '@kokuin/token';
1
2
  import type { StoreProvider } from '@kubun/db';
2
3
  import { HLC } from '@kubun/hlc';
3
4
  import { type Logger } from '@kubun/logger';
4
5
  import type { GroupMLS } from '@kumiai/rpc';
6
+ import type { P2PEventEmitter } from './events.js';
5
7
  import type { GroupHandleRegistry } from './group-handle-registry.js';
6
8
  export type CreateGroupMLSParams = {
7
9
  /** Single canonical access point for the device's MLS `GroupHandle`. */
8
10
  registry: GroupHandleRegistry;
11
+ /**
12
+ * The device's own signing identity. The recovery members sign under it — the
13
+ * request token, the membership attestation on a sealed reply, and the
14
+ * external commit that rejoins — and the commit-apply path reads its DID to
15
+ * recognise a removal that names THIS device.
16
+ *
17
+ * Held rather than passed per call, which departs from how the rest of this
18
+ * package takes an identity ({@link GroupManager}'s methods each receive one
19
+ * from the request that drove them). Recovery has no such request: the lane
20
+ * mints a gather on its own, off a drain that no user mutation is waiting on,
21
+ * so there is no caller to take the key from at the moment it is needed.
22
+ */
23
+ identity: OwnIdentity;
9
24
  /**
10
25
  * Store coordinator used to open the receive-side commit transaction so the
11
26
  * MLS ratchet advance and the roster delta writes commit (or roll back)
@@ -22,14 +37,30 @@ export type CreateGroupMLSParams = {
22
37
  */
23
38
  hlc: HLC;
24
39
  groupID: string;
40
+ /**
41
+ * Emitter for the domain events a landed commit's control entries drive
42
+ * (circle created/changed/deleted, circle membership, group settings) — the
43
+ * same events a broadcast-delivered entry fires. Optional: a caller that
44
+ * never surfaces those changes to subscribers (and existing tests) omits it
45
+ * and the fold still writes the projections.
46
+ */
47
+ emitter?: P2PEventEmitter;
25
48
  logger?: Logger;
26
49
  };
27
50
  /**
28
51
  * Adapts a `GroupHandleRegistry` group into the `@kumiai/rpc`
29
52
  * {@link GroupMLS} lifecycle port: apply received Commits to advance the epoch,
30
- * export GroupInfo for a recovery responder, and expose the epoch-independent
31
- * recovery seed. group-rpc owns the transport + orchestration (subscribe the
32
- * non-rotating handshake topic, run these methods, resync the app lane when the
33
- * epoch advances); this adapter owns the MLS state, storage, and atomicity.
53
+ * read the roster, seal this handle's ledger for another member's gather, and
54
+ * expose the epoch-independent recovery seed. group-rpc owns the transport +
55
+ * orchestration (subscribe the non-rotating handshake topic, run these methods,
56
+ * resync the app lane when the epoch advances); this adapter owns the MLS state,
57
+ * storage, and atomicity.
58
+ *
59
+ * The recovery members that throw below do so for reasons that live in KUBUN,
60
+ * not upstream: `@kumiai/mls` ships every primitive they would need. An earlier
61
+ * revision of this file justified the same throws by upstream gaps, and a later
62
+ * upstream release quietly made those justifications false. So a reader deciding
63
+ * whether one of these can now be wired must re-check the KUBUN-side statement
64
+ * in each message — not assume the blocker moved when a dependency did.
34
65
  */
35
66
  export declare function createGroupMLS(params: CreateGroupMLSParams): GroupMLS;