@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
@@ -0,0 +1,295 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { HLC } from '@kubun/hlc';
3
+ import { getP2PStore } from '@kubun/store-p2p';
4
+ /** Coalescing window for the epoch lane. A catch-up walk applies its commits well inside it. */ const ANNOUNCE_COALESCE_MS = 50;
5
+ /**
6
+ * Pull an announcement out of an untyped gather reply. The gather surface hands
7
+ * back `unknown`, so a version-skewed or malicious responder must contribute
8
+ * nothing rather than throw — and must not be able to write a row from a shape
9
+ * the schema would have refused.
10
+ */ function extractAnnouncement(value) {
11
+ if (value == null || typeof value !== 'object') {
12
+ return null;
13
+ }
14
+ const reply = value;
15
+ const { label, availability, capabilities, hlc } = reply;
16
+ if (typeof label !== 'string' || typeof hlc !== 'string' || hlc === '') {
17
+ return null;
18
+ }
19
+ if (availability !== 'always-on' && availability !== 'interactive' && availability !== 'mobile') {
20
+ return null;
21
+ }
22
+ if (!Array.isArray(capabilities)) {
23
+ return null;
24
+ }
25
+ const parsed = [];
26
+ for (const entry of capabilities){
27
+ if (entry == null || typeof entry !== 'object') {
28
+ return null;
29
+ }
30
+ const capability = entry;
31
+ if (typeof capability.protocol !== 'string' || typeof capability.version !== 'number') {
32
+ return null;
33
+ }
34
+ const transports = capability.transports;
35
+ if (transports !== null && !Array.isArray(transports)) {
36
+ return null;
37
+ }
38
+ parsed.push({
39
+ protocol: capability.protocol,
40
+ version: capability.version,
41
+ transports: transports === null ? null : transports.filter((hint)=>typeof hint === 'string')
42
+ });
43
+ }
44
+ return {
45
+ label,
46
+ availability,
47
+ capabilities: parsed,
48
+ hlc
49
+ };
50
+ }
51
+ export function createPeerPresence(params) {
52
+ const { logger } = params;
53
+ const selfDID = normalizeDID(params.localDID);
54
+ // groupID → the timer coalescing that group's epoch-lane announces.
55
+ const pendingAnnounces = new Map();
56
+ // Tasks whose timer has already fired. A pending timer can be cleared; these
57
+ // can only be waited for — see `dispose`.
58
+ const inFlight = new Set();
59
+ let disposed = false;
60
+ /** Run `task` now, keeping it visible to `dispose` until it settles. */ const track = (task)=>{
61
+ const running = task().finally(()=>{
62
+ inFlight.delete(running);
63
+ });
64
+ inFlight.add(running);
65
+ };
66
+ const getProfile = async ()=>{
67
+ const store = await getP2PStore(params.stores);
68
+ const row = await store.getLocalPeerProfile(selfDID);
69
+ if (row == null) {
70
+ return null;
71
+ }
72
+ return {
73
+ label: row.label,
74
+ // As in the query handler: the row was written from a validated profile.
75
+ availability: row.availability,
76
+ capabilities: row.capabilities
77
+ };
78
+ };
79
+ const announceProfile = async (groupID, profile)=>{
80
+ // Stamped once for both the frame and the local row, and never taken from the
81
+ // caller: the anchor decides which of two announcements from this device wins
82
+ // on every receiver, so it has to come from the device's one clock — and the
83
+ // local row has to carry the same value a co-member holds, or the two copies
84
+ // of one announcement disagree.
85
+ const announcedHLC = HLC.serialize(params.hlc.now());
86
+ // Seed this device's own row. The hub fans a publish to subscribers minus the
87
+ // sender, so nothing ever hands a device back its own announcement: without
88
+ // this write the projection is the one place that cannot see the device it
89
+ // lives on. It is also what a `peer/query` reply is read from.
90
+ const store = await getP2PStore(params.stores);
91
+ await store.upsertPeerAnnouncement({
92
+ groupID,
93
+ peerDID: selfDID,
94
+ label: profile.label,
95
+ availability: profile.availability,
96
+ capabilities: profile.capabilities,
97
+ hlc: announcedHLC,
98
+ epoch: params.getGroupEpoch(groupID) ?? 0
99
+ });
100
+ await params.publish(groupID, {
101
+ type: 'peer:announce',
102
+ label: profile.label,
103
+ availability: profile.availability,
104
+ capabilities: profile.capabilities,
105
+ hlc: announcedHLC,
106
+ announcerDID: selfDID
107
+ });
108
+ };
109
+ const announce = async (groupID)=>{
110
+ const profile = await getProfile();
111
+ if (profile == null) {
112
+ logger?.debug('nothing announced: this device has declared no peer profile', {
113
+ groupID
114
+ });
115
+ return false;
116
+ }
117
+ await announceProfile(groupID, profile);
118
+ return true;
119
+ };
120
+ /**
121
+ * How many co-members could answer this query at all — every projected device
122
+ * but this one, keeping only those whose advertised capabilities match what is
123
+ * being asked for.
124
+ *
125
+ * This is the set that would not DECLINE, which is what a quorum can count:
126
+ * a decline is a throw, and the gather drops an errored reply before counting
127
+ * it. Counting roster members instead would set a target no group with a
128
+ * silent or non-matching device could ever reach.
129
+ */ const answerableCount = async (groupID, protocols)=>{
130
+ const store = await getP2PStore(params.stores);
131
+ const rows = await store.listPeerAnnouncements(groupID);
132
+ return rows.filter((entry)=>normalizeDID(entry.peer_did) !== selfDID && (protocols == null || entry.capabilities.some((capability)=>protocols.includes(capability.protocol)))).length;
133
+ };
134
+ const gather = async (groupID, options = {})=>{
135
+ const protocols = options.protocols ?? null;
136
+ // Without a quorum every gather costs its whole window — 5s, on every
137
+ // foreground refresh — because nothing else can tell the client that the
138
+ // last answer has already arrived. Known-and-answerable is the largest
139
+ // target that can actually be met, so it closes the window the moment
140
+ // everybody replies and falls back to the timeout when somebody is asleep.
141
+ // A caller's own quorum always wins.
142
+ const quorum = options.quorum ?? await answerableCount(groupID, protocols);
143
+ const replies = await params.query(groupID, {
144
+ protocols
145
+ }, // Zero would be a quorum met by the first reply, which is not what "nothing
146
+ // is known to answer" means. Nothing known, nothing to wait for: the window.
147
+ quorum > 0 ? {
148
+ ...options,
149
+ quorum
150
+ } : options);
151
+ const gathered = [];
152
+ const seen = new Set();
153
+ for (const reply of replies){
154
+ // Attributed exactly as a pushed frame is: the sender the lane
155
+ // authenticated, never anything the reply body carried. The reply body
156
+ // holds no DID at all — it is the announce body.
157
+ const peerDID = normalizeDID(reply.senderDID);
158
+ if (peerDID === selfDID || seen.has(peerDID)) {
159
+ continue;
160
+ }
161
+ const announcement = extractAnnouncement(reply.value);
162
+ if (announcement == null) {
163
+ logger?.warn('peer/query reply ignored: not an announcement', {
164
+ groupID,
165
+ peerDID
166
+ });
167
+ continue;
168
+ }
169
+ seen.add(peerDID);
170
+ // A reply IS an announcement, so it folds through the same apply — same LWW
171
+ // by the announcer's stamp, same epoch stamping, same loopback guard.
172
+ await params.applyAnnouncement(groupID, {
173
+ type: 'peer:announce',
174
+ label: announcement.label,
175
+ availability: announcement.availability,
176
+ capabilities: announcement.capabilities,
177
+ hlc: announcement.hlc,
178
+ announcerDID: peerDID
179
+ });
180
+ gathered.push({
181
+ peerDID,
182
+ ...announcement
183
+ });
184
+ }
185
+ return gathered;
186
+ };
187
+ const refresh = async (groupID, options = {})=>{
188
+ // No profile, no traffic: a device that has not said what it is has not opted
189
+ // into the peer lane, and a trigger must not put a query on the lane on its
190
+ // behalf. An explicit `gather` still asks — there the host did.
191
+ if (!await announce(groupID)) {
192
+ return [];
193
+ }
194
+ return await gather(groupID, options);
195
+ };
196
+ // Off the current turn, and never awaited by its caller. Both triggers that use
197
+ // this fire from inside something that must not wait: an epoch advance holds
198
+ // the group mutex and its write transaction, and a hub lifecycle event is
199
+ // awaited by the adapter's state machine.
200
+ const later = (run)=>{
201
+ setTimeout(()=>{
202
+ if (disposed) {
203
+ return;
204
+ }
205
+ track(run);
206
+ }, 0);
207
+ };
208
+ return {
209
+ async setProfile (profile) {
210
+ const store = await getP2PStore(params.stores);
211
+ await store.setLocalPeerProfile({
212
+ peerDID: selfDID,
213
+ label: profile.label,
214
+ availability: profile.availability,
215
+ capabilities: profile.capabilities
216
+ });
217
+ // Before the announces, so nothing can arrive in response to one of them
218
+ // with no listener up yet.
219
+ params.onProfileSet?.();
220
+ // Device-wide, so every group hears it: the label and the capability set are
221
+ // properties of the device, and a group told nothing would keep offering a
222
+ // capability this device no longer answers.
223
+ for (const groupID of params.joinedGroupIDs()){
224
+ try {
225
+ await announceProfile(groupID, profile);
226
+ } catch (error) {
227
+ logger?.warn('profile announce failed for a group', {
228
+ groupID,
229
+ error
230
+ });
231
+ }
232
+ }
233
+ },
234
+ getProfile,
235
+ announce,
236
+ gather,
237
+ refresh,
238
+ scheduleRefresh (groupID, reason) {
239
+ later(async ()=>{
240
+ try {
241
+ logger?.debug('peer presence refresh', {
242
+ groupID,
243
+ reason
244
+ });
245
+ await refresh(groupID);
246
+ } catch (error) {
247
+ logger?.warn('peer presence refresh failed', {
248
+ groupID,
249
+ reason,
250
+ error
251
+ });
252
+ }
253
+ });
254
+ },
255
+ scheduleAnnounce (groupID, reason) {
256
+ if (pendingAnnounces.has(groupID)) {
257
+ return;
258
+ }
259
+ const timer = setTimeout(()=>{
260
+ pendingAnnounces.delete(groupID);
261
+ if (disposed) {
262
+ return;
263
+ }
264
+ track(async ()=>{
265
+ try {
266
+ logger?.debug('peer presence announce', {
267
+ groupID,
268
+ reason
269
+ });
270
+ await announce(groupID);
271
+ } catch (error) {
272
+ logger?.warn('peer presence announce failed', {
273
+ groupID,
274
+ reason,
275
+ error
276
+ });
277
+ }
278
+ });
279
+ }, ANNOUNCE_COALESCE_MS);
280
+ pendingAnnounces.set(groupID, timer);
281
+ },
282
+ async dispose () {
283
+ disposed = true;
284
+ for (const timer of pendingAnnounces.values()){
285
+ clearTimeout(timer);
286
+ }
287
+ pendingAnnounces.clear();
288
+ // One pass is enough: nothing a tracked task does schedules another one,
289
+ // and `disposed` stops any trigger that fires from here on.
290
+ await Promise.allSettled([
291
+ ...inFlight
292
+ ]);
293
+ }
294
+ };
295
+ }
@@ -0,0 +1,37 @@
1
+ import type { PeerAnnouncement } from '@kubun/store-p2p';
2
+ /**
3
+ * "I answer directed merkle catch-up sessions" — matched on protocol AND version,
4
+ * exactly. A future direct transport is a new capability, not a wider match here.
5
+ */
6
+ export declare const MERKLE_SYNC_PROTOCOL = "kubun/sync-merkle";
7
+ export declare const MERKLE_SYNC_VERSION = 1;
8
+ export type SelectSyncPeerOptions = {
9
+ /**
10
+ * This device's own DID. Required, because the projection holds this device's
11
+ * own row (every announce seeds it) and a session with yourself is a session
12
+ * with nobody.
13
+ */
14
+ selfDID: string;
15
+ };
16
+ /**
17
+ * Every co-member advertising merkle catch-up, best first.
18
+ *
19
+ * Pure and synchronous over rows the caller already read — the ranking is a
20
+ * policy, and the one thing that must not become another store query.
21
+ *
22
+ * "Live" here means "advertises the capability", with no freshness cutoff: this
23
+ * system holds no liveness fact to threshold. `announced_at` is when THIS device
24
+ * applied the announcement, a row is never deleted on silence, and a stale row
25
+ * says so rather than disappearing — so a cutoff would be a liveness policy with
26
+ * UI consequences invented at the ranking.
27
+ *
28
+ * The whole order rather than its head, because a row says only that a device
29
+ * once announced: the peer it names may be off, and nothing here can tell. Only
30
+ * an attempted session finds out, so the caller needs somewhere to go next.
31
+ */
32
+ export declare function rankSyncPeers(rows: Array<PeerAnnouncement>, options: SelectSyncPeerOptions): Array<PeerAnnouncement>;
33
+ /**
34
+ * The candidate an automatic catch-up tries first, or `undefined` when the
35
+ * projection offers none. The head of {@link rankSyncPeers}.
36
+ */
37
+ export declare function selectSyncPeer(rows: Array<PeerAnnouncement>, options: SelectSyncPeerOptions): PeerAnnouncement | undefined;
@@ -0,0 +1,72 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ /**
3
+ * "I answer directed merkle catch-up sessions" — matched on protocol AND version,
4
+ * exactly. A future direct transport is a new capability, not a wider match here.
5
+ */ export const MERKLE_SYNC_PROTOCOL = 'kubun/sync-merkle';
6
+ export const MERKLE_SYNC_VERSION = 1;
7
+ /**
8
+ * Availability tiers, best first. A device's own claim about itself, agreed by
9
+ * nobody: one that overstates it wastes a session, so there is no authority here
10
+ * to protect. An unrecognized class ranks after every known one rather than being
11
+ * dropped, so a device announcing a future tier stays selectable.
12
+ */ const AVAILABILITY_RANK = {
13
+ 'always-on': 0,
14
+ interactive: 1,
15
+ mobile: 2
16
+ };
17
+ const UNKNOWN_AVAILABILITY_RANK = Object.keys(AVAILABILITY_RANK).length;
18
+ function answersMerkleSync(row) {
19
+ return row.capabilities.some((capability)=>capability.protocol === MERKLE_SYNC_PROTOCOL && capability.version === MERKLE_SYNC_VERSION);
20
+ }
21
+ function availabilityRank(row) {
22
+ return AVAILABILITY_RANK[row.availability] ?? UNKNOWN_AVAILABILITY_RANK;
23
+ }
24
+ /**
25
+ * Compared relationally, never subtracted: `announced_at` is a count of seconds
26
+ * on SQLite and a timestamp on Postgres, and `b - a` is `NaN` for the latter —
27
+ * which sorts nothing and reports no error.
28
+ */ function compareRecency(left, right) {
29
+ if (left.announced_at === right.announced_at) {
30
+ return 0;
31
+ }
32
+ return left.announced_at < right.announced_at ? 1 : -1;
33
+ }
34
+ /**
35
+ * Every co-member advertising merkle catch-up, best first.
36
+ *
37
+ * Pure and synchronous over rows the caller already read — the ranking is a
38
+ * policy, and the one thing that must not become another store query.
39
+ *
40
+ * "Live" here means "advertises the capability", with no freshness cutoff: this
41
+ * system holds no liveness fact to threshold. `announced_at` is when THIS device
42
+ * applied the announcement, a row is never deleted on silence, and a stale row
43
+ * says so rather than disappearing — so a cutoff would be a liveness policy with
44
+ * UI consequences invented at the ranking.
45
+ *
46
+ * The whole order rather than its head, because a row says only that a device
47
+ * once announced: the peer it names may be off, and nothing here can tell. Only
48
+ * an attempted session finds out, so the caller needs somewhere to go next.
49
+ */ export function rankSyncPeers(rows, options) {
50
+ const selfDID = normalizeDID(options.selfDID);
51
+ const candidates = rows.filter((row)=>normalizeDID(row.peer_did) !== selfDID && answersMerkleSync(row));
52
+ candidates.sort((left, right)=>{
53
+ const byAvailability = availabilityRank(left) - availabilityRank(right);
54
+ if (byAvailability !== 0) {
55
+ return byAvailability;
56
+ }
57
+ const byRecency = compareRecency(left, right);
58
+ if (byRecency !== 0) {
59
+ return byRecency;
60
+ }
61
+ // Total order, so two rows a caller cannot tell apart never pick different
62
+ // peers on two devices reading the same projection.
63
+ return left.peer_did < right.peer_did ? -1 : 1;
64
+ });
65
+ return candidates;
66
+ }
67
+ /**
68
+ * The candidate an automatic catch-up tries first, or `undefined` when the
69
+ * projection offers none. The head of {@link rankSyncPeers}.
70
+ */ export function selectSyncPeer(rows, options) {
71
+ return rankSyncPeers(rows, options)[0];
72
+ }
@@ -0,0 +1,53 @@
1
+ import type { GroupMember, P2PStoreAPI } from '@kubun/store-p2p';
2
+ import type { GroupPermission, RosterState } from '@kumiai/mls';
3
+ import type { GroupMemberData } from '../types.js';
4
+ /**
5
+ * The role a member holds according to the handle's own roster.
6
+ *
7
+ * The roster is folded from the genesis anchor plus exactly the entries the
8
+ * authenticated MLS commit chain enacted, in epoch order — the same roster the
9
+ * commit policy judges an incoming commit against. Its order is therefore the
10
+ * epoch chain's, not a timestamp the signer chose for itself, so no party can
11
+ * place a role change earlier in the sequence than the commit that carried it.
12
+ *
13
+ * The roster is DID-keyed and can hold a role for a DID with no MLS membership,
14
+ * so lookups normalize; a member the roster says nothing about holds no
15
+ * permission beyond membership, which is `member`.
16
+ */
17
+ export declare function roleFromRoster(roster: RosterState, memberDID: string): GroupPermission;
18
+ export type MirrorRosterRolesParams = {
19
+ /**
20
+ * The store to write through. Inside a transaction this MUST be the
21
+ * transactional store — a bare store read/write there deadlocks
22
+ * single-connection SQLite against the transaction's own write lock.
23
+ */
24
+ store: P2PStoreAPI;
25
+ groupID: string;
26
+ /** The post-commit roster: `handle.roster` of the handle that accepted it. */
27
+ roster: RosterState;
28
+ /** The members whose `role` should be reconciled against that roster. */
29
+ memberDIDs: Array<string>;
30
+ /**
31
+ * The live membership rows to compare the roster against. Callers that have
32
+ * already read them pass them in so the comparison costs no extra query; when
33
+ * omitted the rows are read here.
34
+ *
35
+ * They must be read on the SAME store — inside a transaction, the
36
+ * transactional one — and must predate no write that changes a role.
37
+ */
38
+ members?: Array<GroupMember>;
39
+ };
40
+ /**
41
+ * Reconcile each member's role against the roster and report what changed.
42
+ *
43
+ * Only rows whose stored role actually differs from the roster's answer are
44
+ * written, and only those are returned, so a caller can announce a role change
45
+ * without announcing every member the commit merely left alone. The common
46
+ * commit changes nobody's role and therefore writes nothing.
47
+ *
48
+ * A DID with no live membership row is skipped: membership stays MLS-native and
49
+ * a role can never conjure a member. A tombstoned row is likewise left alone —
50
+ * an ex-member's role is frozen at the value it held when they were removed,
51
+ * which is what an announcement of their removal must report.
52
+ */
53
+ export declare function mirrorRosterRoles(params: MirrorRosterRolesParams): Promise<Array<GroupMemberData>>;
@@ -0,0 +1,65 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { toISO } from '../context/types.js';
3
+ /**
4
+ * The role a member holds according to the handle's own roster.
5
+ *
6
+ * The roster is folded from the genesis anchor plus exactly the entries the
7
+ * authenticated MLS commit chain enacted, in epoch order — the same roster the
8
+ * commit policy judges an incoming commit against. Its order is therefore the
9
+ * epoch chain's, not a timestamp the signer chose for itself, so no party can
10
+ * place a role change earlier in the sequence than the commit that carried it.
11
+ *
12
+ * The roster is DID-keyed and can hold a role for a DID with no MLS membership,
13
+ * so lookups normalize; a member the roster says nothing about holds no
14
+ * permission beyond membership, which is `member`.
15
+ */ export function roleFromRoster(roster, memberDID) {
16
+ return roster.roles.get(normalizeDID(memberDID)) ?? 'member';
17
+ }
18
+ /**
19
+ * Reconcile each member's role against the roster and report what changed.
20
+ *
21
+ * Only rows whose stored role actually differs from the roster's answer are
22
+ * written, and only those are returned, so a caller can announce a role change
23
+ * without announcing every member the commit merely left alone. The common
24
+ * commit changes nobody's role and therefore writes nothing.
25
+ *
26
+ * A DID with no live membership row is skipped: membership stays MLS-native and
27
+ * a role can never conjure a member. A tombstoned row is likewise left alone —
28
+ * an ex-member's role is frozen at the value it held when they were removed,
29
+ * which is what an announcement of their removal must report.
30
+ */ export async function mirrorRosterRoles(params) {
31
+ const { store, groupID, roster, memberDIDs } = params;
32
+ const rows = params.members ?? await store.listGroupMembers(groupID);
33
+ const byDID = new Map(rows.map((row)=>[
34
+ normalizeDID(row.member_did),
35
+ row
36
+ ]));
37
+ const changed = [];
38
+ for (const memberDID of memberDIDs){
39
+ const row = byDID.get(normalizeDID(memberDID));
40
+ if (row == null) {
41
+ continue;
42
+ }
43
+ const role = roleFromRoster(roster, memberDID);
44
+ if (row.role === role) {
45
+ continue;
46
+ }
47
+ changed.push({
48
+ groupID,
49
+ memberDID: row.member_did,
50
+ role,
51
+ createdAt: toISO(row.created_at)
52
+ });
53
+ }
54
+ // Written together once every row is judged: the batch costs one statement per
55
+ // distinct role rather than one per member. The common commit moves nobody, and
56
+ // that case must not reach the store at all — not even to no-op on an empty
57
+ // list.
58
+ if (changed.length > 0) {
59
+ await store.setGroupMemberRoles(groupID, changed.map(({ memberDID, role })=>({
60
+ memberDID,
61
+ role
62
+ })));
63
+ }
64
+ return changed;
65
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from '@kubun/logger';
2
- import type { DelegationStoreAPI } from '@kubun/store-delegation';
2
+ import { type DelegationStoreAPI } from '@kubun/store-delegation';
3
3
  import type { P2PStoreAPI } from '@kubun/store-p2p';
4
4
  import type { P2PEventEmitter } from './events.js';
5
5
  export type StoreReceivedGrantParams = {
@@ -39,8 +39,9 @@ export type StoreReceivedGrantParams = {
39
39
  *
40
40
  * Returns `true` when the held row was inserted or materially changed (LWW
41
41
  * upsert with a newer hlc and at least one differing content field). Returns
42
- * `false` when no row change occurred — verification failure, audience
43
- * mismatch, missing required claims, LWW lost against an existing row, or an
42
+ * `false` when no row change occurred — verification failure, a subject that
43
+ * is not the signer, audience mismatch, missing required claims, a lifetime
44
+ * above `MAX_CAP_TTL_SECONDS`, LWW lost against an existing row, or an
44
45
  * idempotent re-broadcast with identical content. Never throws on a forged or
45
46
  * malformed token — it is skipped like an unverifiable broadcast entry.
46
47
  *