@kubun/plugin-p2p 0.10.1 → 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.
Files changed (145) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +3 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +105 -91
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +5 -1
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +61 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +11 -0
  28. package/lib/groups/group-health-monitor.js +139 -1
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +20 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +0 -6
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.js +198 -1
  73. package/lib/hub/wiring.d.ts +17 -48
  74. package/lib/hub/wiring.js +132 -1
  75. package/lib/index.d.ts +17 -16
  76. package/lib/index.js +392 -1
  77. package/lib/protocol.d.ts +1 -1
  78. package/lib/protocol.js +251 -1
  79. package/lib/schema.d.ts +2 -1
  80. package/lib/schema.js +439 -2
  81. package/lib/sync/authorize.d.ts +1 -1
  82. package/lib/sync/authorize.js +73 -1
  83. package/lib/sync/broadcast-queue.js +183 -1
  84. package/lib/sync/broadcast-sender.js +179 -1
  85. package/lib/sync/catalog-scope.js +68 -1
  86. package/lib/sync/errors.d.ts +3 -2
  87. package/lib/sync/errors.js +19 -1
  88. package/lib/sync/forwarder.js +277 -1
  89. package/lib/sync/handlers.js +160 -1
  90. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
  91. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  92. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  93. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  94. package/lib/sync/merkle-apply.d.ts +5 -0
  95. package/lib/sync/merkle-apply.js +194 -1
  96. package/lib/sync/merkle-channel.d.ts +2 -2
  97. package/lib/sync/merkle-channel.js +182 -1
  98. package/lib/sync/merkle-tree.js +120 -1
  99. package/lib/sync/peer-registry.d.ts +1 -1
  100. package/lib/sync/peer-registry.js +107 -1
  101. package/lib/sync/receive-access-gate.js +24 -1
  102. package/lib/sync/scope-resolver.js +41 -1
  103. package/lib/sync/sync-client.d.ts +3 -3
  104. package/lib/sync/sync-client.js +192 -1
  105. package/lib/sync/sync-manager.d.ts +2 -2
  106. package/lib/sync/sync-manager.js +272 -1
  107. package/lib/types.d.ts +11 -0
  108. package/lib/types.js +2 -1
  109. package/lib/util/mutex.js +10 -1
  110. package/lib/utils.js +21 -1
  111. package/package.json +47 -40
  112. package/lib/groups/broadcast-service.d.ts +0 -263
  113. package/lib/groups/broadcast-service.js +0 -1
  114. package/lib/groups/rejoin-codec.d.ts +0 -14
  115. package/lib/groups/rejoin-codec.js +0 -1
  116. package/lib/groups/wire-frame.d.ts +0 -34
  117. package/lib/groups/wire-frame.js +0 -1
  118. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  119. package/lib/hub/circle-catchup-requester.js +0 -1
  120. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  121. package/lib/hub/circle-catchup-responder.js +0 -1
  122. package/lib/hub/connection-pool.d.ts +0 -43
  123. package/lib/hub/connection-pool.js +0 -1
  124. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  125. package/lib/hub/epoch-stale-detector.js +0 -1
  126. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  127. package/lib/hub/forward-remote-broadcast.js +0 -1
  128. package/lib/hub/group-channel.d.ts +0 -89
  129. package/lib/hub/group-channel.js +0 -1
  130. package/lib/hub/hub-connection.d.ts +0 -96
  131. package/lib/hub/hub-connection.js +0 -1
  132. package/lib/hub/receive-handler.d.ts +0 -51
  133. package/lib/hub/receive-handler.js +0 -1
  134. package/lib/hub/rejoin-manager.d.ts +0 -78
  135. package/lib/hub/rejoin-manager.js +0 -1
  136. package/lib/hub/rejoin-responder.d.ts +0 -32
  137. package/lib/hub/rejoin-responder.js +0 -1
  138. package/lib/hub/relay-manager.d.ts +0 -142
  139. package/lib/hub/relay-manager.js +0 -1
  140. package/lib/hub/send-handler.d.ts +0 -40
  141. package/lib/hub/send-handler.js +0 -1
  142. package/lib/hub/tunnel-inbox.d.ts +0 -20
  143. package/lib/hub/tunnel-inbox.js +0 -1
  144. package/lib/hub/wait-for-gate.d.ts +0 -14
  145. 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
+ }
@@ -1,4 +1,4 @@
1
- import { EventEmitter } from '@enkaku/event';
1
+ import { EventEmitter } from '@sozai/event';
2
2
  import type { CircleData, CircleMemberData, DelegationData, GroupData, GroupHealthData, GroupMemberData, RevokedDelegationData } from '../types.js';
3
3
  export type P2PEventMap = {
4
4
  groupJoined: 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
@@ -1 +1,10 @@
1
- import{EventEmitter as e}from"@enkaku/event";import{fromEmitter as r}from"@enkaku/generator";export function createP2PEventEmitter(){return new e}export function createFilteredGenerator(e,t,n){return r(e,t,{filter:n})}
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 `@enkaku/group` epoch ops (`commitInvite`,
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,6 +91,41 @@ 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.