@kubun/plugin-p2p 0.10.1 → 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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- 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 +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- 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 +55 -0
- package/lib/groups/admin-roster.js +69 -0
- 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-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- 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 +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- 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 +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- 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 +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- 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 +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- 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 +155 -1
- 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 +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- 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 +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- 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 +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- 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 +704 -22
- package/lib/types.js +1 -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 +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -89
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { signAccessDefaultSet } from './access-default-token.js';
|
|
2
|
+
import { processBroadcast } from './broadcast.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
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Build the control + sync procedure handlers for a single group.
|
|
35
|
+
*
|
|
36
|
+
* Each handler is a thin adapter: it maps the typed group-rpc procedure payload
|
|
37
|
+
* back to the {@link GroupBroadcastMessage} the pure {@link processBroadcast}
|
|
38
|
+
* apply logic already understands, re-adding the `groupID` the peer is bound to
|
|
39
|
+
* (group-rpc strips it from the wire because each topic already encodes the
|
|
40
|
+
* group). The apply logic owns verification, conflict resolution, and storage;
|
|
41
|
+
* the handler owns only the shape translation.
|
|
42
|
+
*
|
|
43
|
+
* Event handlers are fire-and-forget. `processBroadcast` already drops an
|
|
44
|
+
* unverifiable or malformed payload and returns `{ applied: false }` rather than
|
|
45
|
+
* throwing, so the result is intentionally ignored. A programmer-error rethrow
|
|
46
|
+
* (or an unexpected store fault) is caught and logged here so a single bad frame
|
|
47
|
+
* never escapes as an unhandled rejection out of the receive loop.
|
|
48
|
+
*/ export function buildGroupHandlers(params, groupID) {
|
|
49
|
+
const safeApply = async (apply)=>{
|
|
50
|
+
try {
|
|
51
|
+
await apply();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
params.logger?.warn('group handler apply failed', {
|
|
54
|
+
groupID,
|
|
55
|
+
error
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
control: {
|
|
61
|
+
// Event handlers return the (never-rejecting) apply promise. The procedure
|
|
62
|
+
// contract types them `=> void`, so the server ignores the return; returning
|
|
63
|
+
// the promise is purely so a caller (e.g. a test) that holds the handler
|
|
64
|
+
// directly can await the apply completing.
|
|
65
|
+
'control/delegationShare': ({ data })=>safeApply(()=>processBroadcast(params, {
|
|
66
|
+
type: 'delegation:share',
|
|
67
|
+
token: data.token,
|
|
68
|
+
groupID,
|
|
69
|
+
hlc: data.hlc
|
|
70
|
+
}, groupID)),
|
|
71
|
+
'control/delegationRevoke': ({ data })=>safeApply(()=>processBroadcast(params, {
|
|
72
|
+
type: 'delegation:revoke',
|
|
73
|
+
token: data.token,
|
|
74
|
+
groupID,
|
|
75
|
+
hlc: data.hlc
|
|
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)),
|
|
103
|
+
'control/groupLeaveRequest': ({ data })=>safeApply(()=>processBroadcast(params, {
|
|
104
|
+
type: 'group:leaveRequest',
|
|
105
|
+
groupID,
|
|
106
|
+
memberDID: data.memberDID,
|
|
107
|
+
hlc: data.hlc
|
|
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
|
+
},
|
|
142
|
+
'control/ledgerCatchup': async ()=>{
|
|
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);
|
|
161
|
+
return {
|
|
162
|
+
entries: ledger.map((held)=>held.token)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
sync: {
|
|
167
|
+
'sync/mutationApply': ({ data, message })=>safeApply(()=>processBroadcast(params, {
|
|
168
|
+
type: 'mutation:apply',
|
|
169
|
+
entries: data.entries,
|
|
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
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Logger } from '@kubun/logger';
|
|
1
2
|
import type { GroupHealthCondition, GroupHealthSnapshot, GroupHealthState } from '../types.js';
|
|
2
3
|
import type { P2PEventEmitter } from './events.js';
|
|
3
4
|
export type GroupHealthMonitorParams = {
|
|
@@ -8,8 +9,18 @@ export type GroupHealthMonitorParams = {
|
|
|
8
9
|
};
|
|
9
10
|
/** Clock for `lastTransitionAt` stamps. Defaults to `Date.now`. */
|
|
10
11
|
now?: () => number;
|
|
12
|
+
/** Logger for a throwing `groupHealthChanged` subscriber. Defaults to the
|
|
13
|
+
* `group-health-monitor` namespace. */
|
|
14
|
+
logger?: Logger;
|
|
11
15
|
};
|
|
12
16
|
/**
|
|
17
|
+
* INERT: the mutating producers (`signal`/`reset`/`mark*`) have no live caller,
|
|
18
|
+
* so `getHealth` always reports `healthy` and `groupHealthChanged` never fires.
|
|
19
|
+
* This is the front-end of the epoch-stale-recovery feature, deliberately
|
|
20
|
+
* dormant until tier-2 Welcome re-admission exists — see
|
|
21
|
+
* docs/agents/plans/backlog/recovery-rendezvous-transport.md. Kept (not deleted)
|
|
22
|
+
* because that feature is roadmapped and this scaffolding is design-aligned.
|
|
23
|
+
*
|
|
13
24
|
* Per-group health state machine. Counts/latches failure signals per
|
|
14
25
|
* `(groupID, condition)` and emits `groupHealthChanged` on transitions.
|
|
15
26
|
* Crypto- and persistence-free: callers classify failures before signalling.
|
|
@@ -1 +1,139 @@
|
|
|
1
|
-
|
|
1
|
+
import { getKubunLogger } from '@kubun/logger';
|
|
2
|
+
const DEFAULT_EPOCH_STALE_THRESHOLD = 3;
|
|
3
|
+
/**
|
|
4
|
+
* INERT: the mutating producers (`signal`/`reset`/`mark*`) have no live caller,
|
|
5
|
+
* so `getHealth` always reports `healthy` and `groupHealthChanged` never fires.
|
|
6
|
+
* This is the front-end of the epoch-stale-recovery feature, deliberately
|
|
7
|
+
* dormant until tier-2 Welcome re-admission exists — see
|
|
8
|
+
* docs/agents/plans/backlog/recovery-rendezvous-transport.md. Kept (not deleted)
|
|
9
|
+
* because that feature is roadmapped and this scaffolding is design-aligned.
|
|
10
|
+
*
|
|
11
|
+
* Per-group health state machine. Counts/latches failure signals per
|
|
12
|
+
* `(groupID, condition)` and emits `groupHealthChanged` on transitions.
|
|
13
|
+
* Crypto- and persistence-free: callers classify failures before signalling.
|
|
14
|
+
*
|
|
15
|
+
* State is in-memory per-process — a restart re-derives it from the next signal.
|
|
16
|
+
* Transitions are synchronous (no `await`), so calls cannot interleave; signals
|
|
17
|
+
* arriving while a rejoin is in flight (`recovering`) are ignored — the recovery
|
|
18
|
+
* owns the group until it resolves.
|
|
19
|
+
*/ export class GroupHealthMonitor {
|
|
20
|
+
#emitter;
|
|
21
|
+
#epochStaleThreshold;
|
|
22
|
+
#now;
|
|
23
|
+
#logger;
|
|
24
|
+
// groupID -> condition -> per-condition state
|
|
25
|
+
#groups = new Map();
|
|
26
|
+
// groupID -> epoch ms of the last transition affecting its aggregate state
|
|
27
|
+
#lastTransitionAt = new Map();
|
|
28
|
+
constructor(params){
|
|
29
|
+
this.#emitter = params.emitter;
|
|
30
|
+
this.#epochStaleThreshold = params.epochStale?.threshold ?? DEFAULT_EPOCH_STALE_THRESHOLD;
|
|
31
|
+
this.#now = params.now ?? Date.now;
|
|
32
|
+
this.#logger = params.logger ?? getKubunLogger('group-health-monitor');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Record a failure observation for a condition. Below the condition's
|
|
36
|
+
* threshold this only increments the counter; on reaching it the group
|
|
37
|
+
* transitions to `degraded` (emitted once, then latched). Ignored while the
|
|
38
|
+
* group is `recovering` (the rejoin owns it) or already `degraded`/failed.
|
|
39
|
+
*/ signal(groupID, condition) {
|
|
40
|
+
const state = this.#ensure(groupID, condition);
|
|
41
|
+
if (state.phase !== 'healthy') {
|
|
42
|
+
// non-healthy: further failures are expected, don't re-emit.
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
state.count += 1;
|
|
46
|
+
if (state.count >= this.#thresholdFor(condition)) {
|
|
47
|
+
state.phase = 'degraded';
|
|
48
|
+
this.#stamp(groupID);
|
|
49
|
+
this.#emit(groupID, condition, 'degraded');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Clear a condition to healthy and re-arm (counter reset), driven by the
|
|
54
|
+
* success path. Silent: only recovery (via the mark* methods) is surfaced.
|
|
55
|
+
*/ reset(groupID, condition) {
|
|
56
|
+
const state = this.#ensure(groupID, condition);
|
|
57
|
+
state.count = 0;
|
|
58
|
+
if (state.phase !== 'healthy') {
|
|
59
|
+
state.phase = 'healthy';
|
|
60
|
+
this.#stamp(groupID);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
markRecovering(groupID) {
|
|
64
|
+
const state = this.#ensure(groupID, 'epoch-stale');
|
|
65
|
+
state.phase = 'recovering';
|
|
66
|
+
this.#stamp(groupID);
|
|
67
|
+
this.#emit(groupID, 'epoch-stale', 'recovering');
|
|
68
|
+
}
|
|
69
|
+
/** Emit the transient `recovered` event, then settle the phase to `healthy`. */ markRecovered(groupID) {
|
|
70
|
+
const state = this.#ensure(groupID, 'epoch-stale');
|
|
71
|
+
state.count = 0;
|
|
72
|
+
state.phase = 'healthy';
|
|
73
|
+
this.#stamp(groupID);
|
|
74
|
+
this.#emit(groupID, 'epoch-stale', 'recovered');
|
|
75
|
+
}
|
|
76
|
+
/** Leave the group `recovery-failed` until a later success resets it. */ markRecoveryFailed(groupID) {
|
|
77
|
+
const state = this.#ensure(groupID, 'epoch-stale');
|
|
78
|
+
state.phase = 'recovery-failed';
|
|
79
|
+
this.#stamp(groupID);
|
|
80
|
+
this.#emit(groupID, 'epoch-stale', 'recovery-failed');
|
|
81
|
+
}
|
|
82
|
+
/** Aggregate state: the first non-healthy condition phase, else `healthy`. */ getState(groupID) {
|
|
83
|
+
const conditions = this.#groups.get(groupID);
|
|
84
|
+
if (conditions == null) {
|
|
85
|
+
return 'healthy';
|
|
86
|
+
}
|
|
87
|
+
for (const state of conditions.values()){
|
|
88
|
+
if (state.phase !== 'healthy') {
|
|
89
|
+
return state.phase;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return 'healthy';
|
|
93
|
+
}
|
|
94
|
+
getHealth(groupID) {
|
|
95
|
+
return {
|
|
96
|
+
state: this.getState(groupID),
|
|
97
|
+
lastTransitionAt: this.#lastTransitionAt.get(groupID) ?? null
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
#stamp(groupID) {
|
|
101
|
+
this.#lastTransitionAt.set(groupID, this.#now());
|
|
102
|
+
}
|
|
103
|
+
#thresholdFor(condition) {
|
|
104
|
+
// Only `epoch-stale` exists today; switch when more conditions land.
|
|
105
|
+
return condition === 'epoch-stale' ? this.#epochStaleThreshold : Number.POSITIVE_INFINITY;
|
|
106
|
+
}
|
|
107
|
+
#ensure(groupID, condition) {
|
|
108
|
+
let conditions = this.#groups.get(groupID);
|
|
109
|
+
if (conditions == null) {
|
|
110
|
+
conditions = new Map();
|
|
111
|
+
this.#groups.set(groupID, conditions);
|
|
112
|
+
}
|
|
113
|
+
let state = conditions.get(condition);
|
|
114
|
+
if (state == null) {
|
|
115
|
+
state = {
|
|
116
|
+
phase: 'healthy',
|
|
117
|
+
count: 0
|
|
118
|
+
};
|
|
119
|
+
conditions.set(condition, state);
|
|
120
|
+
}
|
|
121
|
+
return state;
|
|
122
|
+
}
|
|
123
|
+
#emit(groupID, condition, state) {
|
|
124
|
+
// Enkaku's `emit` rethrows a throwing subscriber; catch so it can't leak an
|
|
125
|
+
// unhandled rejection.
|
|
126
|
+
this.#emitter.emit('groupHealthChanged', {
|
|
127
|
+
groupID,
|
|
128
|
+
condition,
|
|
129
|
+
state
|
|
130
|
+
}).catch((error)=>{
|
|
131
|
+
this.#logger.error('groupHealthChanged listener failed', {
|
|
132
|
+
groupID,
|
|
133
|
+
condition,
|
|
134
|
+
state,
|
|
135
|
+
error
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type OwnIdentity } from '@kokuin/token';
|
|
2
|
+
import type { StoreProvider } from '@kubun/db';
|
|
3
|
+
import { HLC } from '@kubun/hlc';
|
|
4
|
+
import { type Logger } from '@kubun/logger';
|
|
5
|
+
import type { GroupMLS } from '@kumiai/rpc';
|
|
6
|
+
import type { P2PEventEmitter } from './events.js';
|
|
7
|
+
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
8
|
+
export type CreateGroupMLSParams = {
|
|
9
|
+
/** Single canonical access point for the device's MLS `GroupHandle`. */
|
|
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;
|
|
24
|
+
/**
|
|
25
|
+
* Store coordinator used to open the receive-side commit transaction so the
|
|
26
|
+
* MLS ratchet advance and the roster delta writes commit (or roll back)
|
|
27
|
+
* together. Without one transaction, a roster-write failure after the ratchet
|
|
28
|
+
* persists would silently keep a removed member's writes applying.
|
|
29
|
+
*/
|
|
30
|
+
stores: StoreProvider;
|
|
31
|
+
/**
|
|
32
|
+
* Device-wide monotonic clock for stamping the `removed_at_hlc` of members
|
|
33
|
+
* dropped (and the `hlc` of members added) by a received MLS Commit. Pass the
|
|
34
|
+
* engine's single instance so every write from this device advances one shared
|
|
35
|
+
* clock, preventing two same-millisecond writes from minting identical
|
|
36
|
+
* timestamps (the second would silently lose under last-writer-wins).
|
|
37
|
+
*/
|
|
38
|
+
hlc: HLC;
|
|
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;
|
|
48
|
+
logger?: Logger;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Adapts a `GroupHandleRegistry` group into the `@kumiai/rpc`
|
|
52
|
+
* {@link GroupMLS} lifecycle port: apply received Commits to advance the epoch,
|
|
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.
|
|
65
|
+
*/
|
|
66
|
+
export declare function createGroupMLS(params: CreateGroupMLSParams): GroupMLS;
|