@kubun/plugin-p2p 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -0,0 +1,128 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { foldAdminRoster } from './admin-roster.js';
3
+ /**
4
+ * Drop the entries whose issuer lacked admin authority at their own slot, keeping
5
+ * the rest in ledger order.
6
+ *
7
+ * INCLUSIVE compare against the ledger prefix through the entry's own position: a
8
+ * grant authorizes from its own slot onward, a demotion stops authorizing from its
9
+ * own slot onward.
10
+ */ export function authorizedEntries(entries, isAdmin) {
11
+ return entries.filter(({ verified, position })=>isAdmin(verified.issuer, position));
12
+ }
13
+ /**
14
+ * Ledger entry `type` for a circle-membership claim. Every control-ledger type
15
+ * kubun mints lives under `kubun.*` — that namespace is kubun's, and the split
16
+ * between "kubun projects this" and "the MLS library interprets this" is drawn
17
+ * on it. Namespaces the library reserves for itself are its own to name and
18
+ * rename, so kubun stays out of them and never restates them.
19
+ */ export const CIRCLE_MEMBER_ENTRY_TYPE = 'kubun.circle.member';
20
+ /**
21
+ * Deterministic composite subject for a circle member. The signed entry's
22
+ * `subject` encodes both the circle and the member, so the ledger's `subject`
23
+ * column uniquely identifies the pair and per-member LWW ordering + dedup work
24
+ * without consulting the value.
25
+ *
26
+ * Unambiguous because the member is a normalized DID (`did:method:...`), which
27
+ * never contains the `/member:` separator: every reachable separator therefore
28
+ * sits between the circle id and the DID, and the decoder splits on the last
29
+ * one. The labeled `circle:` / `/member:` prefixes also keep the encoding
30
+ * distinct from any other reducer's subject scheme.
31
+ */ export function encodeCircleMemberSubject(circleID, memberDID) {
32
+ return `circle:${circleID}/member:${normalizeDID(memberDID)}`;
33
+ }
34
+ const SUBJECT_PREFIX = 'circle:';
35
+ const MEMBER_SEPARATOR = '/member:';
36
+ /** Tolerant inverse of {@link encodeCircleMemberSubject}: null on a malformed subject. */ export function decodeCircleMemberSubject(subject) {
37
+ if (!subject.startsWith(SUBJECT_PREFIX)) {
38
+ return null;
39
+ }
40
+ const separator = subject.lastIndexOf(MEMBER_SEPARATOR);
41
+ if (separator === -1) {
42
+ return null;
43
+ }
44
+ const circleID = subject.slice(SUBJECT_PREFIX.length, separator);
45
+ const memberDID = subject.slice(separator + MEMBER_SEPARATOR.length);
46
+ if (circleID.length === 0 || memberDID.length === 0) {
47
+ return null;
48
+ }
49
+ return {
50
+ circleID,
51
+ memberDID
52
+ };
53
+ }
54
+ /** Ledger entry `type` for a circle definition (create / update / delete). */ export const CIRCLE_DEF_ENTRY_TYPE = 'kubun.circle.def';
55
+ /**
56
+ * Ledger entry `type` for a group-settings claim. Namespaced under `kubun.*`
57
+ * like every kubun control-ledger type — a bare `group.settings` sits in the
58
+ * namespace the MLS library reserves, so a commit carrying it is refused whole.
59
+ */ export const GROUP_SETTINGS_ENTRY_TYPE = 'kubun.group.settings';
60
+ /**
61
+ * Per-field last-writer-wins group settings. The signed entry's `subject` is the
62
+ * groupID directly, so no decode is needed. Replayed in the ledger's own
63
+ * enactment order, so for each field the LAST entry that SETS that field wins
64
+ * naturally.
65
+ *
66
+ * Merges PER FIELD rather than replacing the whole value the way `circle.def` /
67
+ * `circle.member` do, because these fields are touched in isolation: a name-only
68
+ * update must not clobber a previously-set description. Presence is
69
+ * `value.field !== undefined`, not `'field' in value`, so an explicit `undefined`
70
+ * means no change while `''` or an empty list CLEARS the field.
71
+ *
72
+ * Unauthorized entries never reach here — see {@link authorizedEntries}.
73
+ */ export function createGroupSettingsReducer() {
74
+ return {
75
+ type: GROUP_SETTINGS_ENTRY_TYPE,
76
+ seed: ()=>({
77
+ settings: new Map()
78
+ }),
79
+ // Every entry reaching this fold has already passed the admin gate in
80
+ // `authorizedEntries`, which judges an entry against the ledger prefix
81
+ // through its own slot. That question needs the entry's POSITION, which this
82
+ // hook is not given, so the gate cannot live here.
83
+ verifyAuthority: ()=>true,
84
+ apply: (verified, state)=>{
85
+ const settings = new Map(state.settings);
86
+ const { subject, value } = verified.entry;
87
+ const prior = settings.get(subject) ?? {};
88
+ const next = {
89
+ ...prior
90
+ };
91
+ if (value.name !== undefined) {
92
+ next.name = value.name;
93
+ }
94
+ if (value.description !== undefined) {
95
+ next.description = value.description;
96
+ }
97
+ if (value.openCircleIDs !== undefined) {
98
+ next.openCircleIDs = value.openCircleIDs;
99
+ }
100
+ if (value.commitHub !== undefined) {
101
+ next.commitHub = value.commitHub;
102
+ }
103
+ settings.set(subject, next);
104
+ return {
105
+ settings
106
+ };
107
+ }
108
+ };
109
+ }
110
+ /**
111
+ * Build an {@link IsAdminAtPosition} predicate from the group's admin-role ledger
112
+ * entries. It re-folds the admin sub-ledger through each queried slot, so the
113
+ * answer is rotation-aware (was-admin-then, not is-admin-now): a key granted
114
+ * admin at slot n authorizes entries from slot n onward, and a later demotion
115
+ * stops authorizing from its own slot onward. The compare is inclusive
116
+ * (`position <= throughPosition`), which matters only for a role entry judging
117
+ * itself — a circle entry never shares a slot with the grant that authorizes it.
118
+ *
119
+ * Callers select the role entries once (via `selectRoleEntries`) and reuse the
120
+ * returned predicate across the circle / def / settings folds.
121
+ *
122
+ * LEDGER-ADMIN ONLY — it does not check the issuer's MLS membership, so an admin
123
+ * removed from the MLS group whose admin role was never revoked on the ledger still
124
+ * has its entries folded in. Closing that needs removal to write a ledger
125
+ * revocation atomically with the roster delta; out of scope here.
126
+ */ export function adminAuthorityFromEntries(adminEntries, anchor, groupID) {
127
+ return (issuer, position)=>foldAdminRoster(adminEntries, anchor, groupID, position).admins.has(normalizeDID(issuer));
128
+ }
@@ -0,0 +1,77 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import type { Logger } from '@kubun/logger';
3
+ import type { P2PEventEmitter } from './events.js';
4
+ import type { GroupHandleRegistry } from './group-handle-registry.js';
5
+ import type { SerializedMLSGroupState } from './mls-state.js';
6
+ export type SerializeCommitJournalBlobParams = {
7
+ serializedHandle: SerializedMLSGroupState;
8
+ invitePayload?: string;
9
+ requestID?: string;
10
+ };
11
+ export declare function serializeCommitJournalBlob(params: SerializeCommitJournalBlobParams): Uint8Array;
12
+ /**
13
+ * Read just the control request id a commit journalled, if it carried one.
14
+ *
15
+ * A lost commit the host re-drives (a `ledger` whose tokens survive) is settled
16
+ * under the SAME id it was first enacted for, and that id lives only in the
17
+ * blob. This exposes it without re-implementing the codec: the deserialize stays
18
+ * one function, so the blob shape and its `reviver` are read the same way on
19
+ * every path. An absent id returns `undefined` — never a placeholder, which
20
+ * would flow as a real request and settle an unrelated row.
21
+ */
22
+ export declare function readJournalRequestID(blob: Uint8Array): string | undefined;
23
+ export type AdoptCommitJournalBlobDeps = {
24
+ registry: GroupHandleRegistry;
25
+ groupID: string;
26
+ stores: StoreProvider;
27
+ /**
28
+ * Announces the settle this adopt performs. Optional — without it the request
29
+ * still settles durably, only silently, so a caller reading the row back
30
+ * still sees the outcome.
31
+ */
32
+ emitter?: P2PEventEmitter;
33
+ logger?: Logger;
34
+ };
35
+ /**
36
+ * Adopt a journalled commit now confirmed accepted: restore its post-commit
37
+ * handle, swap it into the registry (persisting the advance), and settle the
38
+ * request it enacted with whatever artifact it produced.
39
+ *
40
+ * An invite's payload is PERSISTED here, never sent. A Welcome reaches its
41
+ * invitee out of band, and by the time a replay runs the caller that would have
42
+ * carried it there is gone — so the only thing this path can do, and the only
43
+ * thing it needs to do, is put the payload where that caller's app can still
44
+ * fetch it by request id.
45
+ *
46
+ * Idempotent, as the journal contract requires — a crash between recording
47
+ * acceptance and clearing the slot replays this. Restoring the serialized handle
48
+ * is a fixed value, `replaceHandle` installs the same advanced state again, and
49
+ * a repeated settle rewrites the same terminal row.
50
+ */
51
+ export declare function adoptCommitJournalBlob(blob: Uint8Array, deps: AdoptCommitJournalBlobDeps): Promise<void>;
52
+ export type SettleLostControlRequestDeps = {
53
+ stores: StoreProvider;
54
+ /**
55
+ * Announces the settle. Optional — without it the request still settles
56
+ * durably, only silently, so a caller reading the row back still sees the
57
+ * outcome.
58
+ */
59
+ emitter?: P2PEventEmitter;
60
+ logger?: Logger;
61
+ };
62
+ /**
63
+ * Settle the control request a lost commit was enacting as `lost`.
64
+ *
65
+ * The mirror image of {@link adoptCommitJournalBlob}: it reads the same blob a
66
+ * pending commit journalled, but a lost commit NEVER landed, so — unlike the
67
+ * accepted path — nothing here restores or advances the handle. The only thing
68
+ * recovered is the request the commit was for, so the caller holding its id
69
+ * learns the action did not happen. For a remove that notice is the admin's
70
+ * sole signal that an eviction they believe took effect did not.
71
+ *
72
+ * A blob with no request id settles NOTHING: an absent id means no request rode
73
+ * this commit, and settling a placeholder id would flow as a real request and
74
+ * mark an unrelated row lost. Idempotent like the adopt path — a repeated
75
+ * settle rewrites the same terminal row.
76
+ */
77
+ export declare function settleLostControlRequest(blob: Uint8Array, deps: SettleLostControlRequestDeps): Promise<void>;
@@ -0,0 +1,108 @@
1
+ import { settleControlRequest } from './control-request.js';
2
+ import { restoreMLSGroupHandle } from './mls-group-handle.js';
3
+ import { replacer, reviver } from './mls-json.js';
4
+ export function serializeCommitJournalBlob(params) {
5
+ const blob = {
6
+ serializedHandle: params.serializedHandle,
7
+ ...params.invitePayload != null ? {
8
+ invitePayload: params.invitePayload
9
+ } : {},
10
+ ...params.requestID != null ? {
11
+ requestID: params.requestID
12
+ } : {}
13
+ };
14
+ return new TextEncoder().encode(JSON.stringify(blob, replacer));
15
+ }
16
+ function deserializeCommitJournalBlob(blob) {
17
+ const json = new TextDecoder().decode(blob);
18
+ return JSON.parse(json, reviver);
19
+ }
20
+ /**
21
+ * Read just the control request id a commit journalled, if it carried one.
22
+ *
23
+ * A lost commit the host re-drives (a `ledger` whose tokens survive) is settled
24
+ * under the SAME id it was first enacted for, and that id lives only in the
25
+ * blob. This exposes it without re-implementing the codec: the deserialize stays
26
+ * one function, so the blob shape and its `reviver` are read the same way on
27
+ * every path. An absent id returns `undefined` — never a placeholder, which
28
+ * would flow as a real request and settle an unrelated row.
29
+ */ export function readJournalRequestID(blob) {
30
+ return deserializeCommitJournalBlob(blob).requestID;
31
+ }
32
+ /**
33
+ * Adopt a journalled commit now confirmed accepted: restore its post-commit
34
+ * handle, swap it into the registry (persisting the advance), and settle the
35
+ * request it enacted with whatever artifact it produced.
36
+ *
37
+ * An invite's payload is PERSISTED here, never sent. A Welcome reaches its
38
+ * invitee out of band, and by the time a replay runs the caller that would have
39
+ * carried it there is gone — so the only thing this path can do, and the only
40
+ * thing it needs to do, is put the payload where that caller's app can still
41
+ * fetch it by request id.
42
+ *
43
+ * Idempotent, as the journal contract requires — a crash between recording
44
+ * acceptance and clearing the slot replays this. Restoring the serialized handle
45
+ * is a fixed value, `replaceHandle` installs the same advanced state again, and
46
+ * a repeated settle rewrites the same terminal row.
47
+ */ export async function adoptCommitJournalBlob(blob, deps) {
48
+ const { serializedHandle, invitePayload, requestID } = deserializeCommitJournalBlob(blob);
49
+ const handle = await restoreMLSGroupHandle({
50
+ serialized: serializedHandle
51
+ });
52
+ await deps.registry.replaceHandle(deps.groupID, handle, {
53
+ stores: deps.stores
54
+ });
55
+ // Settling here is what closes the loop for a request whose process died
56
+ // between publishing the commit and adopting it: the commit landed, so its
57
+ // request landed with it, and the app learns the outcome under the id it has
58
+ // been holding since before the restart. Idempotent like the rest of this
59
+ // function — a repeated adopt rewrites the same terminal row.
60
+ if (requestID != null) {
61
+ await settleControlRequest({
62
+ stores: deps.stores,
63
+ requestID,
64
+ outcome: 'committed',
65
+ ...invitePayload != null ? {
66
+ result: invitePayload
67
+ } : {},
68
+ ...deps.emitter != null ? {
69
+ emitter: deps.emitter
70
+ } : {},
71
+ ...deps.logger != null ? {
72
+ logger: deps.logger
73
+ } : {}
74
+ });
75
+ }
76
+ }
77
+ /**
78
+ * Settle the control request a lost commit was enacting as `lost`.
79
+ *
80
+ * The mirror image of {@link adoptCommitJournalBlob}: it reads the same blob a
81
+ * pending commit journalled, but a lost commit NEVER landed, so — unlike the
82
+ * accepted path — nothing here restores or advances the handle. The only thing
83
+ * recovered is the request the commit was for, so the caller holding its id
84
+ * learns the action did not happen. For a remove that notice is the admin's
85
+ * sole signal that an eviction they believe took effect did not.
86
+ *
87
+ * A blob with no request id settles NOTHING: an absent id means no request rode
88
+ * this commit, and settling a placeholder id would flow as a real request and
89
+ * mark an unrelated row lost. Idempotent like the adopt path — a repeated
90
+ * settle rewrites the same terminal row.
91
+ */ export async function settleLostControlRequest(blob, deps) {
92
+ const { requestID } = deserializeCommitJournalBlob(blob);
93
+ if (requestID == null) {
94
+ deps.logger?.debug('lost commit carried no control request to settle');
95
+ return;
96
+ }
97
+ await settleControlRequest({
98
+ stores: deps.stores,
99
+ requestID,
100
+ outcome: 'lost',
101
+ ...deps.emitter != null ? {
102
+ emitter: deps.emitter
103
+ } : {},
104
+ ...deps.logger != null ? {
105
+ logger: deps.logger
106
+ } : {}
107
+ });
108
+ }
@@ -0,0 +1,14 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import type { CommitJournal } from '@kumiai/rpc';
3
+ /**
4
+ * The `@kumiai/rpc` `CommitJournal` port for one group, backed by the device's
5
+ * single-slot commit-journal store. The port carries no group scope — the group
6
+ * a peer commits to is fixed for its lifetime — so the adapter closes over
7
+ * `groupID` and threads it into every store call.
8
+ *
9
+ * `get` maps the store's raw selectable (snake_case, decoded blobs) to the port's
10
+ * `JournalEntry`: an absent row is `null`, a null `accepted_as` omits `acceptedAs`
11
+ * (the outcome is unknown until the hub answers), and `commit`/`journal` are
12
+ * copied into fresh `Uint8Array`s the port owns.
13
+ */
14
+ export declare function createCommitJournal(stores: StoreProvider, groupID: string): CommitJournal;
@@ -0,0 +1,65 @@
1
+ import { getP2PStore } from '@kubun/store-p2p';
2
+ /**
3
+ * The `@kumiai/rpc` `CommitJournal` port for one group, backed by the device's
4
+ * single-slot commit-journal store. The port carries no group scope — the group
5
+ * a peer commits to is fixed for its lifetime — so the adapter closes over
6
+ * `groupID` and threads it into every store call.
7
+ *
8
+ * `get` maps the store's raw selectable (snake_case, decoded blobs) to the port's
9
+ * `JournalEntry`: an absent row is `null`, a null `accepted_as` omits `acceptedAs`
10
+ * (the outcome is unknown until the hub answers), and `commit`/`journal` are
11
+ * copied into fresh `Uint8Array`s the port owns.
12
+ */ export function createCommitJournal(stores, groupID) {
13
+ return {
14
+ async put (entry) {
15
+ const store = await getP2PStore(stores);
16
+ await store.putCommitJournalEntry({
17
+ groupID,
18
+ publishID: entry.publishID,
19
+ expectedHead: entry.expectedHead,
20
+ epoch: entry.epoch,
21
+ ...entry.acceptedAs != null ? {
22
+ acceptedAs: entry.acceptedAs
23
+ } : {},
24
+ commit: entry.commit,
25
+ bodies: entry.bodies,
26
+ kind: entry.kind,
27
+ journal: entry.journal
28
+ });
29
+ },
30
+ async markAccepted (publishID, sequenceID) {
31
+ const store = await getP2PStore(stores);
32
+ await store.markCommitJournalAccepted({
33
+ groupID,
34
+ publishID,
35
+ sequenceID
36
+ });
37
+ },
38
+ async get () {
39
+ const store = await getP2PStore(stores);
40
+ const row = await store.getCommitJournalEntry(groupID);
41
+ if (row == null) {
42
+ return null;
43
+ }
44
+ return {
45
+ publishID: row.publish_id,
46
+ expectedHead: row.expected_head,
47
+ epoch: row.epoch,
48
+ ...row.accepted_as != null ? {
49
+ acceptedAs: row.accepted_as
50
+ } : {},
51
+ commit: new Uint8Array(row.commit),
52
+ bodies: row.bodies,
53
+ kind: row.kind,
54
+ journal: new Uint8Array(row.journal)
55
+ };
56
+ },
57
+ async clear (publishID) {
58
+ const store = await getP2PStore(stores);
59
+ await store.clearCommitJournalEntry({
60
+ groupID,
61
+ publishID
62
+ });
63
+ }
64
+ };
65
+ }
@@ -0,0 +1,66 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import type { Logger } from '@kubun/logger';
3
+ import { type ControlRequest, type ControlRequestOutcome } from '@kubun/store-p2p';
4
+ import type { ControlRequestData } from '../types.js';
5
+ import type { P2PEventEmitter } from './events.js';
6
+ /**
7
+ * Project a stored control-request row into its API-facing shape. `kind` and
8
+ * `status` are stored as bare text (no CHECK constraint), so the store types
9
+ * them as `string`; only this store's own writers ever produce them, and they
10
+ * write nothing outside the two unions.
11
+ */
12
+ export declare function toControlRequestData(row: ControlRequest): ControlRequestData;
13
+ export type SettleControlRequestParams = {
14
+ stores: StoreProvider;
15
+ requestID: string;
16
+ outcome: ControlRequestOutcome;
17
+ error?: string;
18
+ /**
19
+ * The artifact this request produced. Written in the same update as the
20
+ * status and BEFORE the announcement, so a caller woken by the settle always
21
+ * finds it there.
22
+ */
23
+ result?: string;
24
+ emitter?: P2PEventEmitter;
25
+ logger?: Logger;
26
+ };
27
+ /**
28
+ * Record a control request's terminal outcome and announce it.
29
+ *
30
+ * The row is RETAINED — a client holding the id must be able to read the
31
+ * outcome back — so this only moves `status`/`error`/`settledAt`; the row
32
+ * leaves on ack alone. The announcement carries the row as it was just
33
+ * written, read back rather than reconstructed, so a subscriber and a later
34
+ * query never disagree about the outcome.
35
+ *
36
+ * Settling an id with no row (already acked, or never enqueued) is a silent
37
+ * no-op that announces nothing: there is no outcome for anyone to observe.
38
+ *
39
+ * Never throws. Callers are the commit lane and the drive-failure path, where
40
+ * the outcome has already happened — a bookkeeping fault here cannot un-happen
41
+ * it, and must not turn an accepted commit into a lane error.
42
+ */
43
+ export declare function settleControlRequest(params: SettleControlRequestParams): Promise<void>;
44
+ export type AwaitControlRequestSettledParams = {
45
+ stores: StoreProvider;
46
+ emitter: P2PEventEmitter;
47
+ requestID: string;
48
+ /** Rejects once this many milliseconds pass with the request still pending. */
49
+ timeoutMs: number;
50
+ };
51
+ /**
52
+ * Resolve when a control request reaches a terminal status.
53
+ *
54
+ * Both halves are load-bearing. The subscription alone loses every request that
55
+ * settles before the caller gets here — the common case for a fast lane — and
56
+ * the store read alone cannot observe a settle that has not happened yet. The
57
+ * listener is registered BEFORE the read so there is no window between them
58
+ * where a settle is neither in the row nor in the stream.
59
+ *
60
+ * The timeout is mandatory and REJECTS: callers await this inside a mutation,
61
+ * where an unbounded wait is a request that never answers. A request with no
62
+ * row (never enqueued, or already acked) is indistinguishable from one whose
63
+ * enqueue has not committed yet, so it waits rather than failing fast, and the
64
+ * timeout is what ends it.
65
+ */
66
+ export declare function awaitControlRequestSettled(params: AwaitControlRequestSettledParams): Promise<ControlRequestData>;
@@ -0,0 +1,101 @@
1
+ import { getP2PStore } from '@kubun/store-p2p';
2
+ /**
3
+ * Project a stored control-request row into its API-facing shape. `kind` and
4
+ * `status` are stored as bare text (no CHECK constraint), so the store types
5
+ * them as `string`; only this store's own writers ever produce them, and they
6
+ * write nothing outside the two unions.
7
+ */ export function toControlRequestData(row) {
8
+ return {
9
+ id: row.id,
10
+ groupID: row.group_id,
11
+ kind: row.kind,
12
+ status: row.status,
13
+ error: row.error,
14
+ enqueuedAt: row.enqueued_at,
15
+ settledAt: row.settled_at
16
+ };
17
+ }
18
+ /**
19
+ * Record a control request's terminal outcome and announce it.
20
+ *
21
+ * The row is RETAINED — a client holding the id must be able to read the
22
+ * outcome back — so this only moves `status`/`error`/`settledAt`; the row
23
+ * leaves on ack alone. The announcement carries the row as it was just
24
+ * written, read back rather than reconstructed, so a subscriber and a later
25
+ * query never disagree about the outcome.
26
+ *
27
+ * Settling an id with no row (already acked, or never enqueued) is a silent
28
+ * no-op that announces nothing: there is no outcome for anyone to observe.
29
+ *
30
+ * Never throws. Callers are the commit lane and the drive-failure path, where
31
+ * the outcome has already happened — a bookkeeping fault here cannot un-happen
32
+ * it, and must not turn an accepted commit into a lane error.
33
+ */ export async function settleControlRequest(params) {
34
+ try {
35
+ const store = await getP2PStore(params.stores);
36
+ await store.settleControlRequest({
37
+ id: params.requestID,
38
+ outcome: params.outcome,
39
+ ...params.error != null ? {
40
+ error: params.error
41
+ } : {},
42
+ ...params.result != null ? {
43
+ result: params.result
44
+ } : {},
45
+ settledAt: new Date().toISOString()
46
+ });
47
+ const row = await store.getControlRequest(params.requestID);
48
+ if (row == null || params.emitter == null) {
49
+ return;
50
+ }
51
+ await params.emitter.emit('controlRequestSettled', toControlRequestData(row));
52
+ } catch (error) {
53
+ params.logger?.error('settling a control request failed', {
54
+ requestID: params.requestID,
55
+ outcome: params.outcome,
56
+ error
57
+ });
58
+ }
59
+ }
60
+ /**
61
+ * Resolve when a control request reaches a terminal status.
62
+ *
63
+ * Both halves are load-bearing. The subscription alone loses every request that
64
+ * settles before the caller gets here — the common case for a fast lane — and
65
+ * the store read alone cannot observe a settle that has not happened yet. The
66
+ * listener is registered BEFORE the read so there is no window between them
67
+ * where a settle is neither in the row nor in the stream.
68
+ *
69
+ * The timeout is mandatory and REJECTS: callers await this inside a mutation,
70
+ * where an unbounded wait is a request that never answers. A request with no
71
+ * row (never enqueued, or already acked) is indistinguishable from one whose
72
+ * enqueue has not committed yet, so it waits rather than failing fast, and the
73
+ * timeout is what ends it.
74
+ */ export async function awaitControlRequestSettled(params) {
75
+ const { requestID } = params;
76
+ let unsubscribe;
77
+ let timer;
78
+ try {
79
+ const settled = new Promise((resolve, reject)=>{
80
+ unsubscribe = params.emitter.on('controlRequestSettled', (data)=>{
81
+ if (data.id === requestID) {
82
+ resolve(data);
83
+ }
84
+ });
85
+ timer = setTimeout(()=>{
86
+ reject(new Error(`control request ${requestID} did not settle in ${params.timeoutMs}ms`));
87
+ }, params.timeoutMs);
88
+ });
89
+ const store = await getP2PStore(params.stores);
90
+ const row = await store.getControlRequest(requestID);
91
+ if (row != null && row.status !== 'pending') {
92
+ return toControlRequestData(row);
93
+ }
94
+ return await settled;
95
+ } finally{
96
+ unsubscribe?.();
97
+ if (timer != null) {
98
+ clearTimeout(timer);
99
+ }
100
+ }
101
+ }
@@ -1,5 +1,5 @@
1
- import { EventEmitter } from '@enkaku/event';
2
- import type { CircleData, CircleMemberData, DelegationData, GroupData, GroupHealthData, GroupMemberData, RevokedDelegationData } from '../types.js';
1
+ import { EventEmitter } from '@sozai/event';
2
+ import type { CircleData, CircleMemberData, ControlRequestData, DelegationData, GroupData, GroupHealthData, GroupMemberData, HubServerDIDChangedData, 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
@@ -51,9 +55,22 @@ export type P2PEventMap = {
51
55
  hubID: string;
52
56
  hubURL: string;
53
57
  };
58
+ /**
59
+ * The hub at this URL answers under a different DID than the device had
60
+ * recorded; the new one is adopted and the connection re-dialled with it.
61
+ */
62
+ hubServerDIDChanged: HubServerDIDChangedData;
54
63
  delegationTokenAdded: DelegationData;
55
64
  delegationTokenRevoked: RevokedDelegationData;
56
65
  groupHealthChanged: GroupHealthData;
66
+ /**
67
+ * A control request reached a terminal status; carries the settled request as
68
+ * it was written, so a subscriber and a later query agree on the outcome.
69
+ * Emitted by the commit lane when a commit lands, by the drive path when the
70
+ * lane refuses one, and by the restart adopt path for a commit whose process
71
+ * died before it could report.
72
+ */
73
+ controlRequestSettled: ControlRequestData;
57
74
  };
58
75
  export type P2PEventEmitter = EventEmitter<P2PEventMap>;
59
76
  export declare function createP2PEventEmitter(): P2PEventEmitter;
@@ -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,24 @@
1
+ import type { GroupContextExtension } from '@kumiai/mls';
2
+ import { type GroupAnchor } from '@kumiai/mls';
3
+ /**
4
+ * The anchor's opaque `app` slot as kubun writes it. `@kumiai/mls` owns the
5
+ * anchor container (creator DID, version) and never reads this payload.
6
+ *
7
+ * `recoverySecret` is base64 of 32 cryptographically-random bytes minted once at
8
+ * group creation. It is the epoch-independent seed for the non-rotating
9
+ * handshake/recovery topic: baked into the MLS GroupContext, authenticated by
10
+ * the GroupInfo signature, so every member on every epoch derives the same
11
+ * rendezvous and a peer stranded on a stale epoch can always rejoin.
12
+ */
13
+ export type GroupAnchorApp = {
14
+ recoverySecret: string;
15
+ };
16
+ /** Build the genesis-anchor extension for a group kubun is creating. */
17
+ export declare function buildKubunGroupAnchorExtension(creatorDID: string, recoverySecret: string): GroupContextExtension;
18
+ /**
19
+ * Read the recovery seed out of an anchor's `app` slot. Throws when the payload
20
+ * is missing or malformed: the anchor is written once by kubun and authenticated
21
+ * by the GroupInfo signature, so an unreadable seed is corruption, not absence,
22
+ * and a group whose seed cannot be read cannot participate in recovery.
23
+ */
24
+ export declare function readRecoverySecret(anchor: GroupAnchor): string;
@@ -0,0 +1,22 @@
1
+ import { buildCurrentGroupAnchorExtension } from '@kumiai/mls';
2
+ /** Build the genesis-anchor extension for a group kubun is creating. */ export function buildKubunGroupAnchorExtension(creatorDID, recoverySecret) {
3
+ return buildCurrentGroupAnchorExtension(creatorDID, {
4
+ recoverySecret
5
+ });
6
+ }
7
+ /**
8
+ * Read the recovery seed out of an anchor's `app` slot. Throws when the payload
9
+ * is missing or malformed: the anchor is written once by kubun and authenticated
10
+ * by the GroupInfo signature, so an unreadable seed is corruption, not absence,
11
+ * and a group whose seed cannot be read cannot participate in recovery.
12
+ */ export function readRecoverySecret(anchor) {
13
+ const app = anchor.app;
14
+ if (app == null || typeof app !== 'object') {
15
+ throw new Error('group anchor carries no application payload; cannot derive recovery secret');
16
+ }
17
+ const { recoverySecret } = app;
18
+ if (typeof recoverySecret !== 'string' || recoverySecret.length === 0) {
19
+ throw new Error('group anchor carries no recovery secret');
20
+ }
21
+ return recoverySecret;
22
+ }