@kubun/plugin-p2p 0.11.0 → 0.12.1

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 +59 -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
package/lib/schema.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { TransactionFatalError } from '@kubun/engine';
2
+ import { PluginNodeID } from '@kubun/id';
2
3
  import { getKubunLogger } from '@kubun/logger';
4
+ import { GraphQLError } from 'graphql';
3
5
  import { createFilteredGenerator } from './groups/events.js';
4
6
  function requireP2P(ctx) {
5
7
  if (ctx.p2p == null) {
@@ -7,6 +9,33 @@ function requireP2P(ctx) {
7
9
  }
8
10
  return ctx.p2p;
9
11
  }
12
+ /**
13
+ * The read-default writers `setCircleSync` runs its share diff through.
14
+ *
15
+ * Owner-signed via the engine's access-default executors, so
16
+ * `engine:access-default:set` / `:removed` fire and the broadcast + push seeding
17
+ * stay consistent — the same path `setModelAccessDefaults` takes. Shared by every
18
+ * caller that applies a circle's sync state, so two entry points cannot write the
19
+ * same rule two ways.
20
+ */ function accessDefaultExecutors(context) {
21
+ return {
22
+ setReadDefault: async (p)=>{
23
+ await context.executeSetModelAccessDefaults({
24
+ modelID: p.modelID,
25
+ permissionType: 'read',
26
+ accessLevel: p.accessLevel,
27
+ allowedDIDs: p.allowedDIDs,
28
+ allowedCircles: p.allowedCircles,
29
+ allowedGroups: p.allowedGroups
30
+ });
31
+ },
32
+ removeReadDefault: async (modelID)=>{
33
+ await context.executeRemoveModelAccessDefaults(modelID, [
34
+ 'read'
35
+ ]);
36
+ }
37
+ };
38
+ }
10
39
  /**
11
40
  * Re-raise any failure from a membership/circle operation as the engine's
12
41
  * transaction-rollback marker so `mutateGraph` rolls back the whole transaction.
@@ -17,24 +46,50 @@ function requireP2P(ctx) {
17
46
  * is forwarded unchanged so an expected validation error (e.g. access-denied)
18
47
  * still reaches the client as before; the cause carries the full original error.
19
48
  *
20
- * Only multi-write ops whose partial commit would fail OPEN (toward more
21
- * privilege leaving a removed/demoted DID authoritative) are wrapped:
22
- * `inviteToGroup`, `removeGroupMember`, `leaveGroup`, `setMemberRole`,
23
- * `removeCircleMember`. Ops that fail toward less privilege / benign metadata
24
- * (circle create/update, capability writes) self-heal on the next idempotent
25
- * reprojection and are intentionally left unwrapped — a new remove/demote op
26
- * should add a wrap here.
49
+ * Wrap ONLY multi-write ops whose partial commit would fail OPEN toward more
50
+ * privilege, leaving a removed or demoted DID authoritative plus every
51
+ * request-shaped mutation, which enqueues a request row nothing heals. Ops touching
52
+ * benign metadata are left unwrapped.
27
53
  *
28
- * Caveat: a thrown `GraphQLError`'s `extensions` (e.g. a `code`) are not carried
29
- * onto the re-raised top-level error (they remain on `.cause`); none of the
30
- * wrapped resolvers currently throw coded GraphQLErrors, so no client-visible
31
- * code is lost today.
54
+ * A thrown `GraphQLError`'s `extensions` ARE carried across the re-raise, so a
55
+ * refusal that can only be decided by reading the state it refuses — the last
56
+ * admin leaving, demoting themselves, or being removed keeps the code an app
57
+ * matches on. An authorization gate still belongs BEFORE the `try`: it reads
58
+ * only, so there is nothing to roll back and nothing to un-write. Use
59
+ * {@link gatedTransaction} rather than calling this directly.
32
60
  */ function failTransactionFatal(operation, error) {
33
61
  const message = error instanceof Error ? error.message : `${operation} failed`;
62
+ // A typed refusal keeps its code across the re-raise: graphql-js copies
63
+ // `originalError.extensions` onto the GraphQLError it wraps this throw in.
64
+ // Only a check that must READ the state it refuses needs this — a pure
65
+ // authorization gate still belongs before the `try`, where nothing has been
66
+ // written yet (see {@link gatedTransaction}).
67
+ const extensions = error instanceof GraphQLError && Object.keys(error.extensions ?? {}).length > 0 ? error.extensions : undefined;
34
68
  throw new TransactionFatalError(message, {
35
- cause: error
69
+ cause: error,
70
+ ...extensions == null ? {} : {
71
+ extensions
72
+ }
36
73
  });
37
74
  }
75
+ /**
76
+ * Run an authorization gate and then a rollback-wrapped body, in the only order
77
+ * that preserves a typed refusal.
78
+ *
79
+ * `gate` runs OUTSIDE the `try` because it only reads: there is nothing to roll
80
+ * back, and a refusal that never opened a transaction should not pretend to have
81
+ * one. Taking it as its own argument is what stops a mutation gating inside the
82
+ * wrapper. (A code raised inside `run` reaches the client too — see
83
+ * {@link failTransactionFatal} — so this is about where the refusal belongs,
84
+ * not about whether it survives.)
85
+ */ async function gatedTransaction(operation, gate, run) {
86
+ await gate();
87
+ try {
88
+ return await run();
89
+ } catch (error) {
90
+ failTransactionFatal(operation, error);
91
+ }
92
+ }
38
93
  // Internal lowercase-kebab health values -> SDL enum names.
39
94
  const HEALTH_STATE_TO_SDL = {
40
95
  healthy: 'HEALTHY',
@@ -46,6 +101,48 @@ const HEALTH_STATE_TO_SDL = {
46
101
  const HEALTH_CONDITION_TO_SDL = {
47
102
  'epoch-stale': 'EPOCH_STALE'
48
103
  };
104
+ // Internal lowercase control-request values -> SDL enum names.
105
+ const CONTROL_REQUEST_STATUS_TO_SDL = {
106
+ pending: 'PENDING',
107
+ committed: 'COMMITTED',
108
+ rejected: 'REJECTED',
109
+ lost: 'LOST',
110
+ no_op: 'NO_OP'
111
+ };
112
+ const CONTROL_REQUEST_KIND_TO_SDL = {
113
+ ledger: 'LEDGER',
114
+ invite: 'INVITE',
115
+ remove: 'REMOVE'
116
+ };
117
+ /** Project a control request into its SDL shape, mapping both enums to uppercase. */ function toControlRequestSDL(request) {
118
+ return {
119
+ ...request,
120
+ kind: CONTROL_REQUEST_KIND_TO_SDL[request.kind],
121
+ status: CONTROL_REQUEST_STATUS_TO_SDL[request.status]
122
+ };
123
+ }
124
+ /**
125
+ * Deliver one request's terminal status, whenever it happens, then complete.
126
+ *
127
+ * `stream` must already be listening when this is called — the read below can
128
+ * only see a settle that has landed, and the stream can only see one that has
129
+ * not, so between them every outcome is covered exactly once. A settle is
130
+ * final, so the first delivery ends the stream.
131
+ */ async function* watchOneControlRequest(stream, read) {
132
+ try {
133
+ const existing = await read();
134
+ if (existing != null && existing.status !== 'pending') {
135
+ yield existing;
136
+ return;
137
+ }
138
+ for await (const settled of stream){
139
+ yield settled;
140
+ return;
141
+ }
142
+ } finally{
143
+ await stream.return();
144
+ }
145
+ }
49
146
  async function* emptyAsyncGenerator() {
50
147
  // no-op: closes immediately so subscribers without a viewer get no events
51
148
  }
@@ -60,8 +157,8 @@ type PeerGroup {
60
157
 
61
158
  type PeerGroupMember {
62
159
  groupID: ID!
63
- memberDID: DID!
64
- role: String!
160
+ account: AccountNode!
161
+ role: GroupMemberRole!
65
162
  createdAt: DateTimeISO!
66
163
  }
67
164
 
@@ -70,9 +167,20 @@ type PeerCircle {
70
167
  groupID: ID!
71
168
  name: String!
72
169
  description: String
170
+ """Admin-authored: every group member reaches an open circle implicitly."""
171
+ open: Boolean!
73
172
  createdAt: DateTimeISO!
74
173
  }
75
174
 
175
+ type PeerDevice {
176
+ peerDID: DID!
177
+ """Self-declared display hint, never an identity."""
178
+ label: String!
179
+ availability: String!
180
+ """The announcer's own stamp; the row is last-writer-wins keyed by it."""
181
+ hlc: String!
182
+ }
183
+
76
184
  type PeerCircleMember {
77
185
  circleID: ID!
78
186
  memberDID: DID!
@@ -86,6 +194,9 @@ type PeerCatalog {
86
194
  name: String!
87
195
  description: String
88
196
  filterCriteria: String!
197
+ active: Boolean!
198
+ sourceGroupID: ID
199
+ sourceCircleID: ID
89
200
  createdAt: DateTimeISO!
90
201
  }
91
202
 
@@ -113,8 +224,21 @@ type CreateGroupPayload {
113
224
  group: PeerGroup!
114
225
  }
115
226
 
116
- type InviteToGroupPayload {
117
- invitePayload: String!
227
+ input RequestInviteToGroupInput {
228
+ groupID: ID!
229
+ joinRequest: String!
230
+ grants: [String!]
231
+ revocations: [String!]
232
+ }
233
+
234
+ """
235
+ Waits for its own commit, unlike the other request-shaped mutations. invitePayload
236
+ is present only when status is COMMITTED; PENDING carries none, REJECTED and LOST
237
+ raise. Ack the request once the payload has been passed on.
238
+ """
239
+ type RequestInviteToGroupPayload {
240
+ request: ControlRequest!
241
+ invitePayload: String
118
242
  groupID: ID!
119
243
  groupName: String!
120
244
  }
@@ -135,8 +259,14 @@ type LeaveGroupPayload {
135
259
  groupID: ID!
136
260
  }
137
261
 
138
- type RemoveGroupMemberPayload {
262
+ input RequestRemoveGroupMemberInput {
139
263
  groupID: ID!
264
+ memberDID: DID!
265
+ }
266
+
267
+ """The request, not the removal: the tombstone lands with the commit. Watch it."""
268
+ type RequestRemoveGroupMemberPayload {
269
+ request: ControlRequest!
140
270
  }
141
271
 
142
272
  enum GroupMemberRole {
@@ -144,14 +274,15 @@ enum GroupMemberRole {
144
274
  member
145
275
  }
146
276
 
147
- input SetMemberRoleInput {
277
+ input RequestSetMemberRoleInput {
148
278
  groupID: ID!
149
279
  memberDID: DID!
150
280
  role: GroupMemberRole!
151
281
  }
152
282
 
153
- type SetMemberRolePayload {
154
- member: PeerGroupMember!
283
+ """The request, not the role: it moves with the commit. Watch it."""
284
+ type RequestSetMemberRolePayload {
285
+ request: ControlRequest!
155
286
  }
156
287
 
157
288
  input UpdateCatalogInput {
@@ -160,14 +291,49 @@ input UpdateCatalogInput {
160
291
  filterCriteria: String
161
292
  }
162
293
 
163
- input UpdateGroupInput {
294
+ input RequestUpdateGroupInput {
164
295
  groupID: ID!
165
296
  name: String
166
297
  description: String
298
+ openCircleIDs: [String!]
299
+ commitHub: SuggestedHubInput
167
300
  }
168
301
 
169
- type UpdateGroupPayload {
170
- group: PeerGroup!
302
+ """
303
+ The request, not the settings: they move with the commit. Watch it. No field
304
+ beyond groupID settles NO_OP.
305
+ """
306
+ type RequestUpdateGroupPayload {
307
+ request: ControlRequest!
308
+ }
309
+
310
+ input RequestCreateCircleInput {
311
+ groupID: ID!
312
+ name: String!
313
+ description: String
314
+ }
315
+
316
+ """
317
+ The request plus the new circle's id, minted up front so a caller can name the
318
+ circle before the commit enacts it. Not queryable until then; watch the request.
319
+ """
320
+ type RequestCreateCirclePayload {
321
+ request: ControlRequest!
322
+ circleID: ID!
323
+ }
324
+
325
+ input RequestAddCircleMemberInput {
326
+ circleID: ID!
327
+ memberDID: DID!
328
+ role: String!
329
+ }
330
+
331
+ """
332
+ The request, not the membership: the row appears with the commit. Watch it. A DID
333
+ already in the circle at this role settles NO_OP.
334
+ """
335
+ type RequestAddCircleMemberPayload {
336
+ request: ControlRequest!
171
337
  }
172
338
 
173
339
  input UpdateCircleInput {
@@ -177,30 +343,47 @@ input UpdateCircleInput {
177
343
  catalogIDs: [String!]
178
344
  }
179
345
 
180
- type UpdateCirclePayload {
181
- circle: PeerCircle!
346
+ """
347
+ The request, not the definition: it moves with the commit. Watch it. No field
348
+ beyond circleID settles NO_OP.
349
+ """
350
+ type RequestUpdateCirclePayload {
351
+ request: ControlRequest!
182
352
  }
183
353
 
184
354
  type DeleteCatalogPayload {
185
355
  deletedID: ID!
186
356
  }
187
357
 
188
- type DeleteCirclePayload {
189
- deletedID: ID!
358
+ input RequestDeleteCircleInput {
359
+ circleID: ID!
190
360
  }
191
361
 
192
- type RemoveCircleMemberPayload {
362
+ """
363
+ The request, not the deletion: the tombstone lands with the commit. Watch it. An
364
+ already-gone circle settles NO_OP.
365
+ """
366
+ type RequestDeleteCirclePayload {
367
+ request: ControlRequest!
368
+ }
369
+
370
+ input RequestRemoveCircleMemberInput {
193
371
  circleID: ID!
194
372
  memberDID: DID!
195
373
  }
196
374
 
375
+ """
376
+ The request, not the removal: the tombstone lands with the commit. Watch it. A DID
377
+ that is not an active member settles NO_OP.
378
+ """
379
+ type RequestRemoveCircleMemberPayload {
380
+ request: ControlRequest!
381
+ }
382
+
197
383
  type SyncPeer {
198
384
  id: String!
199
385
  peerDID: DID!
200
386
  endpoint: URL!
201
- mode: String!
202
- priority: String!
203
- trustLevel: String!
204
387
  createdAt: Int!
205
388
  updatedAt: Int!
206
389
  }
@@ -221,6 +404,20 @@ type RemoveSyncPeerPayload {
221
404
  success: Boolean!
222
405
  }
223
406
 
407
+ type SyncPeerPayload {
408
+ messagesReceived: Int!
409
+ messagesSent: Int!
410
+ divergentBuckets: Int!
411
+ }
412
+
413
+ type SyncEvent {
414
+ type: String!
415
+ peerID: ID!
416
+ documentID: ID
417
+ timestamp: Float!
418
+ error: String
419
+ }
420
+
224
421
  input CreateHubInput {
225
422
  url: URL!
226
423
  label: String
@@ -254,10 +451,8 @@ input DelegationTokenCovers {
254
451
  }
255
452
 
256
453
  """
257
- A verified revocation of a delegation token addressed to the viewer.
258
- Only surfaces while the underlying held capability still exists locally;
259
- revocations whose cap was deleted or never arrived are excluded by the
260
- audience-derivation join.
454
+ A verified revocation of a delegation token addressed to the viewer. Surfaces only
455
+ while the held capability still exists locally.
261
456
  """
262
457
  type RevokedDelegationToken {
263
458
  jti: ID!
@@ -266,22 +461,114 @@ type RevokedDelegationToken {
266
461
  revokerDID: DID!
267
462
  "Issuer's revocation-record timestamp (iat), Unix epoch seconds."
268
463
  revokedAt: Int!
269
- """
270
- Wall-clock time the revocation became binding, Unix epoch seconds. For
271
- self-issued revocations this equals revokedAt (verified at mint time);
272
- for received revocations it is the receiver's clock at the moment the
273
- cap-vs-revoker cross-check succeeded.
274
- """
464
+ "When the revocation became binding here, Unix epoch seconds."
275
465
  verifiedAt: Int!
276
466
  capExp: Int
277
467
  groupID: ID!
278
468
  }
279
469
 
470
+ type RemoteGroupInfo {
471
+ id: ID!
472
+ name: String!
473
+ }
474
+
475
+ type PeerInfo {
476
+ did: ID!
477
+ endpoint: String!
478
+ groups: [RemoteGroupInfo!]!
479
+ }
480
+
481
+ type ConnectPeerPayload {
482
+ peer: PeerInfo!
483
+ }
484
+
485
+ """
486
+ Models to share into a circle. Omitted on sharePeerGroup leaves that leg untouched.
487
+ On setCircleSync it is the desired END STATE — an empty list stops sharing.
488
+ """
489
+ input ShareInput {
490
+ models: [ID!]!
491
+ }
492
+
493
+ input ShareReceiveInput {
494
+ activate: Boolean!
495
+ }
496
+
497
+ enum CircleModelAccess {
498
+ MODEL_GRANTED
499
+ MODEL_PRE_EXISTING
500
+ DOCUMENT_ONLY
501
+ }
502
+
503
+ type ModelShareResult {
504
+ modelID: ID!
505
+ access: CircleModelAccess!
506
+ }
507
+
508
+ type SharePeerGroupPayload {
509
+ group: PeerGroup!
510
+ modelAccess: [ModelShareResult!]!
511
+ catalogID: ID
512
+ }
513
+
514
+ type AdmitJoinRequestPayload {
515
+ group: PeerGroup!
516
+ modelAccess: [ModelShareResult!]!
517
+ catalogID: ID
518
+ """Derived from the join request's KeyPackage credential, not its did field."""
519
+ peerDID: ID!
520
+ """
521
+ The invite to hand to the joiner. Carries the access designated above, so the
522
+ joiner lands in the share circle at join.
523
+ """
524
+ invitePayload: String!
525
+ }
526
+
527
+ """
528
+ A circle this device can reach, with its own sync state for it. pulling is true only
529
+ when the circle has catalogs and every one is active locally; sharing holds the
530
+ models this device lets the circle read.
531
+ """
532
+ type CircleSyncState {
533
+ id: ID!
534
+ groupID: ID!
535
+ name: String!
536
+ description: String!
537
+ catalogIDs: [ID!]!
538
+ """Admin-authored: every group member reaches an open circle implicitly."""
539
+ open: Boolean!
540
+ pulling: Boolean!
541
+ sharing: [ID!]!
542
+ }
543
+
544
+ type JoinPeerGroupPayload {
545
+ group: PeerGroup!
546
+ knownCatalogIDs: [ID!]!
547
+ alreadyJoined: Boolean!
548
+ """
549
+ The group's OPEN circles, for an opt-in picker. A closed circle this device holds
550
+ a grant for is excluded; circleSync returns every reachable circle.
551
+ """
552
+ openCircles: [CircleSyncState!]!
553
+ }
554
+
280
555
  extend type Query {
281
556
  groups: [PeerGroup!]!
282
557
  group(id: ID!): PeerGroup
283
558
  groupMembers(groupID: ID!): [PeerGroupMember!]!
284
559
  circles(groupID: ID!): [PeerCircle!]!
560
+ """
561
+ Every device this one has heard announce in the group, its own row included. A
562
+ projection, never liveness: no row is deleted on silence, so a sleeping device is
563
+ still listed and a screen marks it stale rather than losing it.
564
+ """
565
+ peerDevices(groupID: ID!): [PeerDevice!]!
566
+ """
567
+ Every circle in the group this device can currently reach, with its pull/share
568
+ state. Re-read to pick up a circle the admin opened or closed since the join.
569
+ """
570
+ circleSync(groupID: ID!): [CircleSyncState!]!
571
+ catalogs(active: Boolean): [PeerCatalog!]!
285
572
  hubs: [PeerHub!]!
286
573
  hub(id: ID!): PeerHub
287
574
  syncPeers: [SyncPeer!]!
@@ -289,6 +576,11 @@ extend type Query {
289
576
  ownDelegationTokens(grantor: ID, covers: DelegationTokenCovers): [DelegationToken!]!
290
577
  ownRevokedDelegationTokens(grantor: ID): [RevokedDelegationToken!]!
291
578
  groupHealth(groupID: ID!): GroupHealthStatus!
579
+ """
580
+ This device's own un-acked control requests, oldest first, optionally scoped to
581
+ one group. The store is per-device.
582
+ """
583
+ controlRequests(groupID: ID): [ControlRequest!]!
292
584
  }
293
585
 
294
586
  enum GroupHealthCondition {
@@ -320,28 +612,93 @@ type RejoinGroupPayload {
320
612
  state: GroupHealthState!
321
613
  }
322
614
 
615
+ enum ControlRequestStatus {
616
+ PENDING
617
+ COMMITTED
618
+ REJECTED
619
+ LOST
620
+ """
621
+ The state already held, so no commit was driven. Terminal, and deliberately not
622
+ COMMITTED: nothing was committed.
623
+ """
624
+ NO_OP
625
+ }
626
+
627
+ enum ControlRequestKind {
628
+ LEDGER
629
+ INVITE
630
+ REMOVE
631
+ }
632
+
633
+ """
634
+ One entry of this device's control-request queue: a control-plane mutation whose
635
+ commit lands later. A settled request keeps its row until ackControlRequest drops it.
636
+ """
637
+ type ControlRequest implements Node {
638
+ id: ID!
639
+ groupID: ID!
640
+ kind: ControlRequestKind!
641
+ status: ControlRequestStatus!
642
+ error: String
643
+ enqueuedAt: DateTimeISO!
644
+ settledAt: DateTimeISO
645
+ }
646
+
323
647
  extend type Mutation {
648
+ connectPeer(url: String!): ConnectPeerPayload!
649
+ sharePeerGroup(peerDID: ID!, groupID: ID, name: String, send: ShareInput, receive: ShareReceiveInput): SharePeerGroupPayload!
650
+ """
651
+ Admit a join request's author from local state alone: designate access as
652
+ sharePeerGroup does, then mint the invite carrying it. No peer connection is
653
+ opened — the caller hands the payload to the joiner however it can. The admitted
654
+ DID comes from the request's KeyPackage credential; a disagreeing did field, or a
655
+ DID already on the roster, is refused.
656
+ """
657
+ admitJoinRequest(groupID: ID!, joinRequest: String!, send: ShareInput, receive: ShareReceiveInput): AdmitJoinRequestPayload!
658
+ joinPeerGroup(peerDID: ID!, groupID: ID!): JoinPeerGroupPayload!
659
+ """
660
+ Apply one circle's desired sync end state. A null argument leaves that dimension
661
+ untouched; share with an empty model list stops sharing into the circle.
662
+ """
663
+ setCircleSync(circleID: ID!, pull: Boolean, share: ShareInput): CircleSyncState!
664
+ """
665
+ Sync with one named device now. groupID selects the ROUTE, not the scope — the
666
+ session carries the device's whole active-catalog scope, so one call can move
667
+ documents belonging to a different group.
668
+ """
669
+ syncPeer(groupID: ID!, peerDID: ID!): SyncPeerPayload!
324
670
  createGroup(input: CreateGroupInput!): CreateGroupPayload!
325
- addGroupMember(groupID: ID!, memberDID: DID!, role: String!): PeerGroupMember!
326
- createCircle(groupID: ID!, name: String!, description: String): PeerCircle!
327
- addCircleMember(circleID: ID!, memberDID: DID!, role: String!): PeerCircleMember!
671
+ requestCreateCircle(input: RequestCreateCircleInput!): RequestCreateCirclePayload!
672
+ requestAddCircleMember(input: RequestAddCircleMemberInput!): RequestAddCircleMemberPayload!
328
673
  createCatalog(name: String!, description: String, filterCriteria: String!): PeerCatalog!
329
- inviteToGroup(groupID: ID!, joinRequest: String!, grants: [String!], revocations: [String!]): InviteToGroupPayload!
674
+ requestInviteToGroup(input: RequestInviteToGroupInput!): RequestInviteToGroupPayload!
330
675
  joinGroup(invitePayload: String!, joinRequestPayload: String!): JoinGroupPayload!
331
676
  prepareJoinRequest: PrepareJoinRequestPayload!
332
- completeJoin(invitePayload: String!): CompleteJoinPayload!
677
+ """
678
+ Join the group an invite names, declaring in the same call what this device shares
679
+ back and whether it pulls. send / receive mirror admitJoinRequest. Left off, the
680
+ join is plumbing only: a member that grants nothing and pulls nothing. Applied to
681
+ every circle this device now belongs to, which is exactly what the invite seeded.
682
+ """
683
+ completeJoin(
684
+ invitePayload: String!
685
+ send: ShareInput
686
+ receive: ShareReceiveInput
687
+ ): CompleteJoinPayload!
333
688
  leaveGroup(groupID: ID!): LeaveGroupPayload!
334
- removeGroupMember(groupID: ID!, memberDID: DID!): RemoveGroupMemberPayload!
335
- setMemberRole(input: SetMemberRoleInput!): SetMemberRolePayload!
689
+ requestRemoveGroupMember(input: RequestRemoveGroupMemberInput!): RequestRemoveGroupMemberPayload!
690
+ requestSetMemberRole(input: RequestSetMemberRoleInput!): RequestSetMemberRolePayload!
336
691
  updateCatalog(input: UpdateCatalogInput!): PeerCatalog!
337
- updateGroup(input: UpdateGroupInput!): UpdateGroupPayload!
338
- updateCircle(input: UpdateCircleInput!): UpdateCirclePayload!
692
+ activateCatalogSync(catalogID: ID!): PeerCatalog!
693
+ deactivateCatalogSync(catalogID: ID!): PeerCatalog!
694
+ requestUpdateGroup(input: RequestUpdateGroupInput!): RequestUpdateGroupPayload!
695
+ requestUpdateCircle(input: UpdateCircleInput!): RequestUpdateCirclePayload!
339
696
  deleteCatalog(catalogID: ID!): DeleteCatalogPayload!
340
- deleteCircle(circleID: ID!): DeleteCirclePayload!
341
- removeCircleMember(circleID: ID!, memberDID: DID!): RemoveCircleMemberPayload!
697
+ requestDeleteCircle(input: RequestDeleteCircleInput!): RequestDeleteCirclePayload!
698
+ requestRemoveCircleMember(input: RequestRemoveCircleMemberInput!): RequestRemoveCircleMemberPayload!
342
699
  grantWriteCapability(to: DID!, res: String!, groupID: ID!, expiresIn: Int): String!
343
700
  revokeCapability(jti: ID!): Boolean!
344
- addSyncPeer(peerDID: DID!, endpoint: URL!, mode: String, priority: String, trustLevel: String): Boolean!
701
+ addSyncPeer(peerDID: DID!, endpoint: URL!): Boolean!
345
702
  removeSyncPeer(peerDID: DID!): RemoveSyncPeerPayload!
346
703
  createHub(input: CreateHubInput!): PeerHub!
347
704
  updateHub(input: UpdateHubInput!): PeerHub!
@@ -349,10 +706,26 @@ extend type Mutation {
349
706
  bindHubToGroup(hubID: ID!, groupID: ID!): Boolean!
350
707
  unbindHubFromGroup(hubID: ID!, groupID: ID!): Boolean!
351
708
  rejoinGroup(groupID: ID!): RejoinGroupPayload!
709
+ """
710
+ Drop a settled request from the queue — the app saying it has observed the
711
+ outcome. Acking a pending or unknown request is a no-op. node(id:) on an acked
712
+ request throws.
713
+ """
714
+ ackControlRequest(id: ID!): Boolean!
352
715
  }
353
716
 
354
717
  extend type PeerGroup {
355
718
  hubs: [PeerHub!]!
719
+ """
720
+ Whether the viewer may perform this group's admin-only operations right now. Read
721
+ from the membership row the admin gate consults, so the two never disagree.
722
+ """
723
+ viewerIsAdmin: Boolean!
724
+ """
725
+ Every current admin, from the same membership rows. Moves when the MLS commit
726
+ carrying a role change is enacted.
727
+ """
728
+ admins: [AccountNode!]!
356
729
  }
357
730
 
358
731
  extend type PeerHub {
@@ -365,6 +738,8 @@ extend type Subscription {
365
738
  groupDataChanged(groupID: ID!): PeerGroup!
366
739
  groupMemberJoined(groupID: ID!): PeerGroupMember!
367
740
  groupMemberLeft(groupID: ID!): PeerGroupMember!
741
+ """A member's role as it stands after the enacted commit that changed it."""
742
+ groupMemberRoleChanged(groupID: ID!): PeerGroupMember!
368
743
  circleCreated(groupID: ID!): PeerCircle!
369
744
  circleDeleted(groupID: ID!): PeerCircle!
370
745
  circleDataChanged(circleID: ID!): PeerCircle!
@@ -374,11 +749,48 @@ extend type Subscription {
374
749
  ownDelegationTokenAdded: DelegationToken!
375
750
  ownDelegationTokenRevoked: RevokedDelegationToken!
376
751
  groupHealthChanged(groupID: ID): GroupHealthEvent!
752
+ syncEvents(peerDID: ID): SyncEvent!
753
+ """
754
+ A control request reached a terminal status, under the same global id the
755
+ enqueuing mutation returned. requestID watches one request, replaying a status
756
+ that settled before the subscription opened, then completes; groupID watches every
757
+ request in the group and stays open. An unknown requestID waits rather than
758
+ failing — indistinguishable from an uncommitted enqueue — so bring your own timeout.
759
+ """
760
+ controlRequestSettled(groupID: ID, requestID: ID): ControlRequest!
377
761
  }
378
762
  `;
379
- export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-schema')) {
763
+ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubunLogger('p2p-schema')) {
380
764
  return {
381
765
  sdl,
766
+ // These mutations perform network I/O (peer discovery, the MLS dance,
767
+ // merkle sync) and/or apply received mutations in their own per-step
768
+ // transactions. Running them inside `mutateGraph`'s write transaction would
769
+ // hold the DB connection across a network round-trip and deadlock a nested
770
+ // apply on single-connection SQLite. They still run owner-signed through
771
+ // `mutateGraph`'s authorization path — only the outer transaction is
772
+ // dropped, and their local writes autocommit per step (each step is
773
+ // idempotent, so a partial run is retry-safe).
774
+ nonTransactionalMutationFields: [
775
+ 'connectPeer',
776
+ 'syncPeer',
777
+ 'sharePeerGroup',
778
+ // Runs the same designation as `sharePeerGroup` — every step awaits a
779
+ // control-request settle — and then awaits its own Add commit.
780
+ 'admitJoinRequest',
781
+ // The mirror of `admitJoinRequest`, and network I/O for the same reason:
782
+ // it calls `setCircleSync` per circle, which pushes `group/push-control`
783
+ // over a live peer connection. Latent while the offline CLI join holds no
784
+ // connection — that arm iterates an empty list — and a deadlock the first
785
+ // time a joiner completes with one held.
786
+ 'completeJoin',
787
+ 'joinPeerGroup',
788
+ 'setCircleSync',
789
+ // Waits for its Add commit to land. The lane reads the group's handle on
790
+ // the registry's own connection, so holding a write transaction across
791
+ // that wait deadlocks single-connection SQLite.
792
+ 'requestInviteToGroup'
793
+ ],
382
794
  resolvers: {
383
795
  queryFields: {
384
796
  groups: async (_source, _args, context)=>{
@@ -393,6 +805,18 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
393
805
  circles: async (_source, args, context)=>{
394
806
  return await requireP2P(context).group.listCircles(args.groupID);
395
807
  },
808
+ peerDevices: async (_source, args, context)=>{
809
+ return await requireP2P(context).sync.listPeerDevices(args.groupID);
810
+ },
811
+ circleSync: async (_source, args, context)=>{
812
+ return await requireP2P(context).peer.listCircleSyncStates(args.groupID);
813
+ },
814
+ catalogs: async (_source, args, context)=>{
815
+ const active = args.active;
816
+ return await requireP2P(context).group.listStoredCatalogs(active != null ? {
817
+ active
818
+ } : undefined);
819
+ },
396
820
  hubs: async (_source, _args, context)=>{
397
821
  return await requireP2P(context).hub.list();
398
822
  },
@@ -410,8 +834,8 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
410
834
  };
411
835
  },
412
836
  // INERT: always reports `healthy` — nothing signals the monitor. Live
413
- // scaffolding for epoch-stale recovery; see
414
- // docs/agents/plans/backlog/welcome-based-deep-recovery.md.
837
+ // scaffolding for epoch-stale recovery, kept so the surface exists once
838
+ // something drives it.
415
839
  groupHealth: (_source, args, context)=>{
416
840
  const groupID = args.groupID;
417
841
  const { state, lastTransitionAt } = requireP2P(context).hub.getGroupHealth(groupID);
@@ -421,6 +845,10 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
421
845
  lastTransitionAt: lastTransitionAt == null ? null : new Date(lastTransitionAt).toISOString()
422
846
  };
423
847
  },
848
+ controlRequests: async (_source, args, context)=>{
849
+ const requests = await requireP2P(context).group.listControlRequests(args.groupID);
850
+ return requests.map(toControlRequestSDL);
851
+ },
424
852
  ownDelegationTokens: async (_source, args, context)=>{
425
853
  const viewerDID = context.getViewer();
426
854
  if (viewerDID == null || context.p2p == null) {
@@ -447,6 +875,83 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
447
875
  }
448
876
  },
449
877
  mutationFields: {
878
+ connectPeer: async (_source, args, context)=>{
879
+ const peer = await requireP2P(context).peer.connect(args.url);
880
+ return {
881
+ peer
882
+ };
883
+ },
884
+ sharePeerGroup: async (_source, args, context)=>{
885
+ const send = args.send;
886
+ const receive = args.receive;
887
+ return await requireP2P(context).peer.sharePeerGroup({
888
+ peerDID: args.peerDID,
889
+ groupID: args.groupID ?? null,
890
+ name: args.name ?? null,
891
+ // No `send` = no access legs (plumbing only); the leg exists only from
892
+ // this explicit user selection. `receive.activate` opts the sharer into
893
+ // pulling the share scope (default off).
894
+ sendModels: send?.models ?? null,
895
+ receiveActivate: receive?.activate ?? false,
896
+ // Model-default rows go through the engine's owner-signed executor so
897
+ // `engine:access-default:set` fires and broadcast/invite seeding stay
898
+ // consistent — the same path the `setModelAccessDefaults` mutation uses.
899
+ setReadDefaultForCircle: async (modelID, circleID)=>{
900
+ await context.executeSetModelAccessDefaults({
901
+ modelID,
902
+ permissionType: 'read',
903
+ accessLevel: 'restricted',
904
+ allowedDIDs: null,
905
+ allowedCircles: [
906
+ circleID
907
+ ],
908
+ allowedGroups: null
909
+ });
910
+ }
911
+ });
912
+ },
913
+ admitJoinRequest: async (_source, args, context)=>{
914
+ const send = args.send;
915
+ const receive = args.receive;
916
+ return await requireP2P(context).peer.admitJoinRequest({
917
+ groupID: args.groupID,
918
+ joinRequest: args.joinRequest,
919
+ sendModels: send?.models ?? null,
920
+ receiveActivate: receive?.activate ?? false,
921
+ // Same owner-signed executor `sharePeerGroup` binds, so the rows this
922
+ // designation writes are indistinguishable from a connected share's.
923
+ setReadDefaultForCircle: async (modelID, circleID)=>{
924
+ await context.executeSetModelAccessDefaults({
925
+ modelID,
926
+ permissionType: 'read',
927
+ accessLevel: 'restricted',
928
+ allowedDIDs: null,
929
+ allowedCircles: [
930
+ circleID
931
+ ],
932
+ allowedGroups: null
933
+ });
934
+ }
935
+ });
936
+ },
937
+ joinPeerGroup: async (_source, args, context)=>{
938
+ return await requireP2P(context).peer.joinPeerGroup({
939
+ peerDID: args.peerDID,
940
+ groupID: args.groupID
941
+ });
942
+ },
943
+ setCircleSync: async (_source, args, context)=>{
944
+ const share = args.share;
945
+ return await requireP2P(context).peer.setCircleSync({
946
+ circleID: args.circleID,
947
+ pull: args.pull ?? null,
948
+ share: share ?? null,
949
+ ...accessDefaultExecutors(context)
950
+ });
951
+ },
952
+ syncPeer: async (_source, args, context)=>{
953
+ return await requireP2P(context).sync.syncPeer(args.groupID, args.peerDID);
954
+ },
450
955
  createGroup: async (_source, args, context)=>{
451
956
  const input = args.input;
452
957
  const group = await requireP2P(context).group.create(input);
@@ -454,18 +959,36 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
454
959
  group
455
960
  };
456
961
  },
457
- addGroupMember: async (_source, args, context)=>{
458
- return await requireP2P(context).group.addMember(args.groupID, args.memberDID, args.role);
459
- },
460
- createCircle: async (_source, args, context)=>{
461
- return await requireP2P(context).group.createCircle({
462
- groupID: args.groupID,
463
- name: args.name,
464
- description: args.description
962
+ requestCreateCircle: async (_source, args, context)=>{
963
+ const input = args.input;
964
+ // The context method gates again for callers that reach it directly.
965
+ //
966
+ // Returns `pending`, not awaited: the commit lane runs on the outermost
967
+ // commit, after this transaction closes.
968
+ return await gatedTransaction('requestCreateCircle', ()=>requireP2P(context).group.requireAdmin(input.groupID, 'create a circle'), async ()=>{
969
+ // Only the three public fields are forwarded: `circleID` is an
970
+ // internal affordance for a caller that derives it, and letting a
971
+ // client name it would merge two admins' circles under one subject.
972
+ const { request, circleID } = await requireP2P(context).group.requestCreateCircle({
973
+ groupID: input.groupID,
974
+ name: input.name,
975
+ description: input.description
976
+ });
977
+ return {
978
+ request: toControlRequestSDL(request),
979
+ circleID
980
+ };
465
981
  });
466
982
  },
467
- addCircleMember: async (_source, args, context)=>{
468
- return await requireP2P(context).group.addCircleMember(args.circleID, args.memberDID, args.role);
983
+ requestAddCircleMember: async (_source, args, context)=>{
984
+ const input = args.input;
985
+ // The group is reached through the circle, so the gate resolves it first.
986
+ return await gatedTransaction('requestAddCircleMember', ()=>requireP2P(context).group.requireCircleAdmin(input.circleID, 'add a circle member'), async ()=>{
987
+ const request = await requireP2P(context).group.requestAddCircleMember(input);
988
+ return {
989
+ request: toControlRequestSDL(request)
990
+ };
991
+ });
469
992
  },
470
993
  createCatalog: async (_source, args, context)=>{
471
994
  return await requireP2P(context).group.createCatalog({
@@ -474,15 +997,17 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
474
997
  filterCriteria: args.filterCriteria
475
998
  });
476
999
  },
477
- inviteToGroup: async (_source, args, context)=>{
478
- // invite advances the MLS ratchet and writes the local roster: any
479
- // throw must roll back the whole transaction so the eagerly-swapped
480
- // MLS epoch can never outlive a failed roster write.
481
- try {
482
- return await requireP2P(context).group.invite(args.groupID, args.joinRequest, args.grants, args.revocations);
483
- } catch (error) {
484
- failTransactionFatal('inviteToGroup', error);
485
- }
1000
+ requestInviteToGroup: async (_source, args, context)=>{
1001
+ const input = args.input;
1002
+ // No rollback wrapper: this field is non-transactional, so there is no
1003
+ // outer write transaction to undo. It cannot be transactional — it
1004
+ // waits for the commit lane, and the lane's own reads need the
1005
+ // connection a write transaction would be holding.
1006
+ const result = await requireP2P(context).group.requestInvite(input);
1007
+ return {
1008
+ ...result,
1009
+ request: toControlRequestSDL(result.request)
1010
+ };
486
1011
  },
487
1012
  joinGroup: async (_source, args, context)=>{
488
1013
  return await requireP2P(context).group.join(args.invitePayload, args.joinRequestPayload);
@@ -491,7 +1016,36 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
491
1016
  return await requireP2P(context).join.prepareRequest();
492
1017
  },
493
1018
  completeJoin: async (_source, args, context)=>{
494
- return await requireP2P(context).join.complete(args.invitePayload);
1019
+ const p2p = requireP2P(context);
1020
+ const result = await p2p.join.complete(args.invitePayload);
1021
+ const send = args.send;
1022
+ const receive = args.receive;
1023
+ const groupID = result.group?.id;
1024
+ if (groupID == null || send == null && receive == null) {
1025
+ return result;
1026
+ }
1027
+ // Every circle this device now reaches in the joined group. It joined a
1028
+ // moment ago, so those are the circles the invite seeded and no others
1029
+ // — deriving the scope from the invite payload would say the same thing
1030
+ // through a second reading of it.
1031
+ // Only circles carrying a catalog. A circle without one is not a scope:
1032
+ // `pull` has nothing to activate and a read default naming it is granted
1033
+ // to a set no catalog will ever ask for — the pair reads as configured
1034
+ // and moves nothing, which is the failure this argument exists to close.
1035
+ const circles = (await p2p.peer.listCircleSyncStates(groupID)).filter((circle)=>circle.catalogIDs.length > 0);
1036
+ if (circles.length === 0) {
1037
+ throw new Error('completeJoin: the invite designated no circle to share into — the admitting device admitted without `send`, so this pair can move nothing');
1038
+ }
1039
+ const executors = accessDefaultExecutors(context);
1040
+ for (const circle of circles){
1041
+ await p2p.peer.setCircleSync({
1042
+ circleID: circle.id,
1043
+ pull: receive?.activate ?? null,
1044
+ share: send ?? null,
1045
+ ...executors
1046
+ });
1047
+ }
1048
+ return result;
495
1049
  },
496
1050
  leaveGroup: async (_source, args, context)=>{
497
1051
  // leave tears down the MLS handle and emits a leave event: any throw
@@ -502,64 +1056,100 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
502
1056
  failTransactionFatal('leaveGroup', error);
503
1057
  }
504
1058
  },
505
- removeGroupMember: async (_source, args, context)=>{
506
- // removeMember advances the MLS ratchet and tombstones the removed
507
- // member: any throw must roll back so a removed member can never be
508
- // left untombstoned (and still writable) after a partial commit.
509
- try {
510
- return await requireP2P(context).group.removeMember(args.groupID, args.memberDID);
511
- } catch (error) {
512
- failTransactionFatal('removeGroupMember', error);
513
- }
1059
+ requestRemoveGroupMember: async (_source, args, context)=>{
1060
+ const input = args.input;
1061
+ // This transaction owns only the request row the MLS advance, the roster
1062
+ // tombstone and the demotion belong to the accepted commit — so a throw
1063
+ // must roll it back: a queued request no lane settles is `pending` forever.
1064
+ // Returns `pending`, not awaited: the commit lane runs on the outermost
1065
+ // commit, after this transaction closes.
1066
+ return await gatedTransaction('requestRemoveGroupMember', ()=>requireP2P(context).group.requireAdmin(input.groupID, 'remove a member'), async ()=>{
1067
+ const request = await requireP2P(context).group.requestRemoveMember(input);
1068
+ return {
1069
+ request: toControlRequestSDL(request)
1070
+ };
1071
+ });
514
1072
  },
515
- setMemberRole: async (_source, args, context)=>{
1073
+ requestSetMemberRole: async (_source, args, context)=>{
516
1074
  const input = args.input;
517
- // setMemberRole appends a ledger entry and reprojects the admin
518
- // overlay: any throw must roll back so the durable ledger and the
519
- // projected role column can never diverge across a partial commit.
520
- try {
521
- const member = await requireP2P(context).group.setMemberRole(input);
1075
+ // The entry and the request that reports it are written in ONE transaction,
1076
+ // so neither can outlive the other's rollback.
1077
+ // Returns `pending`, not awaited: the commit lane runs on the outermost
1078
+ // commit, after this transaction closes.
1079
+ //
1080
+ return await gatedTransaction('requestSetMemberRole', ()=>requireP2P(context).group.requireAdmin(input.groupID, 'set member roles'), async ()=>{
1081
+ const request = await requireP2P(context).group.requestSetMemberRole(input);
522
1082
  return {
523
- member
1083
+ request: toControlRequestSDL(request)
524
1084
  };
525
- } catch (error) {
526
- failTransactionFatal('setMemberRole', error);
527
- }
1085
+ });
528
1086
  },
529
1087
  updateCatalog: async (_source, args, context)=>{
530
1088
  const input = args.input;
531
1089
  return await requireP2P(context).group.updateCatalog(input);
532
1090
  },
533
- updateGroup: async (_source, args, context)=>{
1091
+ activateCatalogSync: async (_source, args, context)=>{
1092
+ return await requireP2P(context).group.activateCatalogSync(args.catalogID);
1093
+ },
1094
+ deactivateCatalogSync: async (_source, args, context)=>{
1095
+ return await requireP2P(context).group.deactivateCatalogSync(args.catalogID);
1096
+ },
1097
+ requestUpdateGroup: async (_source, args, context)=>{
534
1098
  const input = args.input;
535
- const group = await requireP2P(context).group.update(input);
536
- return {
537
- group
538
- };
1099
+ // The context method gates again, ahead of its own no-op check, so a
1100
+ // caller with no authority is refused rather than told NO_OP.
1101
+ //
1102
+ // Nothing is appended to the ledger here — the fold off the enacted commit
1103
+ // is the only writer — so a throw must roll back the request row.
1104
+ // Returns `pending`, not awaited: the commit lane runs on the outermost
1105
+ // commit, after this transaction closes.
1106
+ //
1107
+ return await gatedTransaction('requestUpdateGroup', ()=>requireP2P(context).group.requireAdmin(input.groupID, 'update a group'), async ()=>{
1108
+ const request = await requireP2P(context).group.requestUpdate(input);
1109
+ return {
1110
+ request: toControlRequestSDL(request)
1111
+ };
1112
+ });
539
1113
  },
540
- updateCircle: async (_source, args, context)=>{
1114
+ requestUpdateCircle: async (_source, args, context)=>{
541
1115
  const input = args.input;
542
- const circle = await requireP2P(context).group.updateCircle(input);
543
- return {
544
- circle
545
- };
1116
+ // The gate runs ahead of the context method's own no-op check, so a
1117
+ // caller with no authority is refused rather than told NO_OP.
1118
+ return await gatedTransaction('requestUpdateCircle', ()=>requireP2P(context).group.requireCircleAdmin(input.circleID, 'update a circle'), async ()=>{
1119
+ const request = await requireP2P(context).group.requestUpdateCircle(input);
1120
+ return {
1121
+ request: toControlRequestSDL(request)
1122
+ };
1123
+ });
546
1124
  },
547
1125
  deleteCatalog: async (_source, args, context)=>{
548
1126
  return await requireP2P(context).group.deleteCatalog(args.catalogID);
549
1127
  },
550
- deleteCircle: async (_source, args, context)=>{
551
- return await requireP2P(context).group.deleteCircle(args.circleID);
1128
+ requestDeleteCircle: async (_source, args, context)=>{
1129
+ const input = args.input;
1130
+ // The context method gates again for callers that reach it directly.
1131
+ //
1132
+ // Returns `pending`, not awaited: the commit lane runs on the outermost
1133
+ // commit, after this transaction closes.
1134
+ //
1135
+ return await gatedTransaction('requestDeleteCircle', ()=>requireP2P(context).group.requireCircleAdmin(input.circleID, 'delete a circle'), async ()=>{
1136
+ const request = await requireP2P(context).group.requestDeleteCircle(input);
1137
+ return {
1138
+ request: toControlRequestSDL(request)
1139
+ };
1140
+ });
552
1141
  },
553
- removeCircleMember: async (_source, args, context)=>{
554
- // removeCircleMember appends a tombstone ledger entry and reprojects
555
- // the circle-member overlay: any throw must roll back so the durable
556
- // ledger and the projected row can never diverge across a partial
557
- // commit.
558
- try {
559
- return await requireP2P(context).group.removeCircleMember(args.circleID, args.memberDID);
560
- } catch (error) {
561
- failTransactionFatal('removeCircleMember', error);
562
- }
1142
+ requestRemoveCircleMember: async (_source, args, context)=>{
1143
+ const input = args.input;
1144
+ // The context method gates again for direct callers; the entry plus
1145
+ // the request that reports its outcome are written in one transaction,
1146
+ // so any throw rolls back both.
1147
+ return await gatedTransaction('requestRemoveCircleMember', ()=>requireP2P(context).group.requireCircleAdmin(input.circleID, 'remove a circle member'), async ()=>{
1148
+ const request = await requireP2P(context).group.requestRemoveCircleMember(input);
1149
+ return {
1150
+ request: toControlRequestSDL(request)
1151
+ };
1152
+ });
563
1153
  },
564
1154
  grantWriteCapability: async (_source, args, context)=>{
565
1155
  return await requireP2P(context).group.grantWriteCapability({
@@ -577,13 +1167,7 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
577
1167
  addSyncPeer: async (_source, args, context)=>{
578
1168
  await requireP2P(context).sync.addPeer({
579
1169
  peerDID: args.peerDID,
580
- endpoint: args.endpoint,
581
- mode: args.mode ?? 'on-demand',
582
- allowedUsers: {
583
- all: true
584
- },
585
- priority: args.priority ?? '0',
586
- trustLevel: args.trustLevel ?? 'trusted'
1170
+ endpoint: args.endpoint
587
1171
  });
588
1172
  return true;
589
1173
  },
@@ -616,11 +1200,20 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
616
1200
  groupID: args.groupID
617
1201
  });
618
1202
  },
619
- // INERT: `getGroupHealth` always returns `healthy` (nothing signals the
620
- // monitor), so the kickoff branch below is currently unreachable and
621
- // `hub.rejoinGroup` terminates in the deferred `recovery-failed` stub.
622
- // Reserved for epoch-stale recovery; see
623
- // docs/agents/plans/backlog/welcome-based-deep-recovery.md.
1203
+ // Takes the GLOBAL id and decodes it, unlike `Connector`'s local-key
1204
+ // convention: a client only ever holds `ControlRequest.id`, and that is
1205
+ // the global one. The `id` type-field and this decode are the only
1206
+ // translators between the two.
1207
+ ackControlRequest: async (_source, args, context)=>{
1208
+ const localID = PluginNodeID.fromString(args.id).localID;
1209
+ await requireP2P(context).group.ackControlRequest(localID);
1210
+ return true;
1211
+ },
1212
+ // `hub.rejoinGroup` genuinely recovers through the rendezvous now, but
1213
+ // this kickoff branch stays unreachable: `getGroupHealth` always returns
1214
+ // `healthy` because nothing signals the monitor yet, so `state` never
1215
+ // leaves `healthy`. Wiring the auto-detection that would fire this is
1216
+ // separate; see docs/agents/plans/backlog/recovery-rendezvous-transport.md.
624
1217
  rejoinGroup: (_source, args, context)=>{
625
1218
  const groupID = args.groupID;
626
1219
  const hub = requireP2P(context).hub;
@@ -649,13 +1242,54 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
649
1242
  hubs: async (source, _args, context)=>{
650
1243
  const group = source;
651
1244
  return await requireP2P(context).hub.listByGroup(group.id);
1245
+ },
1246
+ // Reads the viewer's membership row through the same store call the
1247
+ // admin gate makes, so the answer this field gives and the answer a
1248
+ // refused mutation gives can never come apart. An unauthenticated
1249
+ // request has no membership row to be an admin on, so it reads false
1250
+ // rather than failing.
1251
+ viewerIsAdmin: async (source, _args, context)=>{
1252
+ const viewerDID = context.getViewer();
1253
+ if (viewerDID == null) {
1254
+ return false;
1255
+ }
1256
+ const group = source;
1257
+ return await requireP2P(context).group.isAdmin(group.id, viewerDID);
1258
+ },
1259
+ // Same membership rows, same liveness boundary as `viewerIsAdmin`: a
1260
+ // removed member is not listed, and a DID carrying a role without a
1261
+ // membership row cannot appear at all.
1262
+ admins: async (source, _args, context)=>{
1263
+ const group = source;
1264
+ const members = await requireP2P(context).group.listMembers(group.id);
1265
+ return members.filter((m)=>m.role === 'admin').map((m)=>m.memberDID);
652
1266
  }
653
1267
  },
1268
+ PeerGroupMember: {
1269
+ // A DID string is all `AccountNode` needs: the shared node fetcher
1270
+ // returns a `did:` id unchanged and types it as an account.
1271
+ account: (source)=>source.memberDID
1272
+ },
654
1273
  PeerHub: {
655
1274
  groups: async (source, _args, context)=>{
656
1275
  const hub = source;
657
1276
  return await requireP2P(context).hub.listGroups(hub.id);
658
1277
  }
1278
+ },
1279
+ ControlRequest: {
1280
+ id: (source)=>{
1281
+ const request = source;
1282
+ return PluginNodeID.create('p2p', 'ControlRequest', request.id).toString();
1283
+ }
1284
+ }
1285
+ },
1286
+ nodeResolvers: {
1287
+ ControlRequest: {
1288
+ resolve: async (localID, context)=>{
1289
+ const ctx = context;
1290
+ const request = await requireP2P(ctx).group.getControlRequest(localID);
1291
+ return request == null ? null : toControlRequestSDL(request);
1292
+ }
659
1293
  }
660
1294
  },
661
1295
  subscriptionFields: {
@@ -679,6 +1313,10 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
679
1313
  resolve: (member)=>member,
680
1314
  subscribe: (_src, args)=>createFilteredGenerator(emitter, 'groupMemberLeft', (d)=>d.groupID === args.groupID)
681
1315
  },
1316
+ groupMemberRoleChanged: {
1317
+ resolve: (member)=>member,
1318
+ subscribe: (_src, args)=>createFilteredGenerator(emitter, 'groupMemberRoleChanged', (d)=>d.groupID === args.groupID)
1319
+ },
682
1320
  circleCreated: {
683
1321
  resolve: (circle)=>circle,
684
1322
  subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleCreated', (d)=>d.groupID === args.groupID)
@@ -725,9 +1363,45 @@ export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-s
725
1363
  return createFilteredGenerator(emitter, 'delegationTokenRevoked', (data)=>data.audience === viewerDID);
726
1364
  }
727
1365
  },
1366
+ syncEvents: {
1367
+ resolve: (event)=>{
1368
+ const syncEvent = event;
1369
+ return {
1370
+ type: syncEvent.type,
1371
+ peerID: syncEvent.peerID,
1372
+ documentID: syncEvent.documentID ?? null,
1373
+ timestamp: syncEvent.timestamp,
1374
+ error: syncEvent.error ?? null
1375
+ };
1376
+ },
1377
+ subscribe: (_src, args)=>{
1378
+ const peerDID = args.peerDID;
1379
+ return syncManager.subscribeEvents(peerDID == null ? undefined : (event)=>event.peerID === peerDID);
1380
+ }
1381
+ },
1382
+ controlRequestSettled: {
1383
+ resolve: (data)=>toControlRequestSDL(data),
1384
+ subscribe: (_src, args, context)=>{
1385
+ const globalRequestID = args.requestID;
1386
+ if (globalRequestID != null) {
1387
+ // The client holds the global id the enqueuing payload returned;
1388
+ // the row and the emitted event carry the local one.
1389
+ const requestID = PluginNodeID.fromString(globalRequestID).localID;
1390
+ // The stream is opened HERE, synchronously, before the generator
1391
+ // body below ever reads the row: a settle landing between the two
1392
+ // is queued by the listener, so there is no window where an
1393
+ // outcome is neither in the row nor in the stream. A settle that
1394
+ // already happened is only in the row, which is why the read has
1395
+ // to happen at all.
1396
+ const stream = createFilteredGenerator(emitter, 'controlRequestSettled', (d)=>d.id === requestID);
1397
+ return watchOneControlRequest(stream, ()=>requireP2P(context).group.getControlRequest(requestID));
1398
+ }
1399
+ const groupID = args.groupID;
1400
+ return createFilteredGenerator(emitter, 'controlRequestSettled', groupID == null ? undefined : (d)=>d.groupID === groupID);
1401
+ }
1402
+ },
728
1403
  // INERT: this subscription can never fire — `groupHealthChanged` has no
729
- // live producer. Reserved for epoch-stale recovery; see
730
- // docs/agents/plans/backlog/welcome-based-deep-recovery.md.
1404
+ // live producer. Reserved for epoch-stale recovery.
731
1405
  groupHealthChanged: {
732
1406
  resolve: (data)=>{
733
1407
  const event = data;