@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,57 @@
1
+ import type { P2PStoreAPI } from '@kubun/store-p2p';
2
+ /**
3
+ * `extensions.code` carried by every group-authority refusal, on the local
4
+ * GraphQL path and on the wire alike. The specific refusal travels beside it as
5
+ * `extensions.reason`, so one category covers many refusals without a code per
6
+ * refusal — and only a registered code may leave the device at all.
7
+ */
8
+ export declare const GROUP_CONTROL_DENIED = "KB14";
9
+ /**
10
+ * `extensions.reason` carried by every admin-gate refusal, under
11
+ * `extensions.code = 'KB14'`. An app matches on the pair rather than on the
12
+ * message, so the wording can change without breaking a caller that hides or
13
+ * disables an admin-only control.
14
+ */
15
+ export declare const NOT_GROUP_ADMIN = "NOT_GROUP_ADMIN";
16
+ /**
17
+ * `extensions.reason` carried when a group's only admin tries to leave it,
18
+ * under `extensions.code = 'KB14'`.
19
+ *
20
+ * Leaving revokes the leaver's own control authority, and that revocation has to
21
+ * be one the group accepts: the roster fold drops an entry that would leave zero
22
+ * admins, so the last admin's self-demotion could never take effect. Departing
23
+ * anyway would strand the group with an admin who holds authority everywhere and
24
+ * is present nowhere. The app's move is to promote a successor first.
25
+ */
26
+ export declare const LAST_GROUP_ADMIN = "LAST_GROUP_ADMIN";
27
+ export type RequireGroupAdminParams = {
28
+ store: P2PStoreAPI;
29
+ groupID: string;
30
+ /** The DID whose authority is being checked — the caller, not the target. */
31
+ did: string;
32
+ /** Phrase completing "only a group admin can ..." in the refusal message. */
33
+ action: string;
34
+ };
35
+ /**
36
+ * Refuse unless `did` is an admin of `groupID` right now.
37
+ *
38
+ * The answer comes from the membership row's `role`, written only by the roster
39
+ * mirror — the roster folded over the authenticated MLS epoch chain, in
40
+ * enactment order. Authority moves exactly when the commit carrying the change
41
+ * lands, and no signer can place a role change earlier by choosing its own
42
+ * timestamp.
43
+ *
44
+ * Reading the row rather than the roster is deliberate: a role lives ON a
45
+ * membership row, so reading it this way implies membership, and the mirror's
46
+ * update-only write can never conjure a row. The roster is DID-keyed and can
47
+ * carry a role for a DID holding no MLS leaf, so consulting it directly drops
48
+ * the membership half of the check silently. `isGroupAdmin` also applies the
49
+ * membership check's removal-boundary semantics, so a tombstoned member is
50
+ * never an admin.
51
+ *
52
+ * Advisory, as every local check is: the ultimate authority is each peer's own
53
+ * fold, which drops a non-admin's entry regardless. Refusing here turns a
54
+ * silently discarded write into a loud, catchable failure — a stale gate is a
55
+ * stale button, never a hole.
56
+ */
57
+ export declare function requireGroupAdmin(params: RequireGroupAdminParams): Promise<void>;
@@ -0,0 +1,57 @@
1
+ import { GraphQLError } from 'graphql';
2
+ /**
3
+ * `extensions.code` carried by every group-authority refusal, on the local
4
+ * GraphQL path and on the wire alike. The specific refusal travels beside it as
5
+ * `extensions.reason`, so one category covers many refusals without a code per
6
+ * refusal — and only a registered code may leave the device at all.
7
+ */ export const GROUP_CONTROL_DENIED = 'KB14';
8
+ /**
9
+ * `extensions.reason` carried by every admin-gate refusal, under
10
+ * `extensions.code = 'KB14'`. An app matches on the pair rather than on the
11
+ * message, so the wording can change without breaking a caller that hides or
12
+ * disables an admin-only control.
13
+ */ export const NOT_GROUP_ADMIN = 'NOT_GROUP_ADMIN';
14
+ /**
15
+ * `extensions.reason` carried when a group's only admin tries to leave it,
16
+ * under `extensions.code = 'KB14'`.
17
+ *
18
+ * Leaving revokes the leaver's own control authority, and that revocation has to
19
+ * be one the group accepts: the roster fold drops an entry that would leave zero
20
+ * admins, so the last admin's self-demotion could never take effect. Departing
21
+ * anyway would strand the group with an admin who holds authority everywhere and
22
+ * is present nowhere. The app's move is to promote a successor first.
23
+ */ export const LAST_GROUP_ADMIN = 'LAST_GROUP_ADMIN';
24
+ /**
25
+ * Refuse unless `did` is an admin of `groupID` right now.
26
+ *
27
+ * The answer comes from the membership row's `role`, written only by the roster
28
+ * mirror — the roster folded over the authenticated MLS epoch chain, in
29
+ * enactment order. Authority moves exactly when the commit carrying the change
30
+ * lands, and no signer can place a role change earlier by choosing its own
31
+ * timestamp.
32
+ *
33
+ * Reading the row rather than the roster is deliberate: a role lives ON a
34
+ * membership row, so reading it this way implies membership, and the mirror's
35
+ * update-only write can never conjure a row. The roster is DID-keyed and can
36
+ * carry a role for a DID holding no MLS leaf, so consulting it directly drops
37
+ * the membership half of the check silently. `isGroupAdmin` also applies the
38
+ * membership check's removal-boundary semantics, so a tombstoned member is
39
+ * never an admin.
40
+ *
41
+ * Advisory, as every local check is: the ultimate authority is each peer's own
42
+ * fold, which drops a non-admin's entry regardless. Refusing here turns a
43
+ * silently discarded write into a loud, catchable failure — a stale gate is a
44
+ * stale button, never a hole.
45
+ */ export async function requireGroupAdmin(params) {
46
+ const { store, groupID, did, action } = params;
47
+ if (await store.isGroupAdmin(groupID, did)) {
48
+ return;
49
+ }
50
+ throw new GraphQLError(`not authorized: only a group admin can ${action}`, {
51
+ extensions: {
52
+ code: GROUP_CONTROL_DENIED,
53
+ reason: NOT_GROUP_ADMIN,
54
+ groupID
55
+ }
56
+ });
57
+ }
@@ -1,3 +1,15 @@
1
1
  import type { SyncRequestContext } from '../types.js';
2
2
  import type { ContextDeps } from './types.js';
3
+ /**
4
+ * Why a candidate failed, for a caller that gets only the outcome.
5
+ *
6
+ * The WHOLE cause chain: the outer links are the ones every transport failure
7
+ * shares (`Transport read failed` → `AbortInterruption` → …), so stopping early
8
+ * makes an eviction, a hub outage and a phone that is off read identically. A
9
+ * removal is four links down.
10
+ *
11
+ * A link with no message is stepped over, not stopped at — giving up there is
12
+ * how a deeper unwrap says less than the one-level version.
13
+ */
14
+ export declare function describeFailure(error: unknown): string | undefined;
3
15
  export declare function createSyncContext(_ctx: unknown, deps: ContextDeps): SyncRequestContext;
@@ -1 +1,252 @@
1
- export function createSyncContext(e,s){return{addPeer:e=>s.syncManager.addPeer({config:e,stores:s.stores}),removePeer:e=>s.syncManager.removePeer({peerDID:e,stores:s.stores}),listPeers:()=>s.syncManager.listPeers({stores:s.stores}),getStatus:e=>s.syncManager.getStatus({peerDID:e,stores:s.stores})}}
1
+ import { getGraphStore } from '@kubun/store-graph';
2
+ import { getP2PStore } from '@kubun/store-p2p';
3
+ import { rankSyncPeers } from '../groups/peer-selection.js';
4
+ import { resolveCatalogSyncScopes } from '../sync/catalog-scope.js';
5
+ const NO_OP = {
6
+ messagesReceived: 0,
7
+ messagesSent: 0,
8
+ divergentBuckets: 0
9
+ };
10
+ /**
11
+ * Why a candidate failed, for a caller that gets only the outcome.
12
+ *
13
+ * The WHOLE cause chain: the outer links are the ones every transport failure
14
+ * shares (`Transport read failed` → `AbortInterruption` → …), so stopping early
15
+ * makes an eviction, a hub outage and a phone that is off read identically. A
16
+ * removal is four links down.
17
+ *
18
+ * A link with no message is stepped over, not stopped at — giving up there is
19
+ * how a deeper unwrap says less than the one-level version.
20
+ */ export function describeFailure(error) {
21
+ if (!(error instanceof Error)) {
22
+ return error == null ? undefined : String(error);
23
+ }
24
+ const messages = [];
25
+ // Bounded by identity: nothing here emits a cycle, but this runs on the
26
+ // failure path, where the alternative is hanging while reporting a failure.
27
+ const seen = new Set();
28
+ let current = error;
29
+ while(current instanceof Error && !seen.has(current)){
30
+ seen.add(current);
31
+ if (current.message !== '') {
32
+ messages.push(current.message);
33
+ }
34
+ current = current.cause;
35
+ }
36
+ return messages.join(': ');
37
+ }
38
+ /**
39
+ * What the local device has opted into pulling: only ACTIVE catalogs contribute
40
+ * (`resolveCatalogSyncScopes` skips known/unknown ones), so a device that has
41
+ * activated nothing syncs nothing. The owners come from each active catalog's
42
+ * circle members — never hand-built pairs.
43
+ *
44
+ * Shared by both catch-up entries, so an automatically-chosen peer and a
45
+ * user-named one cannot be asked for different scopes.
46
+ */ async function resolveActiveScopes(stores) {
47
+ const graphStore = await getGraphStore(stores);
48
+ const activeCatalogs = await graphStore.listStoredCatalogs({
49
+ active: true
50
+ });
51
+ const scope = await resolveCatalogSyncScopes(stores, activeCatalogs.map((catalog)=>catalog.id));
52
+ const owners = scope.owners ?? [];
53
+ return {
54
+ scopes: scope.modelIDs.flatMap((modelID)=>owners.map((ownerDID)=>({
55
+ modelID,
56
+ ownerDID
57
+ }))),
58
+ // What this device HAS, not what its catalogs name. A catalog may name a
59
+ // model that was never deployed here — that is the case the peer answers by
60
+ // shipping the model's cluster — so deriving this from the scope would
61
+ // declare every such model known and suppress the very definition needed.
62
+ knownModelIDs: await graphStore.listDocumentModelIDs()
63
+ };
64
+ }
65
+ export function createSyncContext(_ctx, deps) {
66
+ /**
67
+ * Where to reach a co-member, preferring the group's hub tunnel.
68
+ *
69
+ * A peer with a row in the group's discovery projection is reachable through
70
+ * that group by construction: the row was written from an MLS-authenticated
71
+ * announce, and `tunnel://<groupID>/<peerDID>` needs nothing else. `undefined`
72
+ * hands the question back to the persisted registry, which is what a peer
73
+ * added by URL — an HTTP server, an in-process test peer — still needs.
74
+ *
75
+ * Route only. Which documents a session serves is decided per document at the
76
+ * far end, so preferring a tunnel changes who can be dialled and nothing about
77
+ * what comes back.
78
+ */ const resolveRoute = async (groupID, peerDID)=>{
79
+ const p2pStore = await getP2PStore(deps.stores);
80
+ const announcement = await p2pStore.getPeerAnnouncement({
81
+ groupID,
82
+ peerDID
83
+ });
84
+ return announcement == null ? undefined : `tunnel://${groupID}/${peerDID}`;
85
+ };
86
+ // Bidirectional: each device pushes the docs it owns (its own owner-scope
87
+ // authorizes) and pulls what it may. Reaching the peer needs no prior
88
+ // `connectPeer` either way — the projection and the registry are both
89
+ // persisted, so this works on the first call after a restart.
90
+ const runCatchUp = async (groupID, peerDID)=>{
91
+ const { scopes, knownModelIDs } = await resolveActiveScopes(deps.stores);
92
+ // No active catalog (or none resolvable to a concrete owner) → the opt-in
93
+ // resting state. A clean all-zero no-op, never an error and never a peer
94
+ // round-trip.
95
+ if (scopes.length === 0) {
96
+ return NO_OP;
97
+ }
98
+ const endpoint = await resolveRoute(groupID, peerDID);
99
+ const result = await deps.syncManager.merkleSyncWithPeer({
100
+ peerDID,
101
+ scopes,
102
+ stores: deps.stores,
103
+ knownModelIDs,
104
+ direction: 'both',
105
+ ...endpoint == null ? {} : {
106
+ endpoint
107
+ }
108
+ });
109
+ return {
110
+ messagesReceived: result.messagesReceived,
111
+ messagesSent: result.messagesSent,
112
+ divergentBuckets: result.divergentBuckets
113
+ };
114
+ };
115
+ return {
116
+ addPeer: (config)=>deps.syncManager.addPeer({
117
+ config,
118
+ stores: deps.stores
119
+ }),
120
+ removePeer: (peerDID)=>deps.syncManager.removePeer({
121
+ peerDID,
122
+ stores: deps.stores
123
+ }),
124
+ listPeers: ()=>deps.syncManager.listPeers({
125
+ stores: deps.stores
126
+ }),
127
+ getStatus: (peerDID)=>deps.syncManager.getStatus({
128
+ peerDID,
129
+ stores: deps.stores
130
+ }),
131
+ // The user named the peer, so the ranking is not consulted: intent beats
132
+ // policy, and this is what makes phone-to-phone "I just changed this, show me
133
+ // now" work against a device automatic selection would never pick. A failure
134
+ // is raised rather than reported, for the same reason: someone is watching.
135
+ syncPeer: async (groupID, peerDID)=>{
136
+ // Tunnel topics derive from the MLS epoch, so a roster commit landing
137
+ // mid-session kills it. A fresh session derives the new topics.
138
+ //
139
+ // ARMED BY THE ROTATION, never by the error: matching `TransportDisposed`
140
+ // would swallow a session torn down for any other reason, and retrying
141
+ // every failure would double the wait against an unreachable peer.
142
+ //
143
+ // A flag from the listener, not a before/after read of `groupEpoch` — the
144
+ // rotation can land between the failure and the re-read. The listener runs
145
+ // inside the registry's mutex, so it may only set a boolean.
146
+ let rotated = false;
147
+ const stopWatching = deps.registry.onEpochChanged((changed)=>{
148
+ if (changed === groupID) {
149
+ rotated = true;
150
+ }
151
+ });
152
+ try {
153
+ try {
154
+ return await runCatchUp(groupID, peerDID);
155
+ } catch (error) {
156
+ if (!rotated) {
157
+ throw error;
158
+ }
159
+ deps.logger.debug('retrying a sync the group epoch moved under', {
160
+ groupID,
161
+ peerDID
162
+ });
163
+ // ONCE: two consecutive broken sessions means "not now".
164
+ return await runCatchUp(groupID, peerDID);
165
+ }
166
+ } catch (error) {
167
+ // Named, because the raw message is routinely `Transport read failed` —
168
+ // useless to a screen that must say which row it could not reach. Only
169
+ // this layer holds both the peer and the unwrapped cause.
170
+ throw new Error(`sync with ${peerDID} failed: ${describeFailure(error) ?? 'unknown error'}`, {
171
+ cause: error
172
+ });
173
+ } finally{
174
+ stopWatching();
175
+ }
176
+ },
177
+ catchUpWithBestPeer: async (groupID)=>{
178
+ const { scopes } = await resolveActiveScopes(deps.stores);
179
+ if (scopes.length === 0) {
180
+ return {
181
+ peerDID: null,
182
+ outcome: 'no-scopes',
183
+ ...NO_OP
184
+ };
185
+ }
186
+ const p2pStore = await getP2PStore(deps.stores);
187
+ const rows = await p2pStore.listPeerAnnouncements(groupID);
188
+ const candidates = rankSyncPeers(rows, {
189
+ selfDID: deps.identity.id
190
+ });
191
+ if (candidates.length === 0) {
192
+ return {
193
+ peerDID: null,
194
+ outcome: 'no-candidates',
195
+ ...NO_OP
196
+ };
197
+ }
198
+ // Down the ranking until one answers. A row records that a device once
199
+ // announced, never that it is on now — and no cheaper probe exists, so a
200
+ // failed session IS the reachability test. Bounded by the transport's own
201
+ // idle timeout, which is why the tunnel resolver sets one.
202
+ let lastPeerDID = null;
203
+ let lastFailure;
204
+ for (const candidate of candidates){
205
+ lastPeerDID = candidate.peer_did;
206
+ try {
207
+ return {
208
+ peerDID: candidate.peer_did,
209
+ outcome: 'synced',
210
+ ...await runCatchUp(groupID, candidate.peer_did)
211
+ };
212
+ } catch (error) {
213
+ // Nothing was reached, so nothing was learned about this peer beyond
214
+ // "not now". The next candidate is the whole remedy — but a run that
215
+ // walks every candidate and reports `no-route` looks identical whether
216
+ // the devices are off or the local apply is throwing, and only this
217
+ // line tells them apart.
218
+ //
219
+ // At `warn`, not `debug`: the OUTCOME ships at info, so a build that
220
+ // drops debug reports that every candidate failed while discarding the
221
+ // only record of why. That is the shape a device QA session hit — the
222
+ // reason existed and was below the level anyone was reading.
223
+ lastFailure = error;
224
+ deps.logger.warn('catch-up candidate failed', {
225
+ groupID,
226
+ peerDID: candidate.peer_did,
227
+ error
228
+ });
229
+ }
230
+ }
231
+ const failureReason = describeFailure(lastFailure);
232
+ return {
233
+ peerDID: lastPeerDID,
234
+ outcome: 'no-route',
235
+ ...NO_OP,
236
+ ...failureReason == null ? {} : {
237
+ failureReason
238
+ }
239
+ };
240
+ },
241
+ listPeerDevices: async (groupID)=>{
242
+ const p2pStore = await getP2PStore(deps.stores);
243
+ return (await p2pStore.listPeerAnnouncements(groupID)).map((row)=>({
244
+ peerDID: row.peer_did,
245
+ label: row.label,
246
+ availability: row.availability,
247
+ capabilities: row.capabilities,
248
+ hlc: row.hlc
249
+ }));
250
+ }
251
+ };
252
+ }
@@ -1,65 +1,101 @@
1
- import type { KeyPackageBundle } from '@enkaku/group';
2
- import type { Runtime } from '@enkaku/runtime';
3
- import type { OwnIdentity } from '@enkaku/token';
1
+ import type { OwnIdentity } from '@kokuin/token';
4
2
  import type { StoreProvider } from '@kubun/db';
5
3
  import type { Adapter } from '@kubun/db-adapter';
6
4
  import type { HLC } from '@kubun/hlc';
7
5
  import type { Logger } from '@kubun/logger';
6
+ import type { Circle } from '@kubun/store-p2p';
7
+ import type { LaneResult, PendingCommit } from '@kumiai/rpc';
8
+ import type { Runtime } from '@sozai/runtime';
8
9
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
9
10
  import type { P2PEventEmitter } from '../groups/events.js';
10
11
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
11
12
  import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
12
13
  import type { GroupManager } from '../groups/manager.js';
14
+ import type { PeerConnectionRegistry } from '../peer/connection-registry.js';
13
15
  import type { SyncManager } from '../sync/sync-manager.js';
14
- import type { RejoinResult } from '../types.js';
15
- export type PendingJoinRequest = {
16
- publicPackage: KeyPackageBundle['publicPackage'];
17
- privatePackage: KeyPackageBundle['privatePackage'];
18
- did: string;
19
- };
16
+ import type { CircleData, LedgerCatchupOptions, LedgerCatchupSummary, RejoinResult } from '../types.js';
20
17
  export type ContextDeps = {
21
18
  identity: OwnIdentity;
22
19
  groupManager: GroupManager;
23
20
  registry: GroupHandleRegistry;
24
21
  syncManager: SyncManager;
22
+ /**
23
+ * Plugin-scoped in-memory registry of live peer connections, keyed by
24
+ * `peerDID`. `connectPeer` writes here; nothing is persisted.
25
+ */
26
+ peerConnections: PeerConnectionRegistry;
25
27
  stores: StoreProvider;
28
+ /**
29
+ * The DEVICE-wide store provider, which `stores` is not: under `mutateGraph`
30
+ * that one is the request's own transaction, and a write made through it after
31
+ * the commit hook has fired never lands — it waits on a transaction that is
32
+ * already over. Work deferred past the mutation must use this instead.
33
+ */
34
+ deviceStores: StoreProvider;
26
35
  adapter: Adapter;
27
36
  hlc: HLC;
28
37
  emitter: P2PEventEmitter;
29
- pendingJoinRequests: Map<string, PendingJoinRequest>;
30
38
  runtime: Runtime;
31
39
  autoAcceptPeers?: Array<string>;
32
40
  /**
33
- * Fire-and-forget hub broadcast trigger. Wired to `HubWiring.scheduleBroadcast`
34
- * when the p2p plugin is configured with `hub`; otherwise a no-op from
35
- * `DISABLED_HUB`. Handlers invoke this via `stores.onCommit(...)` so the hub
36
- * send runs after the mutation transaction commits.
41
+ * How long `requestInvite` waits for its own Add commit to settle before
42
+ * returning the truthful pending shape (a parked commit, inviter offline).
43
+ * Test-only escape hatch production omits it and runs on the 30s default;
44
+ * lets a headless test drive the park-then-pending path without waiting out
45
+ * the full production timeout.
46
+ */
47
+ inviteCommitTimeoutMs?: number;
48
+ /**
49
+ * Fire-and-forget hub broadcast trigger. Wired to `HubWiring.scheduleBroadcast`;
50
+ * a no-op for a group with no hub-backed peer to fan out to. Handlers invoke
51
+ * this via `stores.onCommit(...)` so the hub send runs after the mutation
52
+ * transaction commits.
37
53
  */
38
54
  scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
39
55
  /**
40
56
  * Awaitable broadcast (encrypt + send completed on resolve). Wired to
41
- * `HubWiring.broadcastNow`; a no-op resolve under `DISABLED_HUB`. Used where
57
+ * `HubWiring.broadcastNow`; a no-op resolve for a group with no hub-backed
58
+ * peer. Used where
42
59
  * the send must complete before a following local op — e.g. a leave-request
43
60
  * encrypted while the MLS handle is still live, before `leaveGroup`.
44
61
  */
45
62
  broadcastNow: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
46
63
  /**
47
- * Fire-and-forget fan-out of an MLS handshake Commit to a group's existing
48
- * members. Wired to `HubWiring.scheduleSendCommit`; a no-op under
49
- * `DISABLED_HUB`. Handlers invoke it via `stores.onCommit(...)` so the send
50
- * runs after the mutation transaction commits.
64
+ * Drive a commit through the group's canonical commit hub and await its
65
+ * outcome. Wired to `HubWiring.commitToGroup`. Used by the control-ledger
66
+ * enact path: the admin's role entry rides the commit's bodies, published to
67
+ * the commit log, and the local MLS handle is advanced only when the log
68
+ * accepts. A group with no hub bound publishes to its in-process loopback log,
69
+ * so the enact path is available with or without a hub.
51
70
  */
52
- scheduleSendCommit: (groupID: string, commitBytes: Uint8Array) => void;
71
+ commitToGroup: (groupID: string, build: () => Promise<PendingCommit>) => Promise<LaneResult>;
53
72
  /** Shared group-health monitor; source for the `groupHealth` query. */
54
73
  monitor: GroupHealthMonitor;
55
74
  /**
56
- * Run stale-device recovery for a group. Wired to `HubWiring.rejoinGroup`;
57
- * a no-op `recovery-failed` under `DISABLED_HUB`.
75
+ * Run stale-device recovery for a group. Wired to `HubWiring.rejoinGroup`,
76
+ * which recovers through the rendezvous when a live member answers — reporting
77
+ * `recovered` on success, or `recovery-failed` (without throwing) when no
78
+ * responder is reachable.
58
79
  */
59
80
  rejoinGroup: (groupID: string) => Promise<RejoinResult>;
81
+ /**
82
+ * Gather the group's whole control ledger from its current members and adopt
83
+ * it locally, verified against this device's authenticated head. Wired to
84
+ * `HubWiring.requestLedgerCatchup`; a no-op all-zero summary for a group with
85
+ * no hub-backed peer to gather from, or when every responder's ledger was
86
+ * refused. The drift/backstop path for a joiner whose invite snapshot missed
87
+ * entries appended after the invite was minted.
88
+ */
89
+ requestLedgerCatchup: (groupID: string, options?: LedgerCatchupOptions) => Promise<LedgerCatchupSummary>;
60
90
  logger: Logger;
61
91
  };
62
92
  export declare function toISO(timestamp: unknown): string;
93
+ /**
94
+ * The one circle row → API mapping, so every producer (queries, events, mutation
95
+ * payloads) reports the same shape — notably the projected `open` flag, stored as
96
+ * integer 0/1 to read back uniformly across SQLite and Postgres.
97
+ */
98
+ export declare function toCircleData(row: Circle): CircleData;
63
99
  /**
64
100
  * Normalize a credential value from the DB.
65
101
  * Kysely's ParseJSONResultsPlugin may have already parsed the JSON string
@@ -1 +1,48 @@
1
- export function toISO(e){return"number"==typeof e?new Date(1e3*e).toISOString():e instanceof Date?e.toISOString():String(e)}export function normalizeCredential(e){return"string"==typeof e?e:JSON.stringify(e)}export function checkPeerAccess(e,t,n){if(null!=n){if(!n.includes(e))throw Error("Peer not in autoAcceptPeers list")}else if(e!==t)throw Error("Only the engine identity can perform this operation")}
1
+ export function toISO(timestamp) {
2
+ if (typeof timestamp === 'number') {
3
+ return new Date(timestamp * 1000).toISOString();
4
+ }
5
+ if (timestamp instanceof Date) {
6
+ return timestamp.toISOString();
7
+ }
8
+ return String(timestamp);
9
+ }
10
+ /**
11
+ * The one circle row → API mapping, so every producer (queries, events, mutation
12
+ * payloads) reports the same shape — notably the projected `open` flag, stored as
13
+ * integer 0/1 to read back uniformly across SQLite and Postgres.
14
+ */ export function toCircleData(row) {
15
+ return {
16
+ id: row.id,
17
+ groupID: row.group_id,
18
+ name: row.name,
19
+ description: row.description,
20
+ open: row.open === 1,
21
+ createdAt: toISO(row.created_at)
22
+ };
23
+ }
24
+ /**
25
+ * Normalize a credential value from the DB.
26
+ * Kysely's ParseJSONResultsPlugin may have already parsed the JSON string
27
+ * into an object. SerializedMLSGroupState expects a JSON string.
28
+ */ export function normalizeCredential(value) {
29
+ if (typeof value === 'string') return value;
30
+ return JSON.stringify(value);
31
+ }
32
+ export function checkPeerAccess(viewerDID, identityID, autoAcceptPeers) {
33
+ // The device operator is always allowed to run its own peer ops. The
34
+ // allow-list gates OTHER DIDs (remote callers of the dance handlers); it must
35
+ // never lock the device out of inviting/joining on its own behalf — an
36
+ // inviter that trusts a peer via `autoAcceptPeers` still runs the invite as
37
+ // itself, and a joiner drives its own inbound dance locally.
38
+ if (viewerDID === identityID) {
39
+ return;
40
+ }
41
+ if (autoAcceptPeers != null) {
42
+ if (!autoAcceptPeers.includes(viewerDID)) {
43
+ throw new Error('Peer not in autoAcceptPeers list');
44
+ }
45
+ } else {
46
+ throw new Error('Only the engine identity can perform this operation');
47
+ }
48
+ }
@@ -0,0 +1,44 @@
1
+ import { type OwnIdentity } from '@kokuin/token';
2
+ /** The rule a member states for one of its models. Mirrors the stored shape. */
3
+ export type AccessDefaultRule = {
4
+ level: string;
5
+ allowedDIDs: Array<string> | null;
6
+ allowedCircles: Array<string> | null;
7
+ allowedGroups: Array<string> | null;
8
+ };
9
+ type SetPayload = {
10
+ op: 'set';
11
+ modelID: string;
12
+ permissionType: 'read' | 'write';
13
+ rule: AccessDefaultRule;
14
+ hlc: string;
15
+ };
16
+ type RemovePayload = {
17
+ op: 'remove';
18
+ modelID: string;
19
+ permissionTypes: Array<'read' | 'write'>;
20
+ hlc: string;
21
+ };
22
+ /**
23
+ * The verified claim behind an access-default broadcast. `issuer` is the
24
+ * authenticated author (the verified token `iss`) — the owner whose sharing
25
+ * policy this is. Every authoritative field comes from the signed payload, so a
26
+ * receiver never trusts the surrounding plaintext frame.
27
+ */
28
+ export type VerifiedAccessDefault = {
29
+ issuer: string;
30
+ claim: SetPayload;
31
+ } | {
32
+ issuer: string;
33
+ claim: RemovePayload;
34
+ };
35
+ export declare function signAccessDefaultSet(identity: OwnIdentity, claim: Omit<SetPayload, 'op'>): Promise<string>;
36
+ export declare function signAccessDefaultRemove(identity: OwnIdentity, claim: Omit<RemovePayload, 'op'>): Promise<string>;
37
+ /**
38
+ * Verify a signed access-default token and extract its claim. Returns `null`
39
+ * (never throws) when the token is unparseable, unsigned (`alg: 'none'`), or
40
+ * structurally malformed — an attacker cannot forge an `iss` this way. The
41
+ * issuer is the normalized verified `iss`.
42
+ */
43
+ export declare function verifyAccessDefault(token: string): Promise<VerifiedAccessDefault | null>;
44
+ export {};