@kubun/plugin-p2p 0.10.0 → 0.11.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 +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +12 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +122 -38
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +7 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +69 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +56 -0
- package/lib/groups/group-health-monitor.js +139 -0
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +29 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +4 -8
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +28 -41
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +29 -13
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +8 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +471 -2
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +73 -0
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +12 -0
- package/lib/sync/errors.js +19 -0
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.d.ts +3 -22
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -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 +19 -0
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +8 -1
- package/lib/sync/peer-registry.js +107 -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 +5 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +41 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +21 -0
- package/package.json +48 -41
- package/lib/groups/broadcast-service.d.ts +0 -189
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -32
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.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 -67
- 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 -33
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -127
- 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,198 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { foldAdminRoster } from './admin-roster.js';
|
|
3
|
+
/** Ledger entry `type` for a circle-membership claim. */ export const CIRCLE_MEMBER_ENTRY_TYPE = 'circle.member';
|
|
4
|
+
function isRemovedValue(value) {
|
|
5
|
+
return 'removed' in value && value.removed === true;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic composite subject for a circle member. The signed entry's
|
|
9
|
+
* `subject` encodes both the circle and the member, so the ledger's `subject`
|
|
10
|
+
* column uniquely identifies the pair and per-member LWW ordering + dedup work
|
|
11
|
+
* without consulting the value.
|
|
12
|
+
*
|
|
13
|
+
* Unambiguous because the member is a normalized DID (`did:method:...`), which
|
|
14
|
+
* never contains the `/member:` separator: every reachable separator therefore
|
|
15
|
+
* sits between the circle id and the DID, and the decoder splits on the last
|
|
16
|
+
* one. The labeled `circle:` / `/member:` prefixes also keep the encoding
|
|
17
|
+
* distinct from any other reducer's subject scheme.
|
|
18
|
+
*/ export function encodeCircleMemberSubject(circleID, memberDID) {
|
|
19
|
+
return `circle:${circleID}/member:${normalizeDID(memberDID)}`;
|
|
20
|
+
}
|
|
21
|
+
const SUBJECT_PREFIX = 'circle:';
|
|
22
|
+
const MEMBER_SEPARATOR = '/member:';
|
|
23
|
+
/** Tolerant inverse of {@link encodeCircleMemberSubject}: null on a malformed subject. */ export function decodeCircleMemberSubject(subject) {
|
|
24
|
+
if (!subject.startsWith(SUBJECT_PREFIX)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const separator = subject.lastIndexOf(MEMBER_SEPARATOR);
|
|
28
|
+
if (separator === -1) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const circleID = subject.slice(SUBJECT_PREFIX.length, separator);
|
|
32
|
+
const memberDID = subject.slice(separator + MEMBER_SEPARATOR.length);
|
|
33
|
+
if (circleID.length === 0 || memberDID.length === 0) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
circleID,
|
|
38
|
+
memberDID
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Per-member last-writer-wins circle roster. {@link foldLedger} sorts by the
|
|
43
|
+
* total order `(hlc, entryID)`, so each subject's highest-ordered entry is
|
|
44
|
+
* applied last and decides the outcome: an active value sets the member with its
|
|
45
|
+
* role, a tombstone removes them. Whole-value replacement means add / remove /
|
|
46
|
+
* re-add converge to the same state on every peer regardless of arrival order.
|
|
47
|
+
*
|
|
48
|
+
* Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
|
|
49
|
+
* HLC so a key authorized when it wrote stays authorized even after a later
|
|
50
|
+
* revoke. The gate is the ledger admin-fold ONLY — membership and MLS-removal
|
|
51
|
+
* are enforced outside this projection — so the fold remains synchronous and
|
|
52
|
+
* deterministic from the ledger alone.
|
|
53
|
+
*/ export function createCircleMemberReducer(isAdminAtHLC) {
|
|
54
|
+
return {
|
|
55
|
+
type: CIRCLE_MEMBER_ENTRY_TYPE,
|
|
56
|
+
seed: ()=>({
|
|
57
|
+
members: new Map()
|
|
58
|
+
}),
|
|
59
|
+
verifyAuthority: (verified)=>isAdminAtHLC(verified.issuer, verified.entry.hlc),
|
|
60
|
+
apply: (verified, state)=>{
|
|
61
|
+
const members = new Map(state.members);
|
|
62
|
+
const { subject, value } = verified.entry;
|
|
63
|
+
if (isRemovedValue(value)) {
|
|
64
|
+
members.delete(subject);
|
|
65
|
+
return {
|
|
66
|
+
members
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const decoded = decodeCircleMemberSubject(subject);
|
|
70
|
+
if (decoded == null) {
|
|
71
|
+
// Malformed subject: leave the map untouched rather than guess the pair.
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
members.set(subject, {
|
|
75
|
+
circleID: decoded.circleID,
|
|
76
|
+
memberDID: decoded.memberDID,
|
|
77
|
+
role: value.role
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
members
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Ledger entry `type` for a circle definition (create / update / delete). */ export const CIRCLE_DEF_ENTRY_TYPE = 'circle.def';
|
|
86
|
+
function isRemovedDef(value) {
|
|
87
|
+
return 'removed' in value && value.removed === true;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Per-circle last-writer-wins definition. The signed entry's `subject` is the
|
|
91
|
+
* circleID directly, so no decode is needed. {@link foldLedger} sorts by the
|
|
92
|
+
* total order `(hlc, entryID)`, so each circle's highest-ordered entry is
|
|
93
|
+
* applied last and decides the outcome: an active value sets the definition, a
|
|
94
|
+
* tombstone deletes it. Whole-value replacement means create / update / delete
|
|
95
|
+
* converge to the same state on every peer regardless of arrival order.
|
|
96
|
+
*
|
|
97
|
+
* Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
|
|
98
|
+
* HLC. The gate is the ledger admin-fold ONLY — membership and MLS-removal are
|
|
99
|
+
* enforced outside this projection — so the fold remains synchronous and
|
|
100
|
+
* deterministic from the ledger alone.
|
|
101
|
+
*/ export function createCircleDefReducer(isAdminAtHLC) {
|
|
102
|
+
return {
|
|
103
|
+
type: CIRCLE_DEF_ENTRY_TYPE,
|
|
104
|
+
seed: ()=>({
|
|
105
|
+
circles: new Map()
|
|
106
|
+
}),
|
|
107
|
+
verifyAuthority: (verified)=>isAdminAtHLC(verified.issuer, verified.entry.hlc),
|
|
108
|
+
apply: (verified, state)=>{
|
|
109
|
+
const circles = new Map(state.circles);
|
|
110
|
+
const { subject, value } = verified.entry;
|
|
111
|
+
if (isRemovedDef(value)) {
|
|
112
|
+
circles.delete(subject);
|
|
113
|
+
return {
|
|
114
|
+
circles
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
circles.set(subject, {
|
|
118
|
+
name: value.name,
|
|
119
|
+
description: value.description,
|
|
120
|
+
catalogIDs: value.catalogIDs
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
circles
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Ledger entry `type` for a group-settings claim. */ export const GROUP_SETTINGS_ENTRY_TYPE = 'group.settings';
|
|
129
|
+
/**
|
|
130
|
+
* Per-field last-writer-wins group settings. The signed entry's `subject` is the
|
|
131
|
+
* groupID directly, so no decode is needed. {@link foldLedger} sorts by the total
|
|
132
|
+
* order `(hlc, entryID)` and applies low→high, so for each field the
|
|
133
|
+
* highest-ordered entry that SETS that field wins naturally.
|
|
134
|
+
*
|
|
135
|
+
* This reducer merges per field rather than replacing the whole value the way
|
|
136
|
+
* `circle.def` / `circle.member` do: those project a single cohesive record
|
|
137
|
+
* where add / remove / re-add must converge to one state, so whole-value
|
|
138
|
+
* replacement is correct. Group settings instead accumulate independent fields
|
|
139
|
+
* (`name`, `description`) that different entries touch in isolation, so a
|
|
140
|
+
* name-only update must not clobber a previously-set description. Presence is
|
|
141
|
+
* decided by `value.field !== undefined` rather than `'field' in value`: an
|
|
142
|
+
* explicit `undefined` is treated as "not set" (no change), while `''` is a real
|
|
143
|
+
* value that clears the field.
|
|
144
|
+
*
|
|
145
|
+
* Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
|
|
146
|
+
* HLC. The gate is the ledger admin-fold ONLY — membership and MLS-removal are
|
|
147
|
+
* enforced outside this projection — so the fold remains synchronous and
|
|
148
|
+
* deterministic from the ledger alone.
|
|
149
|
+
*/ export function createGroupSettingsReducer(isAdminAtHLC) {
|
|
150
|
+
return {
|
|
151
|
+
type: GROUP_SETTINGS_ENTRY_TYPE,
|
|
152
|
+
seed: ()=>({
|
|
153
|
+
settings: new Map()
|
|
154
|
+
}),
|
|
155
|
+
verifyAuthority: (verified)=>isAdminAtHLC(verified.issuer, verified.entry.hlc),
|
|
156
|
+
apply: (verified, state)=>{
|
|
157
|
+
const settings = new Map(state.settings);
|
|
158
|
+
const { subject, value } = verified.entry;
|
|
159
|
+
const prior = settings.get(subject) ?? {};
|
|
160
|
+
const next = {
|
|
161
|
+
...prior
|
|
162
|
+
};
|
|
163
|
+
if (value.name !== undefined) {
|
|
164
|
+
next.name = value.name;
|
|
165
|
+
}
|
|
166
|
+
if (value.description !== undefined) {
|
|
167
|
+
next.description = value.description;
|
|
168
|
+
}
|
|
169
|
+
settings.set(subject, next);
|
|
170
|
+
return {
|
|
171
|
+
settings
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Build an {@link IsAdminAtHLC} predicate from the group's verified admin-role
|
|
178
|
+
* ledger entries. It re-folds the admin sub-ledger at each queried entry's HLC,
|
|
179
|
+
* so the answer is rotation-aware (was-admin-at-HLC, not is-admin-now): a key
|
|
180
|
+
* granted admin at HLC h authorizes entries at or after h, and a later revoke
|
|
181
|
+
* stops authorizing entries from that point on. `foldAdminRoster`'s `atHLC`
|
|
182
|
+
* compare is inclusive (`entry.hlc <= atHLC`), so an entry at exactly the grant
|
|
183
|
+
* HLC sees the grant and is authorized, and an entry at exactly the revoke HLC
|
|
184
|
+
* sees the revoke and is not.
|
|
185
|
+
*
|
|
186
|
+
* Callers load the verified admin entries once (via `loadVerifiedAdminEntries`)
|
|
187
|
+
* and reuse the returned predicate across the circle / def / settings folds.
|
|
188
|
+
*
|
|
189
|
+
* Authority here is ledger-admin-only: it answers "did the ledger grant this
|
|
190
|
+
* issuer admin at this HLC?" and does NOT check the issuer's MLS membership at
|
|
191
|
+
* that HLC. So an admin who was removed from the MLS group but whose admin role
|
|
192
|
+
* was never revoked on the ledger still has authorizing entries folded in.
|
|
193
|
+
* Closing that requires removal to write a ledger revocation atomically with the
|
|
194
|
+
* MLS-roster delta (the same MLS-commit/roster atomicity work that keeps a
|
|
195
|
+
* removed member's writes from applying); it is deliberately out of scope here.
|
|
196
|
+
*/ export function adminAuthorityFromEntries(adminEntries, anchor) {
|
|
197
|
+
return (issuer, hlc)=>foldAdminRoster(adminEntries, anchor, hlc).admins.has(normalizeDID(issuer));
|
|
198
|
+
}
|
package/lib/groups/events.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { EventEmitter } from '@
|
|
2
|
-
import type { CircleData, CircleMemberData, DelegationData, GroupData, GroupMemberData, RevokedDelegationData } from '../types.js';
|
|
1
|
+
import { EventEmitter } from '@sozai/event';
|
|
2
|
+
import type { CircleData, CircleMemberData, DelegationData, GroupData, GroupHealthData, GroupMemberData, RevokedDelegationData } from '../types.js';
|
|
3
3
|
export type P2PEventMap = {
|
|
4
4
|
groupJoined: GroupData;
|
|
5
5
|
groupLeft: GroupData & {
|
|
@@ -14,6 +14,10 @@ export type P2PEventMap = {
|
|
|
14
14
|
groupMemberLeft: GroupMemberData & {
|
|
15
15
|
groupID: string;
|
|
16
16
|
};
|
|
17
|
+
/** A member's group role was promoted to or demoted from admin via the ledger. */
|
|
18
|
+
groupMemberRoleChanged: GroupMemberData & {
|
|
19
|
+
groupID: string;
|
|
20
|
+
};
|
|
17
21
|
/**
|
|
18
22
|
* A member announced a voluntary leave (received `group:leaveRequest`).
|
|
19
23
|
* Advisory: MLS forbids self-removal, so the leaf lingers until an admin
|
|
@@ -53,6 +57,7 @@ export type P2PEventMap = {
|
|
|
53
57
|
};
|
|
54
58
|
delegationTokenAdded: DelegationData;
|
|
55
59
|
delegationTokenRevoked: RevokedDelegationData;
|
|
60
|
+
groupHealthChanged: GroupHealthData;
|
|
56
61
|
};
|
|
57
62
|
export type P2PEventEmitter = EventEmitter<P2PEventMap>;
|
|
58
63
|
export declare function createP2PEventEmitter(): P2PEventEmitter;
|
package/lib/groups/events.js
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
-
import{EventEmitter
|
|
1
|
+
import { EventEmitter } from '@sozai/event';
|
|
2
|
+
import { fromEmitter } from '@sozai/generator';
|
|
3
|
+
export function createP2PEventEmitter() {
|
|
4
|
+
return new EventEmitter();
|
|
5
|
+
}
|
|
6
|
+
export function createFilteredGenerator(emitter, eventName, filter) {
|
|
7
|
+
return fromEmitter(emitter, eventName, {
|
|
8
|
+
filter
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type Capabilities, type GroupContextExtension, type GroupHandle, type IncomingMessageCallback } from '@kumiai/mls';
|
|
2
|
+
/**
|
|
3
|
+
* MLS GroupContext extension type carrying the genesis anchor. A uint16 outside
|
|
4
|
+
* the MLS default extension types (1–5) and clear of every reserved GREASE
|
|
5
|
+
* value, so it can never collide with a ts-mls built-in or a probing extension.
|
|
6
|
+
*/
|
|
7
|
+
export declare const GROUP_ANCHOR_EXTENSION_TYPE = 61696;
|
|
8
|
+
/**
|
|
9
|
+
* Genesis anchor baked into the MLS GroupContext at group creation: the creator
|
|
10
|
+
* DID is the epoch-0 admin. It survives every epoch and is authenticated by the
|
|
11
|
+
* GroupInfo signature, so every joiner reads the same value. Treated as
|
|
12
|
+
* immutable for the lifetime of the group.
|
|
13
|
+
*/
|
|
14
|
+
export type GroupAnchor = {
|
|
15
|
+
creatorDID: string;
|
|
16
|
+
version: number;
|
|
17
|
+
/**
|
|
18
|
+
* Base64 of 32 cryptographically-random bytes minted once at group creation.
|
|
19
|
+
* It is the epoch-independent seed for the non-rotating handshake/recovery
|
|
20
|
+
* topic: baked into the MLS GroupContext, authenticated by the GroupInfo
|
|
21
|
+
* signature, so every member on every epoch derives the same rendezvous and a
|
|
22
|
+
* peer stranded on a stale epoch can always rejoin. Immutable for the group's
|
|
23
|
+
* whole life.
|
|
24
|
+
*/
|
|
25
|
+
recoverySecret: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function encodeGroupAnchor(anchor: GroupAnchor): Uint8Array;
|
|
28
|
+
/**
|
|
29
|
+
* Tolerant decode: returns null on malformed bytes or wrong shape. The
|
|
30
|
+
* `recoverySecret` is required and must be a non-empty string — an anchor
|
|
31
|
+
* without it cannot seed the recovery rendezvous, so a missing/empty value is a
|
|
32
|
+
* malformed anchor, not a tolerated absence.
|
|
33
|
+
*/
|
|
34
|
+
export declare function decodeGroupAnchor(bytes: Uint8Array): GroupAnchor | null;
|
|
35
|
+
export declare function buildGroupAnchorExtension(anchor: GroupAnchor): GroupContextExtension;
|
|
36
|
+
export declare function buildCurrentGroupAnchorExtension(creatorDID: string, recoverySecret: string): GroupContextExtension;
|
|
37
|
+
/**
|
|
38
|
+
* Leaf-node capabilities advertising the genesis-anchor extension. RFC 9420
|
|
39
|
+
* requires every member leaf to advertise each custom GroupContext extension
|
|
40
|
+
* type, or `commitInvite` rejects the added leaf. Pass these at both
|
|
41
|
+
* `createGroup` (creator leaf) and `createKeyPackageBundle` (invitee leaf) so an
|
|
42
|
+
* anchored group can be joined.
|
|
43
|
+
*/
|
|
44
|
+
export declare function groupAnchorCapabilities(): Capabilities;
|
|
45
|
+
/**
|
|
46
|
+
* Commit policy enforcing genesis-anchor immutability. The anchor lives in the
|
|
47
|
+
* GroupContext extensions and is written once at group creation; kubun issues no
|
|
48
|
+
* group-context-extensions proposal through any normal path, so any incoming
|
|
49
|
+
* commit or proposal carrying one is anomalous and is refused before it can
|
|
50
|
+
* alter the anchor. Refine to inspect the specific extension type if a future
|
|
51
|
+
* feature legitimately mutates non-anchor group-context extensions.
|
|
52
|
+
*/
|
|
53
|
+
export declare const anchorImmutabilityPolicy: IncomingMessageCallback;
|
|
54
|
+
/**
|
|
55
|
+
* Read the genesis anchor from a group handle. Returns null only when the anchor
|
|
56
|
+
* extension is genuinely absent (a group created before the anchor, or
|
|
57
|
+
* external). A present-but-undecodable extension is corruption, not absence, and
|
|
58
|
+
* throws — so the control gate treats it as "anchor unreadable" and fails closed
|
|
59
|
+
* rather than silently downgrading to the forgeable role-column gate. (The anchor
|
|
60
|
+
* is kubun-written, frozen by the immutability commit policy, and authenticated
|
|
61
|
+
* by the GroupInfo signature, so this is a corruption guard, not a forgery path.)
|
|
62
|
+
*/
|
|
63
|
+
export declare function readGroupAnchor(handle: GroupHandle): GroupAnchor | null;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { defaultCapabilities, defaultProposalTypes, makeCustomExtension } from '@kumiai/mls';
|
|
2
|
+
/**
|
|
3
|
+
* MLS GroupContext extension type carrying the genesis anchor. A uint16 outside
|
|
4
|
+
* the MLS default extension types (1–5) and clear of every reserved GREASE
|
|
5
|
+
* value, so it can never collide with a ts-mls built-in or a probing extension.
|
|
6
|
+
*/ export const GROUP_ANCHOR_EXTENSION_TYPE = 0xf100;
|
|
7
|
+
const CURRENT_VERSION = 1;
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const decoder = new TextDecoder();
|
|
10
|
+
export function encodeGroupAnchor(anchor) {
|
|
11
|
+
return encoder.encode(JSON.stringify(anchor));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Tolerant decode: returns null on malformed bytes or wrong shape. The
|
|
15
|
+
* `recoverySecret` is required and must be a non-empty string — an anchor
|
|
16
|
+
* without it cannot seed the recovery rendezvous, so a missing/empty value is a
|
|
17
|
+
* malformed anchor, not a tolerated absence.
|
|
18
|
+
*/ export function decodeGroupAnchor(bytes) {
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(decoder.decode(bytes));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (parsed == null || typeof parsed !== 'object') {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const record = parsed;
|
|
29
|
+
if (typeof record.creatorDID !== 'string' || typeof record.version !== 'number') {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (typeof record.recoverySecret !== 'string' || record.recoverySecret.length === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
creatorDID: record.creatorDID,
|
|
37
|
+
version: record.version,
|
|
38
|
+
recoverySecret: record.recoverySecret
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function buildGroupAnchorExtension(anchor) {
|
|
42
|
+
return makeCustomExtension({
|
|
43
|
+
extensionType: GROUP_ANCHOR_EXTENSION_TYPE,
|
|
44
|
+
extensionData: encodeGroupAnchor(anchor)
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export function buildCurrentGroupAnchorExtension(creatorDID, recoverySecret) {
|
|
48
|
+
return buildGroupAnchorExtension({
|
|
49
|
+
creatorDID,
|
|
50
|
+
version: CURRENT_VERSION,
|
|
51
|
+
recoverySecret
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Leaf-node capabilities advertising the genesis-anchor extension. RFC 9420
|
|
56
|
+
* requires every member leaf to advertise each custom GroupContext extension
|
|
57
|
+
* type, or `commitInvite` rejects the added leaf. Pass these at both
|
|
58
|
+
* `createGroup` (creator leaf) and `createKeyPackageBundle` (invitee leaf) so an
|
|
59
|
+
* anchored group can be joined.
|
|
60
|
+
*/ export function groupAnchorCapabilities() {
|
|
61
|
+
const base = defaultCapabilities();
|
|
62
|
+
return base.extensions.includes(GROUP_ANCHOR_EXTENSION_TYPE) ? base : {
|
|
63
|
+
...base,
|
|
64
|
+
extensions: [
|
|
65
|
+
...base.extensions,
|
|
66
|
+
GROUP_ANCHOR_EXTENSION_TYPE
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Commit policy enforcing genesis-anchor immutability. The anchor lives in the
|
|
72
|
+
* GroupContext extensions and is written once at group creation; kubun issues no
|
|
73
|
+
* group-context-extensions proposal through any normal path, so any incoming
|
|
74
|
+
* commit or proposal carrying one is anomalous and is refused before it can
|
|
75
|
+
* alter the anchor. Refine to inspect the specific extension type if a future
|
|
76
|
+
* feature legitimately mutates non-anchor group-context extensions.
|
|
77
|
+
*/ export const anchorImmutabilityPolicy = (incoming)=>{
|
|
78
|
+
const proposals = incoming.kind === 'commit' ? incoming.proposals : [
|
|
79
|
+
incoming.proposal
|
|
80
|
+
];
|
|
81
|
+
for (const { proposal } of proposals){
|
|
82
|
+
if (proposal.proposalType === defaultProposalTypes.group_context_extensions) {
|
|
83
|
+
return 'reject';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return 'accept';
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Read the genesis anchor from a group handle. Returns null only when the anchor
|
|
90
|
+
* extension is genuinely absent (a group created before the anchor, or
|
|
91
|
+
* external). A present-but-undecodable extension is corruption, not absence, and
|
|
92
|
+
* throws — so the control gate treats it as "anchor unreadable" and fails closed
|
|
93
|
+
* rather than silently downgrading to the forgeable role-column gate. (The anchor
|
|
94
|
+
* is kubun-written, frozen by the immutability commit policy, and authenticated
|
|
95
|
+
* by the GroupInfo signature, so this is a corruption guard, not a forgery path.)
|
|
96
|
+
*/ export function readGroupAnchor(handle) {
|
|
97
|
+
const extension = handle.state.groupContext.extensions.find((ext)=>ext.extensionType === GROUP_ANCHOR_EXTENSION_TYPE);
|
|
98
|
+
if (extension == null) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const data = extension.extensionData;
|
|
102
|
+
const anchor = data instanceof Uint8Array ? decodeGroupAnchor(data) : null;
|
|
103
|
+
if (anchor == null) {
|
|
104
|
+
throw new Error('group anchor extension present but could not be decoded');
|
|
105
|
+
}
|
|
106
|
+
return anchor;
|
|
107
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GroupCrypto } from '@kumiai/rpc';
|
|
2
|
+
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
3
|
+
/**
|
|
4
|
+
* Adapts a `GroupHandleRegistry` group into the `@kumiai/rpc`
|
|
5
|
+
* {@link GroupCrypto} port: epoch number, an epoch-bound topic-derivation
|
|
6
|
+
* secret, and byte-level encrypt/decrypt over the live MLS handle.
|
|
7
|
+
*
|
|
8
|
+
* `wrap`/`unwrap`/`exportSecret` route through `registry.readHandle` so they take
|
|
9
|
+
* the per-group mutex and observe the canonical handle, using the same framing as
|
|
10
|
+
* `MLSEncryptor` (see `mls-codec.ts`).
|
|
11
|
+
*
|
|
12
|
+
* `GroupCrypto.epoch()` is synchronous by contract, but the registry only
|
|
13
|
+
* exposes the handle behind an async, mutex-guarded `readHandle`. group-rpc
|
|
14
|
+
* always calls `epoch()` immediately after awaiting `exportSecret()` (see
|
|
15
|
+
* GroupPeer's epoch rebuild), so `exportSecret` records the epoch it read under
|
|
16
|
+
* the same lock and `epoch()` returns that cached value — no second, racy lock
|
|
17
|
+
* acquisition.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createGroupCrypto(registry: GroupHandleRegistry, groupID: string): GroupCrypto;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { toB64 } from '@sozai/codec';
|
|
2
|
+
import { mlsExporter } from 'ts-mls';
|
|
3
|
+
import { mlsDecryptFramed, mlsEncryptFramed } from './mls-codec.js';
|
|
4
|
+
// Domain-separation label for the epoch-bound topic-derivation secret. Stable so
|
|
5
|
+
// the exported secret is constant within an epoch and rotates when a commit
|
|
6
|
+
// advances the epoch (MLS re-derives exporterSecret per epoch).
|
|
7
|
+
const TOPIC_SECRET_LABEL = 'kubun/group-rpc/topic';
|
|
8
|
+
// Empty context: the epoch already varies the secret; topic derivation mixes in
|
|
9
|
+
// its own epoch/protocol/scope on top.
|
|
10
|
+
const TOPIC_SECRET_CONTEXT = new Uint8Array(0);
|
|
11
|
+
// 32-byte secret feeds an HKDF-based topic derivation; the length is otherwise
|
|
12
|
+
// unconstrained.
|
|
13
|
+
const TOPIC_SECRET_LENGTH = 32;
|
|
14
|
+
/**
|
|
15
|
+
* Adapts a `GroupHandleRegistry` group into the `@kumiai/rpc`
|
|
16
|
+
* {@link GroupCrypto} port: epoch number, an epoch-bound topic-derivation
|
|
17
|
+
* secret, and byte-level encrypt/decrypt over the live MLS handle.
|
|
18
|
+
*
|
|
19
|
+
* `wrap`/`unwrap`/`exportSecret` route through `registry.readHandle` so they take
|
|
20
|
+
* the per-group mutex and observe the canonical handle, using the same framing as
|
|
21
|
+
* `MLSEncryptor` (see `mls-codec.ts`).
|
|
22
|
+
*
|
|
23
|
+
* `GroupCrypto.epoch()` is synchronous by contract, but the registry only
|
|
24
|
+
* exposes the handle behind an async, mutex-guarded `readHandle`. group-rpc
|
|
25
|
+
* always calls `epoch()` immediately after awaiting `exportSecret()` (see
|
|
26
|
+
* GroupPeer's epoch rebuild), so `exportSecret` records the epoch it read under
|
|
27
|
+
* the same lock and `epoch()` returns that cached value — no second, racy lock
|
|
28
|
+
* acquisition.
|
|
29
|
+
*/ export function createGroupCrypto(registry, groupID) {
|
|
30
|
+
let lastEpoch = 0;
|
|
31
|
+
const exportSecret = ()=>registry.readHandle(groupID, async (handle)=>{
|
|
32
|
+
lastEpoch = Number(handle.epoch);
|
|
33
|
+
return await mlsExporter(handle.state.keySchedule.exporterSecret, TOPIC_SECRET_LABEL, TOPIC_SECRET_CONTEXT, TOPIC_SECRET_LENGTH, handle.context.cipherSuite);
|
|
34
|
+
});
|
|
35
|
+
const epoch = ()=>lastEpoch;
|
|
36
|
+
const wrap = (bytes)=>registry.readHandle(groupID, async (handle)=>{
|
|
37
|
+
const { framed } = await mlsEncryptFramed(handle, bytes);
|
|
38
|
+
return framed;
|
|
39
|
+
});
|
|
40
|
+
// In-flight decrypt cache, keyed by the ciphertext bytes.
|
|
41
|
+
//
|
|
42
|
+
// group-rpc subscribes TWO broadcast transports to each app-protocol topic —
|
|
43
|
+
// the anycast `BroadcastClient` and the `busServer` — and the hub mux fans
|
|
44
|
+
// every inbound frame to BOTH, so each frame's `unwrap` is invoked twice in the
|
|
45
|
+
// same synchronous burst. MLS application-message decryption is single-use:
|
|
46
|
+
// `handle.decrypt` consumes a forward-secret ratchet generation, so a second
|
|
47
|
+
// decrypt of the same ciphertext fails with "Desired gen in the past" and the
|
|
48
|
+
// frame never reaches the event handler. Caching the in-flight decrypt promise
|
|
49
|
+
// per ciphertext makes `unwrap` idempotent: the first call advances the ratchet
|
|
50
|
+
// once and the sibling transport (which calls before the decrypt resolves)
|
|
51
|
+
// replays the same plaintext without touching the ratchet. The entry is removed
|
|
52
|
+
// once it settles, so the cache only ever holds the handful of concurrently
|
|
53
|
+
// in-flight decrypts — no unbounded growth and never an evicted pending entry.
|
|
54
|
+
// Distinct ciphertexts always miss and decrypt normally, so forward secrecy
|
|
55
|
+
// across messages is preserved.
|
|
56
|
+
const unwrapCache = new Map();
|
|
57
|
+
const unwrap = (bytes)=>{
|
|
58
|
+
const key = toB64(bytes);
|
|
59
|
+
const cached = unwrapCache.get(key);
|
|
60
|
+
if (cached != null) {
|
|
61
|
+
return cached;
|
|
62
|
+
}
|
|
63
|
+
const promise = registry.readHandle(groupID, async (handle)=>{
|
|
64
|
+
const payload = await mlsDecryptFramed(handle, bytes);
|
|
65
|
+
return {
|
|
66
|
+
payload
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
// Drop the entry once it settles (success or failure): the sibling transport
|
|
70
|
+
// has already taken the in-flight promise, and a genuine failure must not be
|
|
71
|
+
// cached so a later valid frame with the same bytes (e.g. after a resync) can
|
|
72
|
+
// decrypt afresh.
|
|
73
|
+
unwrapCache.set(key, promise);
|
|
74
|
+
void promise.finally(()=>{
|
|
75
|
+
unwrapCache.delete(key);
|
|
76
|
+
});
|
|
77
|
+
return promise;
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
epoch,
|
|
81
|
+
exportSecret,
|
|
82
|
+
wrap,
|
|
83
|
+
unwrap
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { GroupHandle } from '@enkaku/group';
|
|
2
1
|
import type { StoreProvider } from '@kubun/db';
|
|
3
2
|
import { type Logger } from '@kubun/logger';
|
|
3
|
+
import type { GroupHandle } from '@kumiai/mls';
|
|
4
4
|
export type GroupHandleRegistryParams = {
|
|
5
5
|
stores: StoreProvider;
|
|
6
6
|
deviceID: string;
|
|
@@ -43,6 +43,17 @@ export declare class GroupHandleRegistry {
|
|
|
43
43
|
* epoch-bumping ops that produce a fresh `GroupHandle` (commitInvite,
|
|
44
44
|
* removeMember, processCommit), use `withHandleReplacing` instead.
|
|
45
45
|
*
|
|
46
|
+
* Bound: when called under a caller transaction that commits later, this
|
|
47
|
+
* mutates/eager-uses the shared cached handle and releases the mutex before
|
|
48
|
+
* that commit, so a concurrent same-group op can observe the uncommitted
|
|
49
|
+
* handle during the window. `onRollback` repairs the cache after a rollback;
|
|
50
|
+
* the live exposure is a concurrent cross-epoch decrypt during a rolling-back
|
|
51
|
+
* membership mutation, which fails the MLS check and skips its persist (a
|
|
52
|
+
* dropped message, never a durable divergence), and only on a multi-connection
|
|
53
|
+
* adapter. The transactional epoch-bumping path uses
|
|
54
|
+
* `withHandleReplacingInTransaction` (fresh handle + mutex held across commit)
|
|
55
|
+
* to avoid the window entirely.
|
|
56
|
+
*
|
|
46
57
|
* `options.stores` overrides the constructor-stored `StoreProvider` for the
|
|
47
58
|
* restore + persist DB calls. Callers running inside an engine transaction
|
|
48
59
|
* (e.g. context handlers under `mutateGraph`) MUST pass the transactional
|
|
@@ -52,10 +63,23 @@ export declare class GroupHandleRegistry {
|
|
|
52
63
|
withHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T>, options?: {
|
|
53
64
|
stores?: StoreProvider;
|
|
54
65
|
}): Promise<T>;
|
|
66
|
+
/**
|
|
67
|
+
* Acquire the canonical handle for `groupID`, run `fn` under the per-group
|
|
68
|
+
* mutex, return the result, release — WITHOUT persisting MLS state.
|
|
69
|
+
*
|
|
70
|
+
* Read-only access: takes the lock and lazy-restores the cached handle if
|
|
71
|
+
* absent, but never writes MLS state. Use this for pure reads that do not
|
|
72
|
+
* mutate the handle (anchor reads, `GroupInfo` export, credential reads). For
|
|
73
|
+
* in-place-mutating ops that advance the MLS secret tree (encrypt/decrypt),
|
|
74
|
+
* use `withHandle`, which persists the advanced state.
|
|
75
|
+
*/
|
|
76
|
+
readHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T> | T, options?: {
|
|
77
|
+
stores?: StoreProvider;
|
|
78
|
+
}): Promise<T>;
|
|
55
79
|
/**
|
|
56
80
|
* Like `withHandle`, but the callback returns `{ result, updated }`.
|
|
57
81
|
* Registry replaces its cached handle with `updated` before persisting on
|
|
58
|
-
* success. Required for `@
|
|
82
|
+
* success. Required for `@kumiai/mls` epoch ops (`commitInvite`,
|
|
59
83
|
* `removeMember`, `processCommit`) which construct a fresh `GroupHandle`
|
|
60
84
|
* rather than mutating the input.
|
|
61
85
|
*
|
|
@@ -67,11 +91,54 @@ export declare class GroupHandleRegistry {
|
|
|
67
91
|
}>, options?: {
|
|
68
92
|
stores?: StoreProvider;
|
|
69
93
|
}): Promise<T>;
|
|
94
|
+
/**
|
|
95
|
+
* Like `withHandleReplacing`, but the per-group mutex is held across the
|
|
96
|
+
* ENTIRE transaction — it is acquired once, then a transaction is opened, run,
|
|
97
|
+
* and committed under that single lock, which releases only AFTER commit (or
|
|
98
|
+
* rollback). The cached `entry.handle` is NEVER touched by this method; the
|
|
99
|
+
* cache swap is the `onCommit` invalidation.
|
|
100
|
+
*
|
|
101
|
+
* Use this when the MLS advance must commit or roll back together with other
|
|
102
|
+
* rows written in the same transaction (e.g. receive-side commit processing
|
|
103
|
+
* that advances the ratchet AND writes the roster delta). `fn` receives both
|
|
104
|
+
* the working handle and the transactional `StoreProvider` so it can write
|
|
105
|
+
* those other rows on the same connection.
|
|
106
|
+
*
|
|
107
|
+
* WHY the lock spans commit: holding the mutex until after commit means the
|
|
108
|
+
* `onCommit` invalidation fires while the lock is still held. By the time a
|
|
109
|
+
* concurrent same-group op acquires the lock, the cache is already invalidated
|
|
110
|
+
* and the committed advanced row is durable, so the concurrent op re-restores
|
|
111
|
+
* the advanced handle. There is no post-mutex / pre-commit window for a
|
|
112
|
+
* concurrent op to interleave and clobber the advance.
|
|
113
|
+
*
|
|
114
|
+
* WHY a fresh working handle rather than the cached one: epoch ops here
|
|
115
|
+
* advance the MLS ratchet IN PLACE (`handle.processMessage` mutates the handle
|
|
116
|
+
* object and returns bytes, not a fresh handle). If the callback ran against
|
|
117
|
+
* the cached `entry.handle`, that in-place mutation would advance the cache
|
|
118
|
+
* immediately — but the transaction is still uncommitted. On ROLLBACK the DB
|
|
119
|
+
* ratchet row reverts while the cached handle stays advanced, so cache and DB
|
|
120
|
+
* diverge. Restoring a throwaway working handle keeps the cache untouched: on
|
|
121
|
+
* rollback the cache still matches the reverted DB; on commit the cache is
|
|
122
|
+
* invalidated and the next access lazily re-restores the committed advanced
|
|
123
|
+
* state. This costs one extra MLS-state restore per call.
|
|
124
|
+
*/
|
|
125
|
+
withHandleReplacingInTransaction<T>(groupID: string, stores: StoreProvider, fn: (handle: GroupHandle, tx: StoreProvider) => Promise<{
|
|
126
|
+
result: T;
|
|
127
|
+
updated: GroupHandle;
|
|
128
|
+
}>): Promise<T>;
|
|
70
129
|
/**
|
|
71
130
|
* Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
|
|
72
131
|
* Called by `leaveGroup` after the MLS state row is deleted, and by tests.
|
|
73
132
|
*/
|
|
74
133
|
invalidate(groupID: string): void;
|
|
134
|
+
/**
|
|
135
|
+
* Atomically swap+persist the cached handle under the per-group mutex, so a
|
|
136
|
+
* concurrent decrypt runs strictly before (stale handle, fails harmlessly) or
|
|
137
|
+
* after (new handle), never interleaved.
|
|
138
|
+
*/
|
|
139
|
+
replaceHandle(groupID: string, handle: GroupHandle, options?: {
|
|
140
|
+
stores?: StoreProvider;
|
|
141
|
+
}): Promise<void>;
|
|
75
142
|
/**
|
|
76
143
|
* Prime the cache with a freshly-built handle and persist the initial row
|
|
77
144
|
* via the supplied transactional `StoreProvider`. Called by `createGroup`
|