@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.
- package/lib/context/group.js +1073 -746
- package/lib/context/hub.js +2 -1
- package/lib/context/join.js +50 -15
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +234 -1
- package/lib/context/types.d.ts +56 -20
- package/lib/context/types.js +23 -3
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +42 -117
- package/lib/groups/admin-roster.js +53 -168
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast.d.ts +199 -81
- package/lib/groups/broadcast.js +443 -368
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +32 -64
- package/lib/groups/circle-projection.js +95 -181
- package/lib/groups/circle-reducers.d.ts +71 -110
- package/lib/groups/circle-reducers.js +57 -127
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +14 -1
- package/lib/groups/group-anchor.d.ts +18 -57
- package/lib/groups/group-anchor.js +15 -100
- package/lib/groups/group-crypto.d.ts +36 -13
- package/lib/groups/group-crypto.js +124 -50
- package/lib/groups/group-handle-registry.d.ts +106 -36
- package/lib/groups/group-handle-registry.js +351 -61
- package/lib/groups/group-handlers.d.ts +5 -4
- package/lib/groups/group-handlers.js +155 -29
- package/lib/groups/group-health-monitor.d.ts +1 -1
- package/lib/groups/group-health-monitor.js +1 -1
- package/lib/groups/group-mls.d.ts +35 -4
- package/lib/groups/group-mls.js +594 -62
- package/lib/groups/group-peer-manager.d.ts +212 -19
- package/lib/groups/group-peer-manager.js +1061 -69
- package/lib/groups/group-protocols.d.ts +485 -28
- package/lib/groups/group-protocols.js +289 -15
- package/lib/groups/invite-payload.d.ts +48 -0
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +60 -0
- package/lib/groups/join-utils.js +262 -0
- package/lib/groups/ledger-affected-events.d.ts +11 -6
- package/lib/groups/ledger-affected-events.js +15 -26
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +202 -19
- package/lib/groups/manager.js +623 -106
- package/lib/groups/mls-codec.d.ts +27 -6
- package/lib/groups/mls-codec.js +30 -15
- package/lib/groups/mls-encryptor.js +9 -6
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +12 -4
- package/lib/groups/mls-receive-errors.d.ts +0 -2
- package/lib/groups/mls-receive-errors.js +3 -6
- package/lib/groups/mls-state.d.ts +5 -3
- package/lib/groups/mls-state.js +27 -4
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +4 -3
- package/lib/groups/store-received-grant.js +80 -35
- package/lib/groups/store-received-revocation.d.ts +16 -13
- package/lib/groups/store-received-revocation.js +23 -22
- package/lib/hub/http-client.d.ts +10 -27
- package/lib/hub/http-client.js +2 -19
- package/lib/hub/hub-like.d.ts +57 -6
- package/lib/hub/hub-like.js +539 -52
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +51 -94
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +71 -14
- package/lib/hub/wiring.js +80 -23
- package/lib/index.d.ts +48 -13
- package/lib/index.js +257 -86
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +213 -0
- package/lib/protocol.js +268 -0
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +805 -131
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +57 -3
- package/lib/sync/authorize.js +45 -4
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +2 -3
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +55 -39
- package/lib/sync/errors.d.ts +9 -0
- package/lib/sync/errors.js +10 -0
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +13 -19
- package/lib/sync/handlers.js +54 -14
- package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
- package/lib/sync/hub-tunnel-sync-listener.js +126 -37
- package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
- package/lib/sync/hub-tunnel-sync-provider.js +68 -19
- package/lib/sync/merkle-channel.d.ts +9 -1
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/peer-registry.d.ts +14 -10
- package/lib/sync/peer-registry.js +0 -25
- package/lib/sync/sync-client.d.ts +11 -1
- package/lib/sync/sync-client.js +72 -10
- package/lib/sync/sync-manager.d.ts +22 -4
- package/lib/sync/sync-manager.js +39 -10
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +699 -28
- package/lib/types.js +0 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +37 -3
- package/package.json +59 -52
- package/lib/groups/ledger-entry.d.ts +0 -37
- package/lib/groups/ledger-entry.js +0 -56
- package/lib/groups/ledger-fold.d.ts +0 -56
- package/lib/groups/ledger-fold.js +0 -53
- package/lib/hub/did-observing-transport.d.ts +0 -75
- package/lib/hub/did-observing-transport.js +0 -126
- package/lib/hub/errors.d.ts +0 -24
- package/lib/hub/errors.js +0 -33
|
@@ -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
|
|
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,
|
|
43
|
-
*
|
|
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
|
*
|