@kubun/plugin-p2p 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +56 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -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
1
  import { EventEmitter } from '@sozai/event';
2
- import type { CircleData, CircleMemberData, DelegationData, GroupData, GroupHealthData, GroupMemberData, RevokedDelegationData } from '../types.js';
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 & {
@@ -55,9 +55,22 @@ export type P2PEventMap = {
55
55
  hubID: string;
56
56
  hubURL: string;
57
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;
58
63
  delegationTokenAdded: DelegationData;
59
64
  delegationTokenRevoked: RevokedDelegationData;
60
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;
61
74
  };
62
75
  export type P2PEventEmitter = EventEmitter<P2PEventMap>;
63
76
  export declare function createP2PEventEmitter(): P2PEventEmitter;
@@ -1,63 +1,24 @@
1
- import { type Capabilities, type GroupContextExtension, type GroupHandle, type IncomingMessageCallback } from '@kumiai/mls';
1
+ import type { GroupContextExtension } from '@kumiai/mls';
2
+ import { type GroupAnchor } from '@kumiai/mls';
2
3
  /**
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.
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.
6
12
  */
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
- */
13
+ export type GroupAnchorApp = {
25
14
  recoverySecret: string;
26
15
  };
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;
16
+ /** Build the genesis-anchor extension for a group kubun is creating. */
17
+ export declare function buildKubunGroupAnchorExtension(creatorDID: string, recoverySecret: string): GroupContextExtension;
54
18
  /**
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.)
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.
62
23
  */
63
- export declare function readGroupAnchor(handle: GroupHandle): GroupAnchor | null;
24
+ export declare function readRecoverySecret(anchor: GroupAnchor): string;
@@ -1,107 +1,22 @@
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,
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, {
51
4
  recoverySecret
52
5
  });
53
6
  }
54
7
  /**
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;
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');
100
16
  }
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');
17
+ const { recoverySecret } = app;
18
+ if (typeof recoverySecret !== 'string' || recoverySecret.length === 0) {
19
+ throw new Error('group anchor carries no recovery secret');
105
20
  }
106
- return anchor;
21
+ return recoverySecret;
107
22
  }
@@ -1,19 +1,42 @@
1
1
  import type { GroupCrypto } from '@kumiai/rpc';
2
+ import { type Runtime } from '@sozai/runtime';
2
3
  import type { GroupHandleRegistry } from './group-handle-registry.js';
3
4
  /**
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.
5
+ * Adapts a `GroupHandleRegistry` group into `@kumiai/rpc`'s {@link GroupCrypto}
6
+ * port: epoch, an epoch-bound topic-derivation secret, and byte-level
7
+ * encrypt/decrypt over the live MLS handle.
7
8
  *
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`).
9
+ * `wrap`/`unwrap`/`exportSecret` route through `registry.readHandle`, taking the
10
+ * per-group mutex and observing the canonical handle, framed as in
11
+ * `mls-codec.ts`. `exportSecret` passes the caller's label through untouched:
12
+ * the labels belong to the package deriving topics from the result, so
13
+ * substituting one moves every topic ID away from what the caller addressed.
11
14
  *
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.
15
+ * `sealEntries`/`openEntries` are a SECOND seal, NOT interchangeable with
16
+ * `wrap`/`unwrap`: those consume a ratchet generation and mutate the handle, so
17
+ * they cannot serve an open running inside the apply of the commit carrying the
18
+ * blob. The bytes must match `@kumiai/mls-rpc` exactly the seal is agreed
19
+ * without exchange, so a divergence is not a decode error but a group whose
20
+ * members silently stop reading each other's commits.
21
+ *
22
+ * The epoch is READ FROM THE REGISTRY, never cached: applying someone else's
23
+ * commit advances the handle without touching this file, and a stale number
24
+ * classifies every later frame as `ahead`, so the cursor skips it forever.
25
+ * `initialEpoch` covers only construction before the registry has observed a
26
+ * handle — the caller MUST read the handle's epoch first, or a returning peer
27
+ * answering `0` steps over every commit it missed. Construction is synchronous
28
+ * and eager, leaving no window to prime it afterwards.
18
29
  */
19
- export declare function createGroupCrypto(registry: GroupHandleRegistry, groupID: string): GroupCrypto;
30
+ export type GroupCryptoParams = {
31
+ registry: GroupHandleRegistry;
32
+ groupID: string;
33
+ /** The handle's CURRENT epoch — see the note above on why `0` is not a safe placeholder. */
34
+ initialEpoch: number;
35
+ /**
36
+ * Host runtime supplying the seal's randomness, so the source follows the
37
+ * platform the host is on. Pass the host's — the default constructs a SECOND
38
+ * runtime rather than propagating one.
39
+ */
40
+ runtime?: Runtime;
41
+ };
42
+ export declare function createGroupCrypto(params: GroupCryptoParams): GroupCrypto;