@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,1216 @@
1
+ import { Client } from '@enkaku/client';
2
+ import { ClientTransport } from '@enkaku/http-fetch';
3
+ import { normalizeDID } from '@kokuin/token';
4
+ import { getGraphStore } from '@kubun/store-graph';
5
+ import { getP2PStore } from '@kubun/store-p2p';
6
+ import { readGroupAnchor } from '@kumiai/mls';
7
+ import { blake3 } from '@noble/hashes/blake3.js';
8
+ import { GraphQLError } from 'graphql';
9
+ import { signAccessDefaultSet } from '../groups/access-default-token.js';
10
+ import { applyAccessDefaultSetFrame } from '../groups/broadcast.js';
11
+ import { foldGroupSettings } from '../groups/circle-projection.js';
12
+ import { awaitControlRequestSettled } from '../groups/control-request.js';
13
+ import { resolveJoinRequestDID } from '../groups/join-request-identity.js';
14
+ import { SyncAccessDeniedError } from '../sync/errors.js';
15
+ import { createGroupContext } from './group.js';
16
+ import { createJoinContext } from './join.js';
17
+ import { requireGroupAdmin } from './require-admin.js';
18
+ import { toISO } from './types.js';
19
+ /**
20
+ * Deterministic name marking the circle a prior `sharePeerGroup` created.
21
+ *
22
+ * One share circle per (group, SHARER), not per peer. A per-peer circle put a
23
+ * new circle in the model's read default on every admission, and that default is
24
+ * never widened once it exists — so the second device admitted to a group landed
25
+ * outside the ACL and could read nothing by model, while the admission reported
26
+ * a share. Sharing into one circle means the circle the first share named is
27
+ * already there, and nothing has to widen an ACL to admit a device.
28
+ *
29
+ * Keyed on the sharer because sharing is per-direction: each device owns the
30
+ * circle for what IT sends, and two admins deriving one id would merge two
31
+ * access scopes (see {@link shareRowID}).
32
+ */ function shareCircleName(ownerDID) {
33
+ return `peer-share:${ownerDID}`;
34
+ }
35
+ /**
36
+ * How long the re-invite path waits for a stale leaf's eviction to land before
37
+ * refusing the invite.
38
+ *
39
+ * Sized for the commit lane, not for a local write: the Remove goes to the
40
+ * group's commit log and can be rebased by a concurrent commit before it is
41
+ * accepted, so the bound has to cover several attempts plus their round-trips.
42
+ * It is a refusal deadline, not a latency target — a rejoin that waits this long
43
+ * has hit a real fault, and the caller can retry.
44
+ */ const EVICT_STALE_LEAF_TIMEOUT = 30_000;
45
+ /**
46
+ * How long one `sharePeerGroup` step waits for the commit carrying its entry to
47
+ * be enacted. A hub round trip inside an interactive share flow — generous
48
+ * enough that a healthy hub never trips it, short enough that a share does not
49
+ * hang a UI. Applied per step, not as one shared budget.
50
+ */ const SHARE_STEP_TIMEOUT_MS = 10_000;
51
+ /**
52
+ * Derive a stable UUID for one of the rows `sharePeerGroup` owns, from the
53
+ * (group, sharer) pair the share is made FROM and a per-row `slot`.
54
+ *
55
+ * DERIVED so a retried step re-mints the SAME subject — `circle.def`'s whole-value
56
+ * LWW folds that as an update — instead of a second circle and a second catalog.
57
+ * A second peer admitted to the same group derives the same pair, which is the
58
+ * point: it joins the circle the first share created.
59
+ *
60
+ * NOT caller-supplied, and NOT keyed on the group alone: two admins picking one
61
+ * id would merge two access scopes, and since a member subject embeds the circle
62
+ * id, both admins' grants would land in the merged scope with nothing thrown.
63
+ * Keying on the sharer is safe because a collision then MEANS this device's own
64
+ * share circle for that group.
65
+ *
66
+ * blake3 for the React Native / browser / node triple.
67
+ */ function shareRowID(slot, groupID, ownerDID) {
68
+ const digest = blake3(new TextEncoder().encode(`kubun.peer-share:${slot}:${groupID}:${ownerDID}`), {
69
+ dkLen: 16
70
+ });
71
+ // Format as a v4-shaped UUID: every other id in the tree comes from
72
+ // `getRandomID()`, which yields v4, and columns and callers assume that shape.
73
+ // The version and variant nibbles are overwritten rather than derived, so the
74
+ // string is a well-formed UUID that carries 122 bits of the digest.
75
+ const bytes = Uint8Array.from(digest);
76
+ bytes[6] = bytes[6] & 0x0f | 0x40;
77
+ bytes[8] = bytes[8] & 0x3f | 0x80;
78
+ const hex = Array.from(bytes, (b)=>b.toString(16).padStart(2, '0')).join('');
79
+ return [
80
+ hex.slice(0, 8),
81
+ hex.slice(8, 12),
82
+ hex.slice(12, 16),
83
+ hex.slice(16, 20),
84
+ hex.slice(20, 32)
85
+ ].join('-');
86
+ }
87
+ /**
88
+ * Models this device shares INTO a circle: its OWN read access-default rows
89
+ * whose `allowedCircles` names the circle. Write defaults are excluded — sharing
90
+ * is letting the circle READ your docs. Shared by the join-time open-circle
91
+ * picker and `setCircleSync`'s payload so the two computations cannot drift.
92
+ */ function circleSharing(ownDefaults, circleID) {
93
+ return ownDefaults.filter((row)=>row.permissionType === 'read' && (row.allowedCircles ?? []).includes(circleID)).map((row)=>row.modelID);
94
+ }
95
+ /**
96
+ * Catalog reads for one flow, cached by id.
97
+ *
98
+ * The cache holds the PROMISE, not the awaited row: caching the row leaves a
99
+ * window where concurrent loads of the same id all miss and each issue their own
100
+ * read, so the cache silently stops working the moment a caller stops awaiting
101
+ * in sequence.
102
+ */ function createCatalogLoader(graphStore) {
103
+ const cache = new Map();
104
+ return {
105
+ async prime (ids) {
106
+ const missing = ids.filter((id)=>!cache.has(id));
107
+ if (missing.length === 0) {
108
+ return;
109
+ }
110
+ const found = await graphStore.getCatalogs(missing);
111
+ // Seed misses too, so a catalog this device does not hold is remembered as
112
+ // absent rather than re-queried per lookup.
113
+ for (const id of missing){
114
+ cache.set(id, Promise.resolve(found.get(id)));
115
+ }
116
+ },
117
+ load (id) {
118
+ const cached = cache.get(id);
119
+ if (cached != null) {
120
+ return cached;
121
+ }
122
+ const pending = graphStore.getCatalog(id);
123
+ cache.set(id, pending);
124
+ return pending;
125
+ }
126
+ };
127
+ }
128
+ /**
129
+ * True only when the circle has at least one catalog AND every one is active on
130
+ * this device — the post-state of a pull that activates all the circle's
131
+ * catalogs together (a pull-off deactivating them all inverts it). A zero-catalog
132
+ * circle has nothing to pull, so it is false.
133
+ */ async function circlePulling(catalogIDs, loadCatalog) {
134
+ if (catalogIDs.length === 0) {
135
+ return false;
136
+ }
137
+ for (const catalogID of catalogIDs){
138
+ const catalog = await loadCatalog(catalogID);
139
+ if (catalog?.active !== 1) {
140
+ return false;
141
+ }
142
+ }
143
+ return true;
144
+ }
145
+ /** Own access-default rows keyed `modelID:permissionType`, for point lookups. */ function indexDefaults(rows) {
146
+ return new Map(rows.map((row)=>[
147
+ `${row.modelID}:${row.permissionType}`,
148
+ row
149
+ ]));
150
+ }
151
+ /**
152
+ * The one circle row + local state → `CircleSyncState` mapping. The join-time
153
+ * picker, the `circleSync` query, and `setCircleSync`'s post-state all go through
154
+ * it, so what a mutation returns is exactly what a re-read reports.
155
+ */ async function circleSyncState(circle, ownDefaults, loadCatalog) {
156
+ return {
157
+ id: circle.id,
158
+ groupID: circle.group_id,
159
+ name: circle.name,
160
+ description: circle.description,
161
+ catalogIDs: circle.catalog_ids,
162
+ open: circle.open === 1,
163
+ pulling: await circlePulling(circle.catalog_ids, loadCatalog),
164
+ sharing: circleSharing(ownDefaults, circle.id)
165
+ };
166
+ }
167
+ /**
168
+ * Well-known protocol names advertised in a peer's `/info`. `connectPeer`
169
+ * requires both: the sync protocol so a later `syncPeer` can reach the data
170
+ * leg, and the peer protocol to run `peer/groups` (and the dance) now. These
171
+ * are the default registration names; a peer that advertises the sync protocol
172
+ * under a custom path is not discoverable by this check.
173
+ */ const SYNC_PROTOCOL_NAME = 'sync';
174
+ const PEER_PROTOCOL_NAME = 'peer';
175
+ function isPeerInfoResponse(value) {
176
+ if (value == null || typeof value !== 'object') return false;
177
+ const candidate = value;
178
+ return typeof candidate.did === 'string' && typeof candidate.protocols === 'object';
179
+ }
180
+ /**
181
+ * Fresh-only read-default classification over the exact models the user chose to
182
+ * send. A model with no existing read default is granted (a fresh row naming the
183
+ * circles, written through the owner-signed executor callback); an existing
184
+ * default that already names every circle is `MODEL_PRE_EXISTING`; one that is
185
+ * missing any is `DOCUMENT_ONLY` — never widened. Only listed models are
186
+ * classified, so every entry maps to a deliberate user selection.
187
+ */ async function classifyModelDefaults(params) {
188
+ const { graphStore, ownerDID, models, circleIDs, setReadDefault } = params;
189
+ // One list read for every model's current rule. The grants below write only
190
+ // models that HAVE no rule, and each model is its own row, so no write here
191
+ // invalidates another model's snapshot.
192
+ const existingByKey = indexDefaults(await graphStore.listUserModelAccessDefaults(ownerDID));
193
+ const modelAccess = [];
194
+ for (const modelID of models){
195
+ const existing = existingByKey.get(`${modelID}:read`);
196
+ if (existing == null) {
197
+ await setReadDefault(modelID);
198
+ modelAccess.push({
199
+ modelID,
200
+ access: 'MODEL_GRANTED'
201
+ });
202
+ } else if (circleIDs.every((circleID)=>(existing.allowedCircles ?? []).includes(circleID))) {
203
+ modelAccess.push({
204
+ modelID,
205
+ access: 'MODEL_PRE_EXISTING'
206
+ });
207
+ } else {
208
+ // A default exists without (all of) these circles — never widened.
209
+ modelAccess.push({
210
+ modelID,
211
+ access: 'DOCUMENT_ONLY'
212
+ });
213
+ }
214
+ }
215
+ return modelAccess;
216
+ }
217
+ /**
218
+ * The inviter leg of the inbound dance, run on behalf of a verified caller by
219
+ * the `group/invite` peer handler. It designates NO circle itself: the joiner
220
+ * lands only in circles the inviter ALREADY designated — a `sharePeerGroup`
221
+ * pre-add, or an explicit `requestAddCircleMember` — which `collectInviteSeeds`
222
+ * snapshots from the ledger. Both of those land through an enacted commit, so
223
+ * by the time a designation is snapshottable here it is already durable. The
224
+ * invite runs as THIS device's own identity; the caller only supplies the group
225
+ * and its KeyPackage.
226
+ */ export async function serveGroupInvite(deps, params) {
227
+ const { callerDID, groupID, joinRequest } = params;
228
+ // The caller may only pull an invite for itself: the signed procedure payload
229
+ // binds `callerDID`, so refuse a join request naming a different recipient — a
230
+ // trusted peer must not be able to inject a third party into the group.
231
+ //
232
+ // Compared against the KeyPackage's own credential rather than the request's
233
+ // `did` field, so this binds the envelope to the identity that will actually
234
+ // hold the MLS leaf. Comparing the plaintext field would let a caller present
235
+ // its own DID beside someone else's KeyPackage and pass a check that looks
236
+ // like it verified something.
237
+ const credentialDID = resolveJoinRequestDID(joinRequest);
238
+ if (normalizeDID(credentialDID) !== normalizeDID(callerDID)) {
239
+ throw new Error('group/invite: join request DID does not match the authenticated caller');
240
+ }
241
+ const groupCtx = createGroupContext({
242
+ viewerDID: deps.identity.id
243
+ }, deps);
244
+ // A caller already in the MLS roster is a joiner that lost its local group
245
+ // state — re-adding it directly would fail (MLS rejects an Add for a current
246
+ // member). Its DID is signature-verified by the peer handler, so a re-join
247
+ // request from the same principal cannot widen access: evict the stale leaf
248
+ // and fall through to a fresh Add + Welcome (key rotation via the new
249
+ // KeyPackage) to restore the joiner's liveness.
250
+ const staleLeafIndex = await deps.registry.readHandle(groupID, (handle)=>handle.findMemberLeafIndex(callerDID), {
251
+ stores: deps.stores
252
+ });
253
+ if (staleLeafIndex != null) {
254
+ deps.logger.warn('group/invite: re-inviting existing member, evicting stale roster entry', {
255
+ groupID,
256
+ callerDID
257
+ });
258
+ // The eviction is AWAITED, not fired and forgotten: the Add below frames
259
+ // against whatever handle it reads, so it must not be built until the Remove
260
+ // has advanced this device's epoch past the stale leaf — otherwise existing
261
+ // members receive an Add against an epoch the Remove is still moving. This
262
+ // handler is not inside the engine's write transaction, so it can wait.
263
+ const request = await groupCtx.requestRemoveMember({
264
+ groupID,
265
+ memberDID: callerDID
266
+ });
267
+ const settled = await awaitControlRequestSettled({
268
+ stores: deps.stores,
269
+ emitter: deps.emitter,
270
+ requestID: request.id,
271
+ timeoutMs: EVICT_STALE_LEAF_TIMEOUT
272
+ });
273
+ if (settled.status !== 'committed') {
274
+ throw new Error(`group/invite: evicting the stale roster entry did not land (${settled.status})`);
275
+ }
276
+ }
277
+ const inviteResult = await groupCtx.requestInvite({
278
+ groupID,
279
+ joinRequest
280
+ });
281
+ if (inviteResult.invitePayload == null) {
282
+ // The Add commit is still parked (this device's hub is offline), so there is
283
+ // no Welcome to hand back yet — the joiner cannot join until it lands. Surface
284
+ // the pending state as an error the caller retries, rather than passing a null
285
+ // payload downstream as if the invite had committed.
286
+ throw new Error(`group/invite: the add commit is still pending (${inviteResult.request.status}) — retry once the inviter is back online`);
287
+ }
288
+ return {
289
+ invitePayload: inviteResult.invitePayload
290
+ };
291
+ }
292
+ /**
293
+ * Apply the access-default rows a verified caller pushed for itself, run by the
294
+ * `group/push-control` peer handler. The source is UNTRUSTED, so the rows apply
295
+ * sender-bound — a row is kept only when its owner equals the authenticated
296
+ * caller, on top of the owner-equals-token-issuer check
297
+ * `applyAccessDefaultSetFrame` already enforces. A caller therefore cannot
298
+ * inject another owner's sharing policy.
299
+ *
300
+ * The caller must additionally be a member of the named group per THIS device's
301
+ * own membership view. The other remote ledger-ingest path rides the group's hub
302
+ * topic, whose MLS derivation is an implicit membership perimeter; this
303
+ * procedure is reachable by any accepted peer, so the perimeter is restated
304
+ * here. Fail-closed: an unknown group reads as no membership.
305
+ */ export async function servePushControl(deps, params) {
306
+ const { callerDID, groupID, accessDefaults } = params;
307
+ const [p2pStore, graphStore] = await Promise.all([
308
+ getP2PStore(deps.stores),
309
+ getGraphStore(deps.stores)
310
+ ]);
311
+ if (!await p2pStore.isGroupMember(groupID, callerDID)) {
312
+ throw new SyncAccessDeniedError('not a group member', `Push control denied: ${callerDID} is not a member of group ${groupID}`);
313
+ }
314
+ const callerNormalized = normalizeDID(callerDID);
315
+ let accessDefaultsApplied = 0;
316
+ for (const row of accessDefaults){
317
+ // Caller-binding: a caller may push only its OWN sharing policy. Drop a row
318
+ // naming a foreign owner before the token check, so a trusted peer cannot
319
+ // relay a third party's rows into this device.
320
+ if (normalizeDID(row.ownerDID) !== callerNormalized) {
321
+ deps.logger.warn('group/push-control: dropped access-default not owned by the caller', {
322
+ groupID,
323
+ ownerDID: row.ownerDID,
324
+ callerDID
325
+ });
326
+ continue;
327
+ }
328
+ if (await applyAccessDefaultSetFrame({
329
+ graphStore,
330
+ frame: row,
331
+ ...deps.logger != null ? {
332
+ logger: deps.logger
333
+ } : {},
334
+ groupID
335
+ })) {
336
+ accessDefaultsApplied++;
337
+ }
338
+ }
339
+ return {
340
+ accessDefaultsApplied
341
+ };
342
+ }
343
+ /**
344
+ * Designate one peer's access to one group: resolve or create the group, fold
345
+ * the commit-hub designation, find or create the share circle, admit both DIDs,
346
+ * and write the catalog + read defaults the share's scope rests on.
347
+ *
348
+ * Local state only — no peer connection is read or written. Whatever follows a
349
+ * designation (the MLS dance, registering the sync peer) belongs to the caller,
350
+ * so admitting a peer without a live connection reuses this rather than copying
351
+ * it: an invite payload snapshots exactly what these steps wrote, so a second
352
+ * copy of the seeding order drifts into a joiner landing in no circle, silently.
353
+ *
354
+ * The steps are ORDER-dependent — each reads what an earlier one wrote — so the
355
+ * sequence below is the contract, not an arrangement.
356
+ */ export async function designatePeerAccess(deps, params) {
357
+ const { groupCtx, selfDID, peerDID, target, operation, sendModels, receiveActivate } = params;
358
+ const [p2pStore, graphStore] = await Promise.all([
359
+ getP2PStore(deps.stores),
360
+ getGraphStore(deps.stores)
361
+ ]);
362
+ // 1. Resolve or create the group. Existing-by-ID is the idempotent reuse;
363
+ // the `name` path always mints a new group (a retry uses the groupID path).
364
+ const group = 'name' in target ? await groupCtx.create({
365
+ name: target.name
366
+ }) : await (async ()=>{
367
+ const existing = await groupCtx.get(target.groupID);
368
+ if (existing == null) {
369
+ throw new GraphQLError(`${operation.name}: group ${target.groupID} not found`);
370
+ }
371
+ return existing;
372
+ })();
373
+ const groupID = group.id;
374
+ // Admin-only gate. Steps 2-3 mint circle-def and circle-member ledger
375
+ // entries whose folds admit only a ledger admin's issuance; a non-admin
376
+ // caller's entries are dropped on every peer, and the drop surfaces frames
377
+ // later as a null projection read-back. Reject here — before any append.
378
+ // The `name` path is admin by construction (creating a group mirrors the
379
+ // fresh handle's roster, which names the creator admin from the genesis
380
+ // anchor), so the check is a tautology there and never false-denies a
381
+ // fresh group.
382
+ const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
383
+ stores: deps.stores
384
+ });
385
+ if (anchor == null) {
386
+ throw new GraphQLError(`${operation.name}: group ${groupID} has no genesis anchor`);
387
+ }
388
+ await requireGroupAdmin({
389
+ store: p2pStore,
390
+ groupID,
391
+ did: selfDID,
392
+ action: operation.adminAction
393
+ });
394
+ // Each step below only ASKS — an entry lands when its commit is enacted — and
395
+ // each later step reads what an earlier one wrote, most sharply the invite the
396
+ // caller mints afterwards, which snapshots the circle and the ledger into its
397
+ // payload. So every settle is awaited; without that the joiner arrives in no
398
+ // circle and nothing throws. Awaiting a settle is only safe outside an engine
399
+ // write transaction, so every mutation field reaching this function must be
400
+ // listed in `nonTransactionalMutationFields` — `sharePeerGroup` is — or the
401
+ // wait deadlocks a single-connection SQLite adapter.
402
+ //
403
+ // Per-step timeout rather than one shared budget, so the throw names the step
404
+ // that stalled — the raw settle error names only an internal request id.
405
+ const settleStep = async (step, requestID)=>{
406
+ let settled;
407
+ try {
408
+ settled = await groupCtx.awaitSettle(requestID, SHARE_STEP_TIMEOUT_MS);
409
+ } catch (cause) {
410
+ // biome-ignore lint/style/useErrorCause: `originalError` is GraphQLError's own cause channel
411
+ throw new GraphQLError(`${operation.name}: ${step} did not land within ${SHARE_STEP_TIMEOUT_MS}ms — the group may be partially configured; re-run to converge`, {
412
+ originalError: cause
413
+ });
414
+ }
415
+ // `no_op` is a success: it means the state the step asked for already
416
+ // held, which is exactly what a re-run should find.
417
+ if (settled.status !== 'committed' && settled.status !== 'no_op') {
418
+ throw new GraphQLError(`${operation.name}: ${step} settled ${settled.status}${settled.error == null ? '' : ` — ${settled.error}`}`);
419
+ }
420
+ };
421
+ // 1b. Promote a locally-designated commit hub to a FOLDED designation before
422
+ // the invite is minted. The bare bind mutation auto-designates a hub on THIS
423
+ // device only, so a co-member would fall back to its own first-bound hub and
424
+ // the group could fork; sharing is the first moment a co-member appears, and
425
+ // the entry must land before the invite step snapshots the ledger. Authors
426
+ // ONLY when a hub is designated locally AND none is folded yet — the same
427
+ // check reprojection reads — so a second share authors nothing.
428
+ const localCommitHub = await p2pStore.getCommitHub(groupID);
429
+ if (localCommitHub != null) {
430
+ const ledger = await deps.registry.readHandle(groupID, (handle)=>handle.ledger, {
431
+ stores: deps.stores
432
+ });
433
+ if (foldGroupSettings(ledger, groupID, anchor)?.commitHub == null) {
434
+ const request = await groupCtx.requestUpdate({
435
+ groupID,
436
+ commitHub: localCommitHub.server_did != null ? {
437
+ url: localCommitHub.url,
438
+ serverDID: localCommitHub.server_did
439
+ } : {
440
+ url: localCommitHub.url
441
+ }
442
+ });
443
+ await settleStep('designating the commit hub', request.id);
444
+ }
445
+ }
446
+ // 2. Find or create the share circle. Its id is DERIVED from the (group,
447
+ // sharer) pair, so a run that timed out and then landed is rediscovered
448
+ // exactly rather than duplicated, and a second peer joins the circle the
449
+ // first share created — the name is a human-readable marker only, and is
450
+ // no longer what the lookup keys on.
451
+ const circleName = shareCircleName(selfDID);
452
+ const circleID = shareRowID('circle', groupID, selfDID);
453
+ const existingCircle = await p2pStore.getCircle(circleID);
454
+ if (existingCircle == null) {
455
+ const created = await groupCtx.requestCreateCircle({
456
+ groupID,
457
+ circleID,
458
+ name: circleName,
459
+ description: 'Share circle created by sharePeerGroup'
460
+ });
461
+ await settleStep('creating the share circle', created.request.id);
462
+ }
463
+ // 3. Add both DIDs to the circle before the invite, so the invite payload
464
+ // seeds the joiner's circle membership. Skip a DID already an ACTIVE member:
465
+ // `getCircleMember` answers with tombstoned rows too, and treating one as
466
+ // present skips the re-add, so a peer removed from this circle once is shared
467
+ // with again and still cannot read.
468
+ for (const did of [
469
+ selfDID,
470
+ peerDID
471
+ ]){
472
+ const member = await p2pStore.getCircleMember(circleID, did);
473
+ if (member == null || member.removed_at_hlc != null) {
474
+ const request = await groupCtx.requestAddCircleMember({
475
+ circleID,
476
+ memberDID: did,
477
+ role: 'member'
478
+ });
479
+ await settleStep(`adding ${did} to the share circle`, request.id);
480
+ }
481
+ }
482
+ // 4. Access legs come ONLY from an explicit `send`. With no send, the share
483
+ // is plumbing only: no catalog, no default rows. The catalog + defaults are
484
+ // written BEFORE the dance so they travel in the invite payload's seeds.
485
+ const circleRow = await p2pStore.getCircle(circleID);
486
+ let catalogID = circleRow?.catalog_ids?.[0] ?? null;
487
+ let modelAccess = [];
488
+ if (sendModels != null) {
489
+ if (catalogID == null) {
490
+ // Derived for the same reason as the circle: a run that created the
491
+ // catalog but never landed the attachment must reuse it, not mint a
492
+ // second one. The catalog row itself is a plain store write, so the
493
+ // probe is the row, not a request.
494
+ const derivedCatalogID = shareRowID('catalog', groupID, selfDID);
495
+ const existingCatalog = await graphStore.getCatalog(derivedCatalogID);
496
+ if (existingCatalog == null) {
497
+ const filterCriteria = {
498
+ models: sendModels,
499
+ circles: [
500
+ circleID
501
+ ]
502
+ };
503
+ await groupCtx.createCatalog({
504
+ catalogID: derivedCatalogID,
505
+ name: circleName,
506
+ filterCriteria: JSON.stringify(filterCriteria)
507
+ });
508
+ }
509
+ catalogID = derivedCatalogID;
510
+ const request = await groupCtx.requestUpdateCircle({
511
+ circleID,
512
+ catalogIDs: [
513
+ catalogID
514
+ ]
515
+ });
516
+ await settleStep('attaching the catalog to the share circle', request.id);
517
+ // The catalog is created active (own-catalog default); activation is now
518
+ // the explicit receive opt-in, so demote it to known unless the sharer
519
+ // opted into pulling the share scope.
520
+ if (!receiveActivate) {
521
+ await graphStore.setCatalogActive(catalogID, false);
522
+ }
523
+ } else {
524
+ // The group's one catalog already exists, and its criteria are what every
525
+ // member of the share circle pulls. A model outside it would be granted a
526
+ // read default here and named by no catalog — a share reported as
527
+ // succeeding that moves nothing — so widen the catalog to cover it.
528
+ //
529
+ // Through `updateCatalog` for its two guarantees: it refuses a catalog
530
+ // this device does not own, and it re-signs the record. Without the
531
+ // re-sign the widened catalog would stop seeding into invites entirely.
532
+ //
533
+ // Group-wide by nature — every device that activated this catalog now
534
+ // pulls the added models too. That is bounded by each owner's own read
535
+ // rules, which the catalog selects within and never grants: an undeclared
536
+ // model defaults to `only_owner` and is not broadcast at all. And it is a
537
+ // deliberate admin act, since `sharePeerGroup` is admin-gated.
538
+ const existingCatalog = await graphStore.getCatalog(catalogID);
539
+ if (existingCatalog == null) {
540
+ throw new GraphQLError(`sharePeerGroup: the share circle names catalog ${catalogID}, which this device does not hold`);
541
+ }
542
+ const covered = existingCatalog.filter_criteria?.models ?? [];
543
+ const uncovered = sendModels.filter((modelID)=>!covered.includes(modelID));
544
+ if (uncovered.length > 0) {
545
+ await groupCtx.updateCatalog({
546
+ catalogID,
547
+ filterCriteria: JSON.stringify({
548
+ ...existingCatalog.filter_criteria,
549
+ models: [
550
+ ...covered,
551
+ ...uncovered
552
+ ]
553
+ })
554
+ });
555
+ }
556
+ if (receiveActivate) {
557
+ await graphStore.setCatalogActive(catalogID, true);
558
+ }
559
+ }
560
+ // Fresh-only read defaults for exactly the user's selection.
561
+ modelAccess = await classifyModelDefaults({
562
+ graphStore,
563
+ ownerDID: selfDID,
564
+ models: sendModels,
565
+ circleIDs: [
566
+ circleID
567
+ ],
568
+ setReadDefault: (modelID)=>params.setReadDefaultForCircle(modelID, circleID)
569
+ });
570
+ } else if (receiveActivate && catalogID != null) {
571
+ // No send, but the sharer opts into pulling an already-configured group's
572
+ // existing catalogs.
573
+ await graphStore.setCatalogsActive(circleRow?.catalog_ids ?? [], true);
574
+ }
575
+ return {
576
+ group,
577
+ modelAccess,
578
+ catalogID
579
+ };
580
+ }
581
+ export function createPeerContext(ctx, deps) {
582
+ return {
583
+ connect: async (url)=>{
584
+ // Discover the peer: GET `/info`, which advertises the device DID and the
585
+ // protocol paths it serves. A server-side outbound fetch to a
586
+ // caller-supplied URL is accepted — the caller is the device operator.
587
+ const infoURL = new URL('/info', url).toString();
588
+ let info;
589
+ try {
590
+ const response = await deps.runtime.fetch(infoURL);
591
+ if (!response.ok) {
592
+ throw new Error(`peer /info responded with status ${response.status}`);
593
+ }
594
+ const body = await response.json();
595
+ if (!isPeerInfoResponse(body)) {
596
+ throw new Error('peer /info returned an unexpected shape');
597
+ }
598
+ info = body;
599
+ } catch (error) {
600
+ const message = error instanceof Error ? error.message : String(error);
601
+ throw new Error(`connectPeer: failed to reach ${infoURL}: ${message}`, {
602
+ cause: error
603
+ });
604
+ }
605
+ // Both protocols must be advertised: the peer leg (used now) and the sync
606
+ // leg (used by a later `syncPeer`).
607
+ const peerPath = info.protocols[PEER_PROTOCOL_NAME];
608
+ const syncPath = info.protocols[SYNC_PROTOCOL_NAME];
609
+ if (peerPath == null || syncPath == null) {
610
+ throw new Error(`connectPeer: ${url} does not advertise the required protocols (need '${SYNC_PROTOCOL_NAME}' and '${PEER_PROTOCOL_NAME}')`);
611
+ }
612
+ // Resolved against the URL the operator gave, and pinned to its origin. The
613
+ // `/info` body is an unauthenticated fetch, so an absolute URL in it would
614
+ // silently move the connection — and the sync leg, which `addPeer`
615
+ // persists — to a host the operator never named. A protocol entry is a
616
+ // path on the peer being connected to; anything else is refused rather
617
+ // than followed.
618
+ const resolveEndpoint = (path, protocol)=>{
619
+ const base = new URL(url);
620
+ const resolved = new URL(path, base);
621
+ if (resolved.origin !== base.origin) {
622
+ throw new Error(`connectPeer: ${url} advertises '${protocol}' at ${resolved.origin}, a different origin`);
623
+ }
624
+ return resolved.toString();
625
+ };
626
+ const peerEndpoint = resolveEndpoint(peerPath, PEER_PROTOCOL_NAME);
627
+ const syncEndpoint = resolveEndpoint(syncPath, SYNC_PROTOCOL_NAME);
628
+ const transport = new ClientTransport({
629
+ url: peerEndpoint,
630
+ fetch: deps.runtime.fetch
631
+ });
632
+ const client = new Client({
633
+ runtime: deps.runtime,
634
+ identity: deps.identity,
635
+ logger: deps.logger.getChild('peer-client'),
636
+ serverID: info.did,
637
+ transport
638
+ });
639
+ let result;
640
+ try {
641
+ result = await client.request('peer/groups', {
642
+ param: {}
643
+ });
644
+ } catch (error) {
645
+ // Nothing has been stored yet — tear down the just-built connection so a
646
+ // failed connect leaves no live transport behind.
647
+ try {
648
+ await client.dispose();
649
+ } catch {
650
+ // best-effort
651
+ }
652
+ try {
653
+ await transport.dispose();
654
+ } catch {
655
+ // best-effort
656
+ }
657
+ const message = error instanceof Error ? error.message : String(error);
658
+ throw new Error(`connectPeer: peer/groups failed against ${peerEndpoint}: ${message}`, {
659
+ cause: error
660
+ });
661
+ }
662
+ // The DID this connection is keyed on has to be the DID it addresses.
663
+ // `info.did` is what every request on this client is sent to — `serverID`
664
+ // becomes the `aud` claim — and it is also what `syncEndpoint` was
665
+ // resolved beside; `result.did` is a field the remote wrote into its own
666
+ // `peer/groups` body. Neither is authenticated on its own: `/info` is a
667
+ // plain fetch. What this refuses is the two disagreeing, which would key
668
+ // the registry — and every grant a caller names off it — on one identity
669
+ // while the traffic is addressed to another.
670
+ if (normalizeDID(result.did) !== normalizeDID(info.did)) {
671
+ try {
672
+ await client.dispose();
673
+ } catch {
674
+ // best-effort
675
+ }
676
+ try {
677
+ await transport.dispose();
678
+ } catch {
679
+ // best-effort
680
+ }
681
+ throw new Error(`connectPeer: ${url} answered peer/groups as ${result.did} but identifies as ${info.did}`);
682
+ }
683
+ // Hold the connection in memory keyed by the remote DID. A repeat connect
684
+ // to the same DID replaces this entry and disposes the prior transport.
685
+ await deps.peerConnections.set({
686
+ peerDID: result.did,
687
+ endpoint: url,
688
+ syncEndpoint,
689
+ client,
690
+ transport
691
+ });
692
+ return {
693
+ did: result.did,
694
+ endpoint: url,
695
+ groups: result.groups.map((group)=>({
696
+ id: group.id,
697
+ name: group.name
698
+ }))
699
+ };
700
+ },
701
+ sharePeerGroup: async (params)=>{
702
+ const { peerDID, groupID: inputGroupID, name, sendModels, receiveActivate } = params;
703
+ // `groupID` XOR `name`: share an existing group or create a new one. Checked
704
+ // here rather than in the designation: the two-optional-argument shape is
705
+ // this mutation's own, and the designation takes one resolved target. It
706
+ // runs BEFORE the connection check, so a malformed call is refused for
707
+ // being malformed whether or not a connection is held.
708
+ if (inputGroupID == null === (name == null)) {
709
+ throw new GraphQLError('sharePeerGroup requires exactly one of groupID (share existing) or name (create new)');
710
+ }
711
+ // The held connection is required — never auto-dial. A caller must run
712
+ // `connectPeer` first; nothing here is persisted to re-establish it.
713
+ // Checked before ANY designation: this is the connected path, so a caller
714
+ // with no connection must fail having changed nothing.
715
+ const connection = deps.peerConnections.get(peerDID);
716
+ if (connection == null) {
717
+ throw new GraphQLError(`sharePeerGroup: no active connection to ${peerDID} — call connectPeer first`);
718
+ }
719
+ const groupCtx = createGroupContext(ctx, deps);
720
+ const p2pStore = await getP2PStore(deps.stores);
721
+ // 0. Pull the peer's join request FIRST and bind it to the DID about to be
722
+ // granted access, before a single row is written.
723
+ //
724
+ // The two identities are derived independently: the circle grant names
725
+ // `peerDID`, the MLS recipient comes from the KeyPackage credential inside
726
+ // `prep.joinRequest`. Uncompared, a peer receives the group's keys under
727
+ // credential Y while this device grants circle read access to whatever X
728
+ // it asked for — the split leaf/membership-row identity
729
+ // `groups/join-request-identity.ts` forbids. Explicit circle membership
730
+ // alone satisfies `circleAccessPredicate`, so X is a real grant to a
731
+ // principal that never joined.
732
+ //
733
+ // Ahead of the designation for the reason `admitJoinRequest` states: a
734
+ // designation minted for a DID the invite then refuses leaves a share
735
+ // circle and catalog behind for a member that never joins. Costs one
736
+ // wasted KeyPackage when re-run against a group the peer already belongs
737
+ // to; the peer mints those on demand.
738
+ const prep = await connection.client.request('group/prepare-join', {
739
+ param: {}
740
+ });
741
+ const credentialDID = resolveJoinRequestDID(prep.joinRequest);
742
+ if (normalizeDID(credentialDID) !== normalizeDID(peerDID)) {
743
+ throw new GraphQLError(`sharePeerGroup: the peer's join request binds ${credentialDID}, not ${peerDID}`);
744
+ }
745
+ // Steps 1-4: designate the peer's access — the state the invite below
746
+ // snapshots. Deliberately not inlined here: any other way of admitting
747
+ // this peer must seed it identically.
748
+ const designated = await designatePeerAccess(deps, {
749
+ groupCtx,
750
+ selfDID: ctx.viewerDID,
751
+ peerDID,
752
+ target: name != null ? {
753
+ name
754
+ } : {
755
+ groupID: inputGroupID
756
+ },
757
+ operation: {
758
+ name: 'sharePeerGroup',
759
+ adminAction: 'share a group'
760
+ },
761
+ sendModels: sendModels ?? null,
762
+ receiveActivate,
763
+ setReadDefaultForCircle: params.setReadDefaultForCircle
764
+ });
765
+ const groupID = designated.group.id;
766
+ // 5. Two-phase MLS dance over the peer connection — skip entirely when the
767
+ // peer is already a group member (a retry after the dance completed). The
768
+ // lookup keys on the DID the credential above was bound to, so it can no
769
+ // longer miss on a peer that is in fact a member and re-run the dance.
770
+ const alreadyMember = await p2pStore.getGroupMember(groupID, peerDID);
771
+ if (alreadyMember == null) {
772
+ const inviteResult = await groupCtx.requestInvite({
773
+ groupID,
774
+ joinRequest: prep.joinRequest
775
+ });
776
+ if (inviteResult.invitePayload == null) {
777
+ // The Add commit is still parked (this device's hub is offline): there
778
+ // is no Welcome to complete the join with. Surface the pending state
779
+ // rather than handing a null payload to `group/complete-join`.
780
+ throw new GraphQLError(`sharePeerGroup: the invite commit is still pending (${inviteResult.request.status}) — retry once the hub is reachable`);
781
+ }
782
+ await connection.client.request('group/complete-join', {
783
+ param: {
784
+ invitePayload: inviteResult.invitePayload
785
+ }
786
+ });
787
+ }
788
+ // 6. Register the sync peer (upsert on peer_did). No initial sync — the
789
+ // sharer pulls via `syncPeer` once catalogs are active.
790
+ await deps.syncManager.addPeer({
791
+ config: {
792
+ peerDID,
793
+ endpoint: connection.syncEndpoint
794
+ },
795
+ stores: deps.stores
796
+ });
797
+ return designated;
798
+ },
799
+ admitJoinRequest: async (params)=>{
800
+ const { groupID, joinRequest, sendModels, receiveActivate } = params;
801
+ // Resolve the joiner BEFORE anything is written. A designation minted for a
802
+ // DID the invite then refuses would leave a share circle and a catalog
803
+ // behind for a member that never joins.
804
+ const peerDID = resolveJoinRequestDID(joinRequest);
805
+ // Ahead of the roster read below, not only ahead of the writes. The
806
+ // designation runs this same gate, so reaching it at all is already
807
+ // admin-only — but the roster read answers "is this DID in the group",
808
+ // and a non-admin holding any join request could otherwise ask that
809
+ // question and read the answer off which refusal came back.
810
+ await requireGroupAdmin({
811
+ store: await getP2PStore(deps.stores),
812
+ groupID,
813
+ did: ctx.viewerDID,
814
+ action: 'admit a member to the group'
815
+ });
816
+ // Where `serveGroupInvite` EVICTS a stale leaf for a DID already in the
817
+ // roster and re-adds it, this refuses. That eviction is justified there by
818
+ // the peer handler having signature-verified the caller; nothing verifies
819
+ // this request's author, so honouring the same branch would let a payload
820
+ // an attacker got in front of an admin remove a current member from the
821
+ // group — a denial of service authored by the admin who pasted it.
822
+ const existingLeafIndex = await deps.registry.readHandle(groupID, (handle)=>handle.findMemberLeafIndex(peerDID), {
823
+ stores: deps.stores
824
+ });
825
+ if (existingLeafIndex != null) {
826
+ throw new GraphQLError(`admitJoinRequest: ${peerDID} is already a member of group ${groupID} — refused rather than re-added, because nothing here verifies who authored the join request`);
827
+ }
828
+ const groupCtx = createGroupContext(ctx, deps);
829
+ // The same designation `sharePeerGroup` performs, on the same function, so
830
+ // an admitted peer is seeded identically whether or not a connection exists.
831
+ const designated = await designatePeerAccess(deps, {
832
+ groupCtx,
833
+ selfDID: ctx.viewerDID,
834
+ peerDID,
835
+ target: {
836
+ groupID
837
+ },
838
+ operation: {
839
+ name: 'admitJoinRequest',
840
+ adminAction: 'admit a member to the group'
841
+ },
842
+ sendModels: sendModels ?? null,
843
+ receiveActivate,
844
+ setReadDefaultForCircle: params.setReadDefaultForCircle
845
+ });
846
+ // Minted last, through the same request facade: the invite payload
847
+ // snapshots exactly what the designation above wrote.
848
+ const inviteResult = await groupCtx.requestInvite({
849
+ groupID: designated.group.id,
850
+ joinRequest
851
+ });
852
+ if (inviteResult.invitePayload == null) {
853
+ // The Add commit is still parked, so there is no Welcome to hand back.
854
+ // Surface the pending state rather than returning a null payload the
855
+ // operator would carry to a joiner that cannot use it.
856
+ throw new GraphQLError(`admitJoinRequest: the invite commit is still pending (${inviteResult.request.status}) — retry once this device's commit lane is reachable`);
857
+ }
858
+ return {
859
+ ...designated,
860
+ peerDID,
861
+ invitePayload: inviteResult.invitePayload
862
+ };
863
+ },
864
+ joinPeerGroup: async (params)=>{
865
+ const { peerDID, groupID } = params;
866
+ // The held connection is required — never auto-dial. A caller must run
867
+ // `connectPeer` first (and discover the group from its group list).
868
+ const connection = deps.peerConnections.get(peerDID);
869
+ if (connection == null) {
870
+ throw new GraphQLError(`joinPeerGroup: no active connection to ${peerDID} — call connectPeer first`);
871
+ }
872
+ const selfDID = ctx.viewerDID;
873
+ const [p2pStore, graphStore] = await Promise.all([
874
+ getP2PStore(deps.stores),
875
+ getGraphStore(deps.stores)
876
+ ]);
877
+ // 1. Inbound MLS dance — skipped only when THIS device holds the full
878
+ // local group state (MLS state + own membership row): a retry after a
879
+ // prior completed join. A membership row WITHOUT MLS state means the
880
+ // device lost its state mid-dance; re-running the dance lets the inviter
881
+ // evict the stale roster entry and re-add with a fresh Welcome.
882
+ // `prepareRequest` mints + stashes the KeyPackage under `selfDID`; the
883
+ // remote `group/invite` returns the Welcome + seeds; `complete` joins MLS
884
+ // and folds the seeded control state (ledger/circles/access-defaults/
885
+ // catalogs) so circles project — and catalogs arrive KNOWN — at join.
886
+ const membershipRow = await p2pStore.getGroupMember(groupID, selfDID);
887
+ const mlsState = await deps.groupManager.loadGroup(deps.stores, groupID);
888
+ const alreadyJoined = membershipRow != null && mlsState != null;
889
+ if (!alreadyJoined) {
890
+ const joinCtx = createJoinContext(ctx, deps);
891
+ const prep = await joinCtx.prepareRequest();
892
+ const invite = await connection.client.request('group/invite', {
893
+ param: {
894
+ groupID,
895
+ joinRequest: prep.joinRequest
896
+ }
897
+ });
898
+ await joinCtx.complete(invite.invitePayload);
899
+ }
900
+ // 2. Register the sync peer (upsert on peer_did). No access writes, no
901
+ // activation, no initial sync — the user opts into what to send/receive
902
+ // afterward via the explicit surfaces (`setModelAccessDefaults` /
903
+ // `activateCatalogSync`), and `syncPeer` pulls only active-catalog scopes.
904
+ await deps.syncManager.addPeer({
905
+ config: {
906
+ peerDID,
907
+ endpoint: connection.syncEndpoint
908
+ },
909
+ stores: deps.stores
910
+ });
911
+ // 3. Ledger-catchup backstop: fold any entries appended after the invite
912
+ // snapshot was minted (also covers a member that missed broadcasts). MUST
913
+ // pass `quorum` — without it the gather blocks the full default window.
914
+ // Non-fatal: a backstop, not the primary seeding.
915
+ try {
916
+ await deps.requestLedgerCatchup(groupID, {
917
+ quorum: 1,
918
+ timeoutMs: 2000
919
+ });
920
+ } catch (error) {
921
+ deps.logger.warn('joinPeerGroup: ledger catch-up failed (non-fatal)', {
922
+ groupID,
923
+ error
924
+ });
925
+ }
926
+ // 4. Report the group's known catalogs (its circles' catalog IDs present
927
+ // locally) so the app can render the receive side of the share settings.
928
+ // Seeded known; the user activates later via `activateCatalogSync`. This
929
+ // leg runs OUTSIDE the alreadyJoined short-circuit and only reads, so a
930
+ // retry — including the alreadyJoined path — recomputes the same payload.
931
+ const circles = await p2pStore.listCirclesByGroup(groupID);
932
+ // The known-catalog scan and the open-circle `pulling` computation read the
933
+ // same catalog rows, so fetch every one the group's circles name in a
934
+ // single query and serve both from it.
935
+ const catalogs = createCatalogLoader(graphStore);
936
+ await catalogs.prime(circles.flatMap((circle)=>circle.catalog_ids));
937
+ const loadCatalog = catalogs.load;
938
+ const seenCatalogIDs = new Set();
939
+ const knownCatalogIDs = [];
940
+ for (const circle of circles){
941
+ for (const catalogID of circle.catalog_ids){
942
+ if (seenCatalogIDs.has(catalogID)) continue;
943
+ seenCatalogIDs.add(catalogID);
944
+ if (await loadCatalog(catalogID) != null) {
945
+ knownCatalogIDs.push(catalogID);
946
+ }
947
+ }
948
+ }
949
+ // 5. Open-circle picker: the group's OPEN circles, each carrying THIS
950
+ // device's current pull/share state — computed from local state alone,
951
+ // with no member-authored wire entry and no extra network round-trip.
952
+ // `pulling` is true only when the circle has catalogs and every one is
953
+ // active locally: the exact post-state a pull that activates all the
954
+ // circle's catalogs together leaves behind (a pull-off deactivating them
955
+ // all inverts it), so it round-trips with the pull toggle; a zero-catalog
956
+ // circle has nothing to pull and is false. `sharing` lists the models this
957
+ // device lets the circle read — its OWN read access-default rows naming the
958
+ // circle (write defaults are excluded, matching how the share toggle writes
959
+ // read defaults). Closed circles are omitted: belonging to one is a grant,
960
+ // not a user opt-in.
961
+ const ownDefaults = await graphStore.listUserModelAccessDefaults(selfDID);
962
+ const openCircles = [];
963
+ for (const circle of circles){
964
+ if (circle.open !== 1) continue;
965
+ openCircles.push(await circleSyncState(circle, ownDefaults, loadCatalog));
966
+ }
967
+ const groupRow = await p2pStore.getGroup(groupID);
968
+ if (groupRow == null) {
969
+ throw new GraphQLError(`joinPeerGroup: group ${groupID} not found after join`);
970
+ }
971
+ const group = {
972
+ id: groupRow.id,
973
+ name: groupRow.name,
974
+ description: groupRow.description,
975
+ createdBy: groupRow.created_by,
976
+ createdAt: toISO(groupRow.created_at)
977
+ };
978
+ return {
979
+ group,
980
+ knownCatalogIDs,
981
+ alreadyJoined,
982
+ openCircles
983
+ };
984
+ },
985
+ setCircleSync: async (params)=>{
986
+ const { circleID, pull, share } = params;
987
+ const selfDID = ctx.viewerDID;
988
+ const [p2pStore, graphStore] = await Promise.all([
989
+ getP2PStore(deps.stores),
990
+ getGraphStore(deps.stores)
991
+ ]);
992
+ // Access gate — runs BEFORE pull and BEFORE share, so a rejected call
993
+ // writes nothing and activates nothing (fail-closed, matching
994
+ // sharePeerGroup's guard). `getCircle` excludes removed rows, so an
995
+ // unknown OR tombstoned circle reads null here.
996
+ const circle = await p2pStore.getCircle(circleID);
997
+ if (circle == null) {
998
+ throw new GraphQLError(`setCircleSync: circle ${circleID} not found or removed`);
999
+ }
1000
+ // Membership per this device's own view (open circles count a group member
1001
+ // as a member); an unreachable circle is rejected before any write.
1002
+ if (!await p2pStore.isMemberOfAnyCircle(selfDID, [
1003
+ circleID
1004
+ ])) {
1005
+ throw new GraphQLError(`setCircleSync: ${selfDID} cannot access circle ${circleID} — not a member`);
1006
+ }
1007
+ const groupID = circle.group_id;
1008
+ // Ordering: local pull first, then the share rows, then the push. A crash
1009
+ // after pull, before share, leaves pull active and share unwritten; a retry
1010
+ // re-activates (flag no-op) and re-writes the share (LWW by HLC). Re-applying
1011
+ // the identical end state is a no-op: the diff is empty and the flags match.
1012
+ // 1. pull — purely local catalog activation. No entry, no broadcast, no push.
1013
+ // The circle's catalogs flip together, so one statement covers them.
1014
+ if (pull != null) {
1015
+ await graphStore.setCatalogsActive(circle.catalog_ids, pull);
1016
+ }
1017
+ // 2. share — an END-STATE diff over the caller's OWN read defaults naming
1018
+ // the circle. Every write is owner-signed (owner == caller) through the
1019
+ // executors, so it only ever touches the caller's rows.
1020
+ const pushRows = [];
1021
+ if (share != null) {
1022
+ const ownDefaults = await graphStore.listUserModelAccessDefaults(selfDID);
1023
+ const currentShared = new Set(circleSharing(ownDefaults, circleID));
1024
+ const target = new Set(share.models);
1025
+ // The rules the two arms read are already in `ownDefaults`; index it
1026
+ // rather than re-reading each model. The arms are disjoint by
1027
+ // construction (`target` vs `currentShared \ target`), so neither arm's
1028
+ // writes stale the other's lookups.
1029
+ const existingByKey = indexDefaults(ownDefaults);
1030
+ // Add: a target model whose read rule does not yet name the circle.
1031
+ for (const modelID of target){
1032
+ if (currentShared.has(modelID)) {
1033
+ continue;
1034
+ }
1035
+ const existing = existingByKey.get(`${modelID}:read`);
1036
+ if (existing == null) {
1037
+ // No read rule: write a fresh restricted rule naming only the circle.
1038
+ await params.setReadDefault({
1039
+ modelID,
1040
+ accessLevel: 'restricted',
1041
+ allowedDIDs: null,
1042
+ allowedCircles: [
1043
+ circleID
1044
+ ],
1045
+ allowedGroups: null
1046
+ });
1047
+ } else {
1048
+ // Union the circle into the existing rule, preserving any
1049
+ // allowedDIDs / allowedGroups / other circles. `only_owner` reads no
1050
+ // circle at all, so leaving the level would name the circle in a rule
1051
+ // that grants it nothing — the share would be reported and mean
1052
+ // nothing. Sharing IS the request to let the circle read, so the
1053
+ // level widens to `restricted`. `anyone` already grants the circle
1054
+ // read and is never narrowed.
1055
+ const nextCircles = Array.from(new Set([
1056
+ ...existing.allowedCircles ?? [],
1057
+ circleID
1058
+ ]));
1059
+ const accessLevel = existing.level === 'only_owner' ? 'restricted' : existing.level;
1060
+ await params.setReadDefault({
1061
+ modelID,
1062
+ accessLevel,
1063
+ allowedDIDs: existing.allowedDIDs,
1064
+ allowedCircles: nextCircles,
1065
+ allowedGroups: existing.allowedGroups
1066
+ });
1067
+ if (existing.level === 'only_owner') {
1068
+ deps.logger.warn('setCircleSync: model read default was only_owner; widened to restricted so the circle can read', {
1069
+ modelID,
1070
+ circleID
1071
+ });
1072
+ } else if (existing.level === 'anyone') {
1073
+ deps.logger.warn('setCircleSync: model read default already grants anyone; circle added, level not narrowed', {
1074
+ modelID,
1075
+ circleID
1076
+ });
1077
+ }
1078
+ }
1079
+ }
1080
+ // Remove: a model that currently names the circle but is not in target.
1081
+ for (const modelID of currentShared){
1082
+ if (target.has(modelID)) {
1083
+ continue;
1084
+ }
1085
+ const existing = existingByKey.get(`${modelID}:read`);
1086
+ if (existing == null) {
1087
+ continue;
1088
+ }
1089
+ const nextCircles = (existing.allowedCircles ?? []).filter((c)=>c !== circleID);
1090
+ const noDIDs = (existing.allowedDIDs ?? []).length === 0;
1091
+ const noGroups = (existing.allowedGroups ?? []).length === 0;
1092
+ if (existing.level === 'restricted' && nextCircles.length === 0 && noDIDs && noGroups) {
1093
+ // A `restricted` rule with no circles/DIDs/groups grants NO ONE
1094
+ // (owner only). Remove it entirely so the model returns to the SERVER
1095
+ // default rather than being pinned owner-only under a future default
1096
+ // change — the faithful "stop sharing entirely". Fires
1097
+ // `access-default:remove`. The restriction to the `restricted` level
1098
+ // is deliberate: removing an emptied `only_owner` rule would WIDEN to
1099
+ // the server default, and removing an `anyone` rule would silently
1100
+ // narrow a global grant — neither is the caller's intent, so those
1101
+ // are rewritten with the circle dropped instead.
1102
+ await params.removeReadDefault(modelID);
1103
+ } else {
1104
+ await params.setReadDefault({
1105
+ modelID,
1106
+ accessLevel: existing.level,
1107
+ allowedDIDs: existing.allowedDIDs,
1108
+ allowedCircles: nextCircles.length > 0 ? nextCircles : null,
1109
+ allowedGroups: existing.allowedGroups
1110
+ });
1111
+ }
1112
+ // Removals/tightenings are NOT pushed — they propagate via the
1113
+ // `access-default:remove` / `:set` broadcasts (eventual). A brief
1114
+ // over-serve by a peer is the same exposure as any circle close.
1115
+ }
1116
+ // Push rows are collected from the WHOLE target set, not just the models
1117
+ // this call added: a previous attempt that committed the share rows and
1118
+ // then failed on the push must re-push on retry, and that retry sees the
1119
+ // models as already shared. Re-pushing a row a peer already holds is a
1120
+ // no-op there (LWW by the row's own HLC).
1121
+ // Re-read AFTER the writes above — these rows are their post-state, and
1122
+ // the row carries its own anchor, so one list replaces a rule + HLC pair
1123
+ // per model. Signing is CPU-bound and touches no store, so the tokens are
1124
+ // minted concurrently.
1125
+ const writtenByKey = indexDefaults(await graphStore.listUserModelAccessDefaults(selfDID));
1126
+ const signed = await Promise.all(Array.from(target, async (modelID)=>{
1127
+ const written = writtenByKey.get(`${modelID}:read`);
1128
+ if (written?.hlc == null) {
1129
+ return null;
1130
+ }
1131
+ const hlc = written.hlc;
1132
+ const rule = {
1133
+ level: written.level,
1134
+ allowedDIDs: written.allowedDIDs,
1135
+ allowedCircles: written.allowedCircles,
1136
+ allowedGroups: written.allowedGroups
1137
+ };
1138
+ const auth = await signAccessDefaultSet(deps.identity, {
1139
+ modelID,
1140
+ permissionType: 'read',
1141
+ rule,
1142
+ hlc
1143
+ });
1144
+ return {
1145
+ ownerDID: selfDID,
1146
+ modelID,
1147
+ permissionType: 'read',
1148
+ rule,
1149
+ hlc,
1150
+ auth
1151
+ };
1152
+ }));
1153
+ pushRows.push(...signed.filter((row)=>row != null));
1154
+ }
1155
+ // 3. Push the shared models' read-default SET rows to the group's connected
1156
+ // in-group peers so
1157
+ // a counterparty can authorize the new pull without waiting on hub fan-out
1158
+ // (which may be dropped for a group with no live binding). Push failure
1159
+ // fails the mutation (unwrapped, per the end-state contract); the retry is
1160
+ // idempotent. `servePushControl` has NO remove arm — a "stop sharing"
1161
+ // tightening is broadcast-only (known gap), never pushed here.
1162
+ // Distinct peers over the network with nothing shared between them, so the
1163
+ // pushes run concurrently: sequentially, one slow peer delays every peer
1164
+ // behind it. A failure still fails the mutation, but every peer is
1165
+ // attempted first rather than the fan-out stopping at the first error —
1166
+ // strictly closer to the end-state contract, since the retry is idempotent.
1167
+ if (pushRows.length > 0) {
1168
+ const connections = deps.peerConnections.list();
1169
+ const inGroup = await Promise.all(connections.map((connection)=>p2pStore.isGroupMember(groupID, connection.peerDID)));
1170
+ await Promise.all(connections.filter((_connection, index)=>inGroup[index]).map((connection)=>connection.client.request('group/push-control', {
1171
+ param: {
1172
+ groupID,
1173
+ accessDefaults: pushRows
1174
+ }
1175
+ })));
1176
+ }
1177
+ // Post-state through the shared builder, so what this mutation returns is
1178
+ // exactly what a `circleSync` re-read (or the join-time picker) reports.
1179
+ // The circle row is re-read: a concurrent settings apply may have flipped
1180
+ // `open` while the share was being written.
1181
+ const [reread, finalDefaults] = await Promise.all([
1182
+ p2pStore.getCircle(circleID),
1183
+ graphStore.listUserModelAccessDefaults(selfDID)
1184
+ ]);
1185
+ const finalCircle = reread ?? circle;
1186
+ const catalogs = createCatalogLoader(graphStore);
1187
+ await catalogs.prime(finalCircle.catalog_ids);
1188
+ return await circleSyncState(finalCircle, finalDefaults, catalogs.load);
1189
+ },
1190
+ listCircleSyncStates: async (groupID)=>{
1191
+ const selfDID = ctx.viewerDID;
1192
+ const [p2pStore, graphStore] = await Promise.all([
1193
+ getP2PStore(deps.stores),
1194
+ getGraphStore(deps.stores)
1195
+ ]);
1196
+ // Only the circles this device can currently reach: an open circle it is a
1197
+ // group member of, or one it holds an explicit grant for. A circle the admin
1198
+ // closed since the join drops out here, which is how the app learns to stop
1199
+ // offering it.
1200
+ const [circles, reachable, ownDefaults] = await Promise.all([
1201
+ p2pStore.listCirclesByGroup(groupID),
1202
+ p2pStore.getCirclesForMember(selfDID),
1203
+ graphStore.listUserModelAccessDefaults(selfDID)
1204
+ ]);
1205
+ const reachableIDs = new Set(reachable.map((circle)=>circle.id));
1206
+ const visible = circles.filter((circle)=>reachableIDs.has(circle.id));
1207
+ const catalogs = createCatalogLoader(graphStore);
1208
+ await catalogs.prime(visible.flatMap((circle)=>circle.catalog_ids));
1209
+ const states = [];
1210
+ for (const circle of visible){
1211
+ states.push(await circleSyncState(circle, ownDefaults, catalogs.load));
1212
+ }
1213
+ return states;
1214
+ }
1215
+ };
1216
+ }