@kubun/plugin-p2p 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +56 -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
package/lib/groups/manager.js
CHANGED
|
@@ -1,45 +1,219 @@
|
|
|
1
1
|
import { normalizeDID } from '@kokuin/token';
|
|
2
2
|
import { HLC } from '@kubun/hlc';
|
|
3
3
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
4
|
-
import { commitInvite, createGroup, createInvite, processWelcome, removeMember } from '@kumiai/mls';
|
|
4
|
+
import { commitInvite, commitLedgerEntries, controlCapabilities, createGroup, createInvite, foldEnvelope, processWelcome, readGroupAnchor, removeMember } from '@kumiai/mls';
|
|
5
5
|
import { toB64 } from '@sozai/codec';
|
|
6
|
+
import { toISO } from '../context/types.js';
|
|
6
7
|
import { bindHubToGroup, upsertHub } from '../hub/manager.js';
|
|
7
|
-
import { reprojectAdminRoster } from './admin-roster.js';
|
|
8
8
|
import { reprojectGroupSettings } from './circle-projection.js';
|
|
9
9
|
import { GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { serializeCommitJournalBlob } from './commit-adoption.js';
|
|
11
|
+
import { settleControlRequest } from './control-request.js';
|
|
12
|
+
import { buildKubunGroupAnchorExtension } from './group-anchor.js';
|
|
13
|
+
import { ledgerEntryDigest, signLedgerEntry, verifyLedgerEntry } from './ledger.js';
|
|
14
|
+
import { emitLedgerAffectedEvents } from './ledger-affected-events.js';
|
|
15
|
+
import { entriesSurfacedByOwnCommit, foldCommittedLedgerEntries } from './ledger-commit-fold.js';
|
|
16
|
+
import { serializeMLSGroupState } from './mls-state.js';
|
|
17
|
+
import { mirrorRosterRoles, roleFromRoster } from './roster-projection.js';
|
|
18
|
+
/**
|
|
19
|
+
* Refuse to author a Remove the group will refuse. A receiver rejects a Remove
|
|
20
|
+
* whose target is still admin in the roster the commit's OWN entries fold to, so
|
|
21
|
+
* the demotion must ride the same commit. The MLS library's pre-flight does not
|
|
22
|
+
* cover this — it checks each entry's authority, not the removal's legality.
|
|
23
|
+
*
|
|
24
|
+
* Without the check the write path fails OPEN: the author tombstones the member
|
|
25
|
+
* locally and reports success while every receiver drops the commit.
|
|
26
|
+
*
|
|
27
|
+
* Test the FOLDED roster, not the one the handle holds now — the demotion riding
|
|
28
|
+
* this commit is what makes the removal legal. A fold that rejects outright is
|
|
29
|
+
* left alone: the commit attempt fails a few lines later with the library's own
|
|
30
|
+
* precise reason.
|
|
31
|
+
*/ async function assertRemovalDemotes(handle, memberDID, tokens) {
|
|
32
|
+
const inputs = [];
|
|
33
|
+
for (const token of tokens){
|
|
34
|
+
const verified = await verifyLedgerEntry(token);
|
|
35
|
+
if (verified == null) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
inputs.push({
|
|
39
|
+
verified,
|
|
40
|
+
entryID: ledgerEntryDigest(token)
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const folded = foldEnvelope(handle.roster, inputs, handle.groupID);
|
|
44
|
+
if (!folded.ok) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (folded.roster.roles.get(normalizeDID(memberDID)) === 'admin') {
|
|
48
|
+
throw new Error(`Cannot remove ${memberDID}: still an admin in the roster this commit folds to. A demotion entry must ride the same commit.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
12
51
|
export class GroupManager {
|
|
13
52
|
#deviceID;
|
|
14
|
-
#
|
|
53
|
+
#runtime;
|
|
15
54
|
#hlc;
|
|
16
55
|
#registry;
|
|
56
|
+
#stores;
|
|
57
|
+
#emitter;
|
|
58
|
+
#logger;
|
|
17
59
|
constructor(params){
|
|
18
60
|
this.#deviceID = params.identity.id;
|
|
19
|
-
this.#
|
|
61
|
+
this.#runtime = params.runtime;
|
|
20
62
|
this.#hlc = params.hlc;
|
|
21
63
|
this.#registry = params.registry;
|
|
64
|
+
this.#stores = params.stores;
|
|
65
|
+
this.#emitter = params.emitter;
|
|
66
|
+
this.#logger = params.logger;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Mirror the roster and fold the entries a commit this device authored enacted,
|
|
70
|
+
* through the same projections a received commit's fold writes.
|
|
71
|
+
*
|
|
72
|
+
* Runs even for a commit that surfaced NO entries: a role change is folded by
|
|
73
|
+
* the MLS library into the roster rather than surfaced as an entry.
|
|
74
|
+
*
|
|
75
|
+
* Lock order is lane → registry, so this opens its own transaction after the
|
|
76
|
+
* handle swap released the mutex — no transaction may span a lane operation.
|
|
77
|
+
* Never throws: the hub has already accepted the commit.
|
|
78
|
+
*/ async #foldOwnCommittedEntries(groupID, handle, tokens) {
|
|
79
|
+
const stores = this.#stores;
|
|
80
|
+
if (stores == null) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const entries = entriesSurfacedByOwnCommit(handle, tokens);
|
|
84
|
+
try {
|
|
85
|
+
const { affected, roleChanged } = await stores.withTransaction(async (tx)=>{
|
|
86
|
+
const p2pStore = await getP2PStore(tx);
|
|
87
|
+
// The author reads roles from the same authenticated source its
|
|
88
|
+
// co-members read them from on receive: the post-commit handle's roster,
|
|
89
|
+
// folded from the genesis anchor and the entries the commit chain
|
|
90
|
+
// enacted. All reads and writes go through the transactional store so
|
|
91
|
+
// they share the transaction's single connection.
|
|
92
|
+
//
|
|
93
|
+
// This is the ONLY place the author's own device learns a role its own
|
|
94
|
+
// commit changed — the role it reports is the post-commit one, since the
|
|
95
|
+
// mirror is what makes the change true locally.
|
|
96
|
+
const members = await p2pStore.listGroupMembers(groupID);
|
|
97
|
+
const roleChanged = await mirrorRosterRoles({
|
|
98
|
+
store: p2pStore,
|
|
99
|
+
groupID,
|
|
100
|
+
roster: handle.roster,
|
|
101
|
+
memberDIDs: members.map((member)=>member.member_did),
|
|
102
|
+
members
|
|
103
|
+
});
|
|
104
|
+
if (entries.length === 0) {
|
|
105
|
+
return {
|
|
106
|
+
affected: [],
|
|
107
|
+
roleChanged
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
affected: await foldCommittedLedgerEntries({
|
|
112
|
+
handle,
|
|
113
|
+
entries,
|
|
114
|
+
p2pStore,
|
|
115
|
+
groupID,
|
|
116
|
+
hlc: this.#hlc,
|
|
117
|
+
...this.#logger != null ? {
|
|
118
|
+
logger: this.#logger
|
|
119
|
+
} : {}
|
|
120
|
+
}),
|
|
121
|
+
roleChanged
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
// Past commit: the rows the events announce are durable.
|
|
125
|
+
const emitter = this.#emitter;
|
|
126
|
+
if (emitter != null) {
|
|
127
|
+
for (const ledger of affected){
|
|
128
|
+
await emitLedgerAffectedEvents(emitter, {
|
|
129
|
+
applied: true,
|
|
130
|
+
affected: {
|
|
131
|
+
kind: 'ledger',
|
|
132
|
+
ledger
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const member of roleChanged){
|
|
137
|
+
await emitter.emit('groupMemberRoleChanged', member);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
this.#logger?.warn('folding own committed ledger entries failed', {
|
|
142
|
+
groupID,
|
|
143
|
+
error
|
|
144
|
+
});
|
|
145
|
+
}
|
|
22
146
|
}
|
|
23
147
|
async createGroup(params) {
|
|
24
|
-
const groupID = this.#getRandomID();
|
|
148
|
+
const groupID = this.#runtime.getRandomID();
|
|
25
149
|
// The creator DID is baked into the MLS GroupContext as the immutable
|
|
26
150
|
// genesis anchor, alongside a fresh 32-byte recovery seed. Both survive
|
|
27
151
|
// every epoch and are authenticated by the GroupInfo signature, so every
|
|
28
152
|
// member derives the same recovery rendezvous. Every member leaf advertises
|
|
29
153
|
// the anchor extension capability so the group can be invited to.
|
|
30
|
-
|
|
154
|
+
// Randomness comes from the host runtime, never a global: this seed is baked
|
|
155
|
+
// into the GroupContext for the life of the group and is what every member's
|
|
156
|
+
// recovery rendezvous derives from, so it is the one value that most needs
|
|
157
|
+
// the platform's own CSPRNG rather than whatever `crypto` a bundler resolved.
|
|
158
|
+
const recoverySecret = toB64(this.#runtime.getRandomValues(new Uint8Array(32)));
|
|
31
159
|
const { group } = await createGroup(params.identity, groupID, {
|
|
32
160
|
extensions: [
|
|
33
|
-
|
|
161
|
+
buildKubunGroupAnchorExtension(normalizeDID(params.identity.id), recoverySecret)
|
|
34
162
|
],
|
|
35
|
-
capabilities:
|
|
163
|
+
capabilities: controlCapabilities()
|
|
36
164
|
});
|
|
37
165
|
// Atomic register + bind: each `hubs` entry is upserted via `upsertHub`
|
|
38
|
-
// and bound to the group inside one transaction
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
166
|
+
// and bound to the group inside one transaction, so a failure on any entry
|
|
167
|
+
// rolls back the group row, the MLS state, the membership row, and any
|
|
168
|
+
// earlier hub bindings that were already added in this transaction.
|
|
169
|
+
// The group's name and description are a ledger entry like any other, so
|
|
170
|
+
// they are ENACTED by a commit rather than written straight to the store.
|
|
171
|
+
// A seeded, un-enacted entry is not an option: `isLedgerComplete` folds a
|
|
172
|
+
// head from the ids the handle holds and compares it to the authenticated
|
|
173
|
+
// head, which only moves when a commit enacts entries — so an entry the
|
|
174
|
+
// head does not cover makes the peer read itself as stranded for life.
|
|
175
|
+
//
|
|
176
|
+
// The creator commits to itself: there are no co-members yet, so the commit
|
|
177
|
+
// message goes nowhere and there is nothing to fan out. What it buys is the
|
|
178
|
+
// head moving to cover the entry, which is what makes the handle's ledger
|
|
179
|
+
// complete from the first epoch and what lets a joiner gather these settings
|
|
180
|
+
// over the authenticated ledger rather than a side channel.
|
|
42
181
|
const hubs = params.hubs ?? [];
|
|
182
|
+
const settingsHLC = HLC.serialize(this.#hlc.now());
|
|
183
|
+
// `description` is OMITTED when absent, never `''`: the settings reducer
|
|
184
|
+
// treats `''` as a real value that CLEARS the field.
|
|
185
|
+
const settingsValue = {
|
|
186
|
+
name: params.name,
|
|
187
|
+
...params.description != null ? {
|
|
188
|
+
description: params.description
|
|
189
|
+
} : {}
|
|
190
|
+
};
|
|
191
|
+
// The creator is admin by construction, so the group's commit-hub designation
|
|
192
|
+
// is authored right here, in the genesis settings the founding commit enacts:
|
|
193
|
+
// the first hub bound below is the one `insertGroupHub` auto-designates
|
|
194
|
+
// locally, and carrying it as folded group state is what lets every other
|
|
195
|
+
// device fold the SAME designation rather than each fall back to its own
|
|
196
|
+
// first-bound hub. A hubless group folds no designation and keeps using its
|
|
197
|
+
// loopback lane.
|
|
198
|
+
const firstHub = hubs[0];
|
|
199
|
+
if (firstHub != null) {
|
|
200
|
+
settingsValue.commitHub = firstHub.serverDID != null ? {
|
|
201
|
+
url: firstHub.url,
|
|
202
|
+
serverDID: firstHub.serverDID
|
|
203
|
+
} : {
|
|
204
|
+
url: firstHub.url
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const settingsToken = await signLedgerEntry(params.identity, {
|
|
208
|
+
type: GROUP_SETTINGS_ENTRY_TYPE,
|
|
209
|
+
groupID,
|
|
210
|
+
subject: groupID,
|
|
211
|
+
value: settingsValue,
|
|
212
|
+
ord: settingsHLC
|
|
213
|
+
});
|
|
214
|
+
const { newGroup: seededGroup } = await commitLedgerEntries(group, [
|
|
215
|
+
settingsToken
|
|
216
|
+
]);
|
|
43
217
|
await params.stores.withTransaction(async (tx)=>{
|
|
44
218
|
const store = await getP2PStore(tx);
|
|
45
219
|
await store.createGroup({
|
|
@@ -51,67 +225,49 @@ export class GroupManager {
|
|
|
51
225
|
});
|
|
52
226
|
await this.#registry.seed({
|
|
53
227
|
groupID,
|
|
54
|
-
handle:
|
|
228
|
+
handle: seededGroup,
|
|
55
229
|
stores: tx
|
|
56
230
|
});
|
|
57
231
|
// The membership row must exist before projection (setGroupMemberRole is
|
|
58
|
-
// update-only); the role value here is irrelevant —
|
|
59
|
-
//
|
|
232
|
+
// update-only); the role value here is irrelevant — the roster mirror
|
|
233
|
+
// below overwrites it.
|
|
60
234
|
await store.addGroupMember({
|
|
61
235
|
group_id: groupID,
|
|
62
236
|
member_did: params.identity.id,
|
|
63
237
|
role: 'member',
|
|
64
238
|
hlc: HLC.serialize(this.#hlc.now())
|
|
65
239
|
});
|
|
66
|
-
// Derive the creator's admin role from the genesis anchor: folding the
|
|
67
|
-
// (empty) ledger to its seed {creator} projects the creator to 'admin'.
|
|
68
240
|
const anchor = readGroupAnchor(group);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// group row already holds and sets the settings watermark.
|
|
82
|
-
const settingsHLC = HLC.serialize(this.#hlc.now());
|
|
83
|
-
const settingsValue = {
|
|
84
|
-
name: params.name,
|
|
85
|
-
description: params.description ?? ''
|
|
86
|
-
};
|
|
87
|
-
const settingsToken = await signLedgerEntry(params.identity, {
|
|
88
|
-
type: GROUP_SETTINGS_ENTRY_TYPE,
|
|
89
|
-
subject: groupID,
|
|
90
|
-
value: settingsValue,
|
|
91
|
-
hlc: settingsHLC
|
|
92
|
-
});
|
|
93
|
-
await store.appendLedgerEntry({
|
|
94
|
-
group_id: groupID,
|
|
95
|
-
entry_id: ledgerEntryDigest(settingsToken),
|
|
96
|
-
type: GROUP_SETTINGS_ENTRY_TYPE,
|
|
97
|
-
subject: groupID,
|
|
98
|
-
value: settingsValue,
|
|
99
|
-
hlc: settingsHLC,
|
|
100
|
-
issuer_did: params.identity.id,
|
|
101
|
-
signed_token: settingsToken
|
|
241
|
+
// The handle's roster is authoritative from the first epoch: it folds the
|
|
242
|
+
// same genesis anchor, so the creator reads as admin here without the
|
|
243
|
+
// group holding a single ledger entry. Mirroring it is what makes every
|
|
244
|
+
// later epoch's role a continuation of the authenticated chain rather than
|
|
245
|
+
// of a separately delivered token.
|
|
246
|
+
await mirrorRosterRoles({
|
|
247
|
+
store,
|
|
248
|
+
groupID,
|
|
249
|
+
roster: group.roster,
|
|
250
|
+
memberDIDs: [
|
|
251
|
+
params.identity.id
|
|
252
|
+
]
|
|
102
253
|
});
|
|
103
254
|
if (anchor != null) {
|
|
255
|
+
// The seeded handle's own log — it holds exactly the settings entry the
|
|
256
|
+
// creation commit enacted, which is the whole ledger a group has at
|
|
257
|
+
// creation.
|
|
104
258
|
await reprojectGroupSettings({
|
|
105
259
|
store,
|
|
106
260
|
groupID,
|
|
107
|
-
anchor
|
|
261
|
+
anchor,
|
|
262
|
+
ledger: seededGroup.ledger
|
|
108
263
|
});
|
|
109
264
|
}
|
|
110
265
|
for (const hub of hubs){
|
|
111
266
|
const upserted = await upsertHub({
|
|
112
267
|
stores: tx,
|
|
113
268
|
url: hub.url,
|
|
114
|
-
serverDID: hub.serverDID ?? null
|
|
269
|
+
serverDID: hub.serverDID ?? null,
|
|
270
|
+
runtime: this.#runtime
|
|
115
271
|
});
|
|
116
272
|
await bindHubToGroup({
|
|
117
273
|
stores: tx,
|
|
@@ -124,18 +280,48 @@ export class GroupManager {
|
|
|
124
280
|
groupID
|
|
125
281
|
};
|
|
126
282
|
}
|
|
127
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Add a member by committing directly against the local handle. TEST-ONLY.
|
|
285
|
+
*
|
|
286
|
+
* Production invites ride the commit lane: the Add is published to the commit
|
|
287
|
+
* hub's log under the head CAS, and the local epoch advances only once the hub
|
|
288
|
+
* accepts. This does neither — it advances and persists the epoch outright —
|
|
289
|
+
* so calling it on a hub-backed group forks that group, and the fork presents
|
|
290
|
+
* as a hang rather than an error. `git log -S "groupManager.inviteToGroup"`
|
|
291
|
+
* shows the last production caller removed by `2e901689`, when the invite
|
|
292
|
+
* moved onto the lane; `addGroupMember` was deleted rather than renamed for
|
|
293
|
+
* the same reason. Kept for the ~40 suites that drive a hubless group
|
|
294
|
+
* directly, and unreachable from outside this package: {@link GroupManager} is
|
|
295
|
+
* not exported from the plugin's entry point.
|
|
296
|
+
*/ async inviteToGroup(params) {
|
|
297
|
+
// The roster of the handle the add-commit produced. The invitee's role rides
|
|
298
|
+
// that commit, so this is the same roster every receiver folds as it applies
|
|
299
|
+
// the Add — the authenticated epoch chain's order, not a permission the
|
|
300
|
+
// caller of this method chose to hand us.
|
|
301
|
+
let postCommitRoster;
|
|
128
302
|
const opResult = await this.#registry.withHandleReplacing(params.groupID, async (handle)=>{
|
|
303
|
+
// The entries this invite ADDS to the group's ledger — its own role entry
|
|
304
|
+
// for the invitee, and nothing else. The invite carries the whole history
|
|
305
|
+
// (a joiner has nothing to fold it onto), but only what is new rides the
|
|
306
|
+
// commit, and only what is new has to reach the existing members.
|
|
307
|
+
const held = new Set(handle.ledgerTokens);
|
|
129
308
|
const { invite } = await createInvite({
|
|
130
309
|
group: handle,
|
|
131
310
|
identity: params.identity,
|
|
132
311
|
recipientDID: params.recipientDID,
|
|
133
312
|
permission: params.permission
|
|
134
313
|
});
|
|
135
|
-
const
|
|
314
|
+
const addedTokens = invite.ledgerEntries.filter((token)=>!held.has(token));
|
|
315
|
+
// The invite's ledger entries ride this commit: their content ids go in
|
|
316
|
+
// the commit's control envelope and the commit advances the ledger head
|
|
317
|
+
// by exactly those ids, so every receiver folds the invitee's role entry
|
|
318
|
+
// into its roster as it applies the Add.
|
|
319
|
+
const { commitMessage, welcomeMessage, newGroup } = await commitInvite(handle, params.recipientKeyPackage, invite);
|
|
320
|
+
postCommitRoster = newGroup.roster;
|
|
136
321
|
return {
|
|
137
322
|
result: {
|
|
138
323
|
invite,
|
|
324
|
+
addedTokens,
|
|
139
325
|
commitMessage,
|
|
140
326
|
welcomeMessage,
|
|
141
327
|
ratchetTree: newGroup.state.ratchetTree
|
|
@@ -146,34 +332,86 @@ export class GroupManager {
|
|
|
146
332
|
stores: params.stores
|
|
147
333
|
});
|
|
148
334
|
const store = await getP2PStore(params.stores);
|
|
335
|
+
// The role comes from the post-commit roster, never from the `permission`
|
|
336
|
+
// argument: the column must carry only what the commit chain enacted, so
|
|
337
|
+
// that a caller cannot mint a role the group's other members will not fold.
|
|
338
|
+
// The roster is always set here — the lane operation assigns it before
|
|
339
|
+
// returning — and `member` is the roster's own answer for an unlisted DID.
|
|
149
340
|
await store.addGroupMember({
|
|
150
341
|
group_id: params.groupID,
|
|
151
342
|
member_did: params.recipientDID,
|
|
152
|
-
role:
|
|
343
|
+
role: postCommitRoster == null ? 'member' : roleFromRoster(postCommitRoster, params.recipientDID),
|
|
153
344
|
hlc: HLC.serialize(this.#hlc.now())
|
|
154
345
|
});
|
|
155
346
|
return opResult;
|
|
156
347
|
}
|
|
157
348
|
async joinGroup(params) {
|
|
158
|
-
//
|
|
159
|
-
|
|
349
|
+
// A Welcome is delivered at-least-once — a sender that crashed between
|
|
350
|
+
// journalling its commit and delivering re-delivers rather than strand an
|
|
351
|
+
// invitee never told it was added — so this path must absorb the repeat.
|
|
352
|
+
//
|
|
353
|
+
// Decided on the EPOCH, not the local group row's status. The status answers
|
|
354
|
+
// "do I believe I am in this group", and a device is entitled to be wrong:
|
|
355
|
+
// one asleep across its own removal still reads `joined`, so its genuine
|
|
356
|
+
// re-admission was discarded as a redelivered Welcome — returning the same
|
|
357
|
+
// `undefined` a success returns, and leaving it with a handle whose roster
|
|
358
|
+
// still called it an admin.
|
|
359
|
+
//
|
|
360
|
+
// The hazard the dedupe exists for is adopting a handle FROZEN AT ITS
|
|
361
|
+
// JOINING EPOCH, which is exactly "not ahead of what I hold". So adopt only
|
|
362
|
+
// a strictly later handle: a repeat is equal-or-lower, a re-admission is
|
|
363
|
+
// later, no MLS state is a first join.
|
|
364
|
+
//
|
|
365
|
+
// Keyed on the id the Welcome DECRYPTS to; the invite's id is an
|
|
366
|
+
// unauthenticated hint. Cannot be hoisted above the join — that id is
|
|
367
|
+
// encrypted to the joiner's key — hence `processWelcome` plus this check
|
|
368
|
+
// rather than `processWelcomeOnce`, which dedupes on group id alone.
|
|
369
|
+
const p2pStore = await getP2PStore(params.stores);
|
|
370
|
+
const processed = await processWelcome({
|
|
160
371
|
identity: params.identity,
|
|
161
372
|
invite: params.invite,
|
|
162
373
|
welcome: params.welcomeMessage,
|
|
163
374
|
keyPackageBundle: params.keyPackageBundle,
|
|
164
375
|
ratchetTree: params.ratchetTree
|
|
165
376
|
});
|
|
377
|
+
const { group } = processed;
|
|
378
|
+
// Every write below keys on `params.groupID`, the hint. Disagreement would
|
|
379
|
+
// evaluate the dedupe against one group and seed the handle, roster and hub
|
|
380
|
+
// bindings onto another, so refuse rather than reconcile.
|
|
381
|
+
if (group.groupID !== params.groupID) {
|
|
382
|
+
throw new Error(`joinGroup: the invite names group ${params.groupID} but its Welcome decrypts to ${group.groupID}`);
|
|
383
|
+
}
|
|
384
|
+
const held = await p2pStore.getMLSState(group.groupID, this.#deviceID);
|
|
385
|
+
if (held != null && Number(group.epoch) <= held.epoch) {
|
|
386
|
+
// A repeat: the first delivery already seeded the handle, the Group row,
|
|
387
|
+
// the roster and the hub bindings.
|
|
388
|
+
this.#logger?.debug('welcome for an epoch already held: absorbed as a repeat', {
|
|
389
|
+
groupID: group.groupID,
|
|
390
|
+
welcomeEpoch: Number(group.epoch),
|
|
391
|
+
heldEpoch: held.epoch
|
|
392
|
+
});
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
// Held state that this Welcome is ahead of: the cached handle can decrypt
|
|
396
|
+
// nothing at the group's current epoch and must be displaced rather than
|
|
397
|
+
// defended.
|
|
398
|
+
const rejoining = held != null;
|
|
166
399
|
// Atomic register + bind: hub entries imported from the invite are
|
|
167
|
-
// upserted and bound to the group inside one transaction
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// bindings.
|
|
400
|
+
// upserted and bound to the group inside one transaction, so a failure on
|
|
401
|
+
// any entry rolls back MLS state, the (provisional) group row, the
|
|
402
|
+
// membership row, and any earlier hub bindings.
|
|
171
403
|
const hubs = params.hubs ?? [];
|
|
172
404
|
await params.stores.withTransaction(async (tx)=>{
|
|
405
|
+
// `replace` only over MLS state this Welcome is ahead of, established from
|
|
406
|
+
// the persisted epoch rather than the invite: an unauthenticated payload
|
|
407
|
+
// must not displace a live member's handle.
|
|
173
408
|
await this.#registry.seed({
|
|
174
409
|
groupID: params.groupID,
|
|
175
410
|
handle: group,
|
|
176
|
-
stores: tx
|
|
411
|
+
stores: tx,
|
|
412
|
+
...rejoining ? {
|
|
413
|
+
replace: true
|
|
414
|
+
} : {}
|
|
177
415
|
});
|
|
178
416
|
// Provision the local Group row if it does not already exist. Required
|
|
179
417
|
// so downstream group-aware components (e.g. HubRelayManager) can read
|
|
@@ -188,24 +426,33 @@ export class GroupManager {
|
|
|
188
426
|
created_by: params.identity.id,
|
|
189
427
|
hlc: HLC.serialize(this.#hlc.now())
|
|
190
428
|
});
|
|
429
|
+
} else {
|
|
430
|
+
// A row already here means this device has been in this group before and
|
|
431
|
+
// left it: joining again is this row rejoining, not a second row for the
|
|
432
|
+
// same group. The point lookup above is deliberately unfiltered so this
|
|
433
|
+
// branch can be reached at all.
|
|
434
|
+
await store.setGroupStatus(params.groupID, 'joined');
|
|
191
435
|
}
|
|
192
436
|
// Seed the full MLS roster (the store roster is the projection of the
|
|
193
437
|
// ratchet tree) so the admin gate can confirm membership of the
|
|
194
|
-
// creator/existing admins on this peer. Roles
|
|
195
|
-
//
|
|
196
|
-
//
|
|
438
|
+
// creator/existing admins on this peer. Roles come from the handle's own
|
|
439
|
+
// roster, which the Welcome's ledger populates: a joiner therefore learns
|
|
440
|
+
// who is an admin from the same authenticated fold every other member
|
|
441
|
+
// holds, at the epoch it joined, without waiting for a token to arrive.
|
|
197
442
|
const localHLC = HLC.serialize(this.#hlc.now());
|
|
443
|
+
const roster = group.roster;
|
|
198
444
|
await store.addGroupMembers(group.listMembers().map((member)=>({
|
|
199
445
|
group_id: params.groupID,
|
|
200
446
|
member_did: member.id,
|
|
201
|
-
role:
|
|
447
|
+
role: roleFromRoster(roster, member.id),
|
|
202
448
|
hlc: localHLC
|
|
203
449
|
})));
|
|
204
450
|
for (const hub of hubs){
|
|
205
451
|
const upserted = await upsertHub({
|
|
206
452
|
stores: tx,
|
|
207
453
|
url: hub.url,
|
|
208
|
-
serverDID: hub.serverDID ?? null
|
|
454
|
+
serverDID: hub.serverDID ?? null,
|
|
455
|
+
runtime: this.#runtime
|
|
209
456
|
});
|
|
210
457
|
await bindHubToGroup({
|
|
211
458
|
stores: tx,
|
|
@@ -216,53 +463,323 @@ export class GroupManager {
|
|
|
216
463
|
});
|
|
217
464
|
return undefined;
|
|
218
465
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
466
|
+
/**
|
|
467
|
+
* The admin write path for the control ledger: build a commit carrying no
|
|
468
|
+
* membership change, only the entries it enacts and the head move covering them.
|
|
469
|
+
* A role entry that never rides a commit is invisible to every other peer's
|
|
470
|
+
* roster and to the ledger head.
|
|
471
|
+
*
|
|
472
|
+
* BUILD IS SPLIT FROM DRIVE, and every `build*Commit` here shares the contract:
|
|
473
|
+
* the returned closure is re-run per lane attempt, reads the group's CURRENT
|
|
474
|
+
* handle and derives a fresh commit against it without advancing the live one,
|
|
475
|
+
* so a rebase after a lost compare-and-set frames at the up-to-date epoch.
|
|
476
|
+
* Nothing is adopted, written or handed out before `onAccepted` — adopting
|
|
477
|
+
* earlier seals the bodies under an epoch no receiver holds — so a losing
|
|
478
|
+
* attempt is discarded whole.
|
|
479
|
+
*/ buildEnactLedgerCommit(params) {
|
|
480
|
+
// Reads/writes go through the registry's own connection, not a request
|
|
481
|
+
// transaction: the lane runs decoupled from the resolver that started it —
|
|
482
|
+
// possibly after that transaction has committed and across rebases — so it
|
|
483
|
+
// must observe durable state, not an in-flight write set.
|
|
484
|
+
return ()=>this.#registry.readHandle(params.groupID, async (handle)=>{
|
|
485
|
+
const { commitMessage, newGroup } = await commitLedgerEntries(handle, params.tokens);
|
|
486
|
+
return {
|
|
487
|
+
commit: commitMessage,
|
|
488
|
+
bodies: params.tokens,
|
|
489
|
+
kind: 'ledger',
|
|
490
|
+
journal: serializeCommitJournalBlob({
|
|
491
|
+
serializedHandle: serializeMLSGroupState(newGroup),
|
|
492
|
+
...params.requestID != null ? {
|
|
493
|
+
requestID: params.requestID
|
|
494
|
+
} : {}
|
|
495
|
+
}),
|
|
496
|
+
onAccepted: async ()=>{
|
|
497
|
+
await this.#registry.replaceHandle(params.groupID, newGroup);
|
|
498
|
+
// The handle is adopted, so this device's own committed entries fold
|
|
499
|
+
// through the SAME ingest a co-member's receive side runs on them.
|
|
500
|
+
await this.#foldOwnCommittedEntries(params.groupID, newGroup, params.tokens);
|
|
501
|
+
await this.#settleCommittedRequest(params.requestID);
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Settle the control request a landed commit enacted, if it had one, with the
|
|
508
|
+
* artifact it produced when there is one.
|
|
509
|
+
*
|
|
510
|
+
* Runs from `onAccepted` and nowhere else: the drive also returns for an
|
|
511
|
+
* attempt that never landed, and only this closure knows THIS attempt is the
|
|
512
|
+
* one the group accepted. It runs after the handle swap, so it holds no
|
|
513
|
+
* registry mutex.
|
|
514
|
+
*/ async #settleCommittedRequest(requestID, result) {
|
|
515
|
+
const stores = this.#stores;
|
|
516
|
+
if (requestID == null || stores == null) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
await settleControlRequest({
|
|
520
|
+
stores,
|
|
521
|
+
requestID,
|
|
522
|
+
outcome: 'committed',
|
|
523
|
+
...result != null ? {
|
|
524
|
+
result
|
|
525
|
+
} : {},
|
|
526
|
+
...this.#emitter != null ? {
|
|
527
|
+
emitter: this.#emitter
|
|
528
|
+
} : {},
|
|
529
|
+
...this.#logger != null ? {
|
|
530
|
+
logger: this.#logger
|
|
531
|
+
} : {}
|
|
230
532
|
});
|
|
231
|
-
const store = await getP2PStore(params.stores);
|
|
232
|
-
await store.removeGroupMember(params.groupID, params.memberDID, HLC.serialize(this.#hlc.now()));
|
|
233
|
-
return {
|
|
234
|
-
commitMessage
|
|
235
|
-
};
|
|
236
533
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
534
|
+
/**
|
|
535
|
+
* The admin membership-add path for the commit lane: build an Add commit
|
|
536
|
+
* carrying the invitee's key package plus the role entry the invite mints, and
|
|
537
|
+
* the head move covering it. Same build-vs-drive contract as
|
|
538
|
+
* {@link buildEnactLedgerCommit}.
|
|
539
|
+
*
|
|
540
|
+
* `kind` is `'invite'` because a host cannot re-issue this commit after a crash:
|
|
541
|
+
* the intent lives in the Add proposal and the KeyPackage, neither of which
|
|
542
|
+
* survives the process.
|
|
543
|
+
*/ buildInviteCommit(params) {
|
|
544
|
+
// Reads/writes go through the registry's own connection, not a request
|
|
545
|
+
// transaction: the lane runs decoupled from the resolver that started it —
|
|
546
|
+
// possibly after that transaction has committed and across rebases — so it
|
|
547
|
+
// must observe durable state, not an in-flight write set.
|
|
548
|
+
return ()=>this.#registry.readHandle(params.groupID, async (handle)=>{
|
|
549
|
+
// The entries this invite ADDS to the group's ledger — the invitee's own
|
|
550
|
+
// role entry, and nothing else. The invite carries the whole history (a
|
|
551
|
+
// joiner has nothing to fold it onto), but only what is new rides the
|
|
552
|
+
// commit, and only what is new has to reach the existing members.
|
|
553
|
+
const held = new Set(handle.ledgerTokens);
|
|
554
|
+
const { invite } = await createInvite({
|
|
555
|
+
group: handle,
|
|
556
|
+
identity: params.identity,
|
|
557
|
+
recipientDID: params.recipientDID,
|
|
558
|
+
permission: params.permission
|
|
559
|
+
});
|
|
560
|
+
const addedTokens = invite.ledgerEntries.filter((token)=>!held.has(token));
|
|
561
|
+
const { commitMessage, welcomeMessage, newGroup } = await commitInvite(handle, params.recipientKeyPackage, invite);
|
|
562
|
+
// Encoded per attempt, before the publish, so the bytes the journal
|
|
563
|
+
// carries are this attempt's own. The Welcome is inside them, which is
|
|
564
|
+
// why the blob no longer carries one separately.
|
|
565
|
+
const invitePayload = await params.encodeInvitePayload({
|
|
566
|
+
invite,
|
|
567
|
+
welcomeMessage,
|
|
568
|
+
ratchetTree: newGroup.state.ratchetTree
|
|
569
|
+
});
|
|
570
|
+
return {
|
|
571
|
+
commit: commitMessage,
|
|
572
|
+
bodies: addedTokens,
|
|
573
|
+
kind: 'invite',
|
|
574
|
+
journal: serializeCommitJournalBlob({
|
|
575
|
+
serializedHandle: serializeMLSGroupState(newGroup),
|
|
576
|
+
// The journal is the only durable copy of this payload: a host that
|
|
577
|
+
// crashed between the hub accepting this commit and the caller being
|
|
578
|
+
// answered has no way to reconstruct it, and the invitee was added
|
|
579
|
+
// to the group either way.
|
|
580
|
+
invitePayload,
|
|
581
|
+
...params.requestID != null ? {
|
|
582
|
+
requestID: params.requestID
|
|
583
|
+
} : {}
|
|
584
|
+
}),
|
|
585
|
+
onAccepted: async ()=>{
|
|
586
|
+
await this.#registry.replaceHandle(params.groupID, newGroup);
|
|
587
|
+
// The handle is adopted, so this device's own committed entries fold
|
|
588
|
+
// through the SAME ingest a co-member's receive side runs on them.
|
|
589
|
+
await this.#foldOwnCommittedEntries(params.groupID, newGroup, addedTokens);
|
|
590
|
+
// The invitee's role entry is not one of kubun's own types, so the
|
|
591
|
+
// fold above skipped it and this is its only writer. The entry's body
|
|
592
|
+
// reaches co-members on the commit's own frame; this row is the
|
|
593
|
+
// sender's copy, and the fallback a restored handle resolves from.
|
|
594
|
+
await this.#recordInvitedMember(params.groupID, newGroup, params.recipientDID);
|
|
595
|
+
if (params.onInvited != null) {
|
|
596
|
+
await params.onInvited({
|
|
597
|
+
invitePayload,
|
|
598
|
+
addedTokens
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
// The payload settles WITH the request, in one update: the same
|
|
602
|
+
// artifact, written to the same row, that a replayed adopt of this
|
|
603
|
+
// commit's journal blob would write. Both paths therefore answer the
|
|
604
|
+
// caller by the same route.
|
|
605
|
+
await this.#settleCommittedRequest(params.requestID, invitePayload);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Write the membership row for a member an accepted Add commit brought in, and
|
|
612
|
+
* announce the join.
|
|
613
|
+
*
|
|
614
|
+
* The roster comes from the handle this call is given — the ACCEPTED attempt's —
|
|
615
|
+
* never from a variable a losing attempt may have written. The role comes from
|
|
616
|
+
* that roster and never from a `permission` argument, so the column can only
|
|
617
|
+
* carry what the commit chain enacted.
|
|
618
|
+
*
|
|
619
|
+
* The author never folds its own commit, so the announcement must come from
|
|
620
|
+
* here. Every OTHER device announces the same join from its fold, so the two can
|
|
621
|
+
* never both fire on one device.
|
|
622
|
+
*/ async #recordInvitedMember(groupID, handle, recipientDID) {
|
|
623
|
+
const stores = this.#stores;
|
|
624
|
+
if (stores == null) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const store = await getP2PStore(stores);
|
|
628
|
+
const role = roleFromRoster(handle.roster, recipientDID);
|
|
629
|
+
await store.addGroupMember({
|
|
630
|
+
group_id: groupID,
|
|
631
|
+
member_did: recipientDID,
|
|
632
|
+
role,
|
|
633
|
+
hlc: HLC.serialize(this.#hlc.now())
|
|
634
|
+
});
|
|
635
|
+
const emitter = this.#emitter;
|
|
636
|
+
if (emitter == null) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// Read the row back rather than announce what was just written: the store
|
|
640
|
+
// owns `created_at`, and a row that already existed keeps its own.
|
|
641
|
+
const row = await store.getGroupMember(groupID, recipientDID);
|
|
642
|
+
if (row != null) {
|
|
643
|
+
await emitter.emit('groupMemberJoined', {
|
|
644
|
+
groupID,
|
|
645
|
+
memberDID: row.member_did,
|
|
646
|
+
role: row.role,
|
|
647
|
+
createdAt: toISO(row.created_at)
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* The admin membership-removal path for the commit lane: build a Remove commit
|
|
653
|
+
* evicting a member, carrying any demotion entry the removal must enact and the
|
|
654
|
+
* head move covering it. Same build-vs-drive contract as
|
|
655
|
+
* {@link buildEnactLedgerCommit}; the leaf index is resolved per attempt so a
|
|
656
|
+
* rebase cannot evict the leaf a superseded roster held.
|
|
657
|
+
*
|
|
658
|
+
* An admin's demotion must ride this very commit — that is what `ledgerEntries`
|
|
659
|
+
* is for, enforced by {@link assertRemovalDemotes} so a caller that forgets it
|
|
660
|
+
* fails here rather than shipping a commit the group silently drops.
|
|
661
|
+
*
|
|
662
|
+
* `kind` is `'remove'` because a host cannot re-issue this commit after a crash:
|
|
663
|
+
* the intent lives in the MLS Remove proposal, which does not survive the
|
|
664
|
+
* process.
|
|
665
|
+
*/ buildRemoveCommit(params) {
|
|
666
|
+
const tokens = params.ledgerEntries ?? [];
|
|
667
|
+
return ()=>this.#registry.readHandle(params.groupID, async (handle)=>{
|
|
668
|
+
const leafIndex = handle.findMemberLeafIndex(params.memberDID);
|
|
669
|
+
if (leafIndex == null) {
|
|
670
|
+
throw new Error(`Member ${params.memberDID} not found in MLS group`);
|
|
671
|
+
}
|
|
672
|
+
await assertRemovalDemotes(handle, params.memberDID, tokens);
|
|
673
|
+
const { commitMessage, newGroup } = await removeMember(handle, leafIndex, tokens);
|
|
674
|
+
return {
|
|
675
|
+
commit: commitMessage,
|
|
676
|
+
bodies: tokens,
|
|
677
|
+
kind: 'remove',
|
|
678
|
+
journal: serializeCommitJournalBlob({
|
|
679
|
+
serializedHandle: serializeMLSGroupState(newGroup),
|
|
680
|
+
...params.requestID != null ? {
|
|
681
|
+
requestID: params.requestID
|
|
682
|
+
} : {}
|
|
683
|
+
}),
|
|
684
|
+
onAccepted: async ()=>{
|
|
685
|
+
// Read the departing row FIRST: the roster mirror inside the fold
|
|
686
|
+
// rewrites the role from the post-commit roster, which no longer
|
|
687
|
+
// names this member, and the tombstone then puts the row out of
|
|
688
|
+
// reach. The announcement has to report the role they held while
|
|
689
|
+
// they were still a member.
|
|
690
|
+
const departing = await this.#readGroupMember(params.groupID, params.memberDID);
|
|
691
|
+
await this.#registry.replaceHandle(params.groupID, newGroup);
|
|
692
|
+
await this.#foldOwnCommittedEntries(params.groupID, newGroup, tokens);
|
|
693
|
+
await this.#recordRemovedMember(params.groupID, params.memberDID, params.hlc, departing);
|
|
694
|
+
await this.#settleCommittedRequest(params.requestID);
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Tombstone the member an accepted Remove commit evicted, and announce it.
|
|
701
|
+
*
|
|
702
|
+
* `departing` is passed in because both the roster mirror and the tombstone have
|
|
703
|
+
* erased the role the announcement must report.
|
|
704
|
+
*
|
|
705
|
+
* The tombstone carries the caller's HLC, shared with the demotion entry riding
|
|
706
|
+
* this same commit, so a fold drops exactly the ex-admin's entries signed at or
|
|
707
|
+
* after the eviction.
|
|
708
|
+
*
|
|
709
|
+
* The author never folds its own commit, so the announcement must come from
|
|
710
|
+
* here; every OTHER device announces the same removal from its fold.
|
|
711
|
+
*/ async #recordRemovedMember(groupID, memberDID, hlc, departing) {
|
|
712
|
+
const stores = this.#stores;
|
|
713
|
+
if (stores == null) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const store = await getP2PStore(stores);
|
|
717
|
+
await store.removeGroupMember(groupID, memberDID, hlc);
|
|
718
|
+
// The committer never folds its own commit, so the discovery rows every
|
|
719
|
+
// other device drops in its fold have to be dropped here too.
|
|
720
|
+
await store.deletePeerAnnouncements({
|
|
721
|
+
groupID,
|
|
722
|
+
peerDIDs: [
|
|
723
|
+
memberDID
|
|
724
|
+
]
|
|
725
|
+
});
|
|
726
|
+
const emitter = this.#emitter;
|
|
727
|
+
if (emitter == null || departing == null) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
await emitter.emit('groupMemberLeft', {
|
|
731
|
+
groupID,
|
|
732
|
+
memberDID: departing.member_did,
|
|
733
|
+
role: departing.role,
|
|
734
|
+
createdAt: toISO(departing.created_at)
|
|
252
735
|
});
|
|
736
|
+
}
|
|
737
|
+
/** The membership row as it stands, or `undefined` when nothing is wired. */ async #readGroupMember(groupID, memberDID) {
|
|
738
|
+
const stores = this.#stores;
|
|
739
|
+
if (stores == null) {
|
|
740
|
+
return undefined;
|
|
741
|
+
}
|
|
742
|
+
const store = await getP2PStore(stores);
|
|
743
|
+
return await store.getGroupMember(groupID, memberDID);
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Record locally that this device has left, without touching MLS.
|
|
747
|
+
*
|
|
748
|
+
* Split from the teardown because the two want opposite timing. What the
|
|
749
|
+
* device says about ITSELF has to be true the moment `leaveGroup` returns — a
|
|
750
|
+
* screen that navigates away must not still be told it belongs to the group —
|
|
751
|
+
* while the departure announcement must reach co-members BEFORE the key it is
|
|
752
|
+
* encrypted under is deleted, and publishing cannot be awaited inside the
|
|
753
|
+
* caller's write transaction.
|
|
754
|
+
*
|
|
755
|
+
* The group row is kept and marked instead of deleted: its circles, circle
|
|
756
|
+
* members, peer announcements and hub bindings hang off it, and a device
|
|
757
|
+
* invited back would otherwise have to rebuild state it still holds.
|
|
758
|
+
*/ async markGroupLeft(params) {
|
|
253
759
|
const store = await getP2PStore(params.stores);
|
|
254
|
-
await store.removeGroupMember(params.groupID, params.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
760
|
+
await store.removeGroupMember(params.groupID, params.identity.id, params.hlc);
|
|
761
|
+
await store.setGroupStatus(params.groupID, 'left');
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Drop this device's MLS state for a group, and nothing else.
|
|
765
|
+
*
|
|
766
|
+
* The other half of {@link GroupManager.markGroupLeft}: it must run AFTER the
|
|
767
|
+
* departure announcement, because that announcement is encrypted under the key
|
|
768
|
+
* this deletes. Separate from the row writes so the caller can commit those
|
|
769
|
+
* with its own transaction and leave only this for afterwards — repeating the
|
|
770
|
+
* writes out here would have them block on a connection the caller still holds.
|
|
771
|
+
*/ async tearDownGroupMLS(stores, groupID) {
|
|
772
|
+
const store = await getP2PStore(stores);
|
|
773
|
+
await store.deleteMLSState(groupID, this.#deviceID);
|
|
774
|
+
stores.onCommit(()=>this.#registry.invalidate(groupID));
|
|
258
775
|
}
|
|
259
776
|
async leaveGroup(params) {
|
|
260
777
|
// MLS does not allow a member to commit their own removal.
|
|
261
778
|
// leaveGroup performs local cleanup: deletes MLS state and group membership.
|
|
262
|
-
// The actual MLS group removal happens when
|
|
779
|
+
// The actual MLS group removal happens when an admin drives a Remove commit.
|
|
263
780
|
const store = await getP2PStore(params.stores);
|
|
264
781
|
await store.deleteMLSState(params.groupID, this.#deviceID);
|
|
265
|
-
await
|
|
782
|
+
await this.markGroupLeft(params);
|
|
266
783
|
// Defer cache eviction to onCommit so a future caller wrapping these
|
|
267
784
|
// deletes in withTransaction does not invalidate the cache when the row
|
|
268
785
|
// delete rolls back. KubunDB's non-tx onCommit fires synchronously, so
|