@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.
- package/lib/context/group.js +1073 -746
- package/lib/context/hub.js +2 -1
- package/lib/context/join.js +50 -15
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +234 -1
- package/lib/context/types.d.ts +56 -20
- package/lib/context/types.js +23 -3
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +42 -117
- package/lib/groups/admin-roster.js +53 -168
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast.d.ts +199 -81
- package/lib/groups/broadcast.js +443 -368
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +32 -64
- package/lib/groups/circle-projection.js +95 -181
- package/lib/groups/circle-reducers.d.ts +71 -110
- package/lib/groups/circle-reducers.js +57 -127
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +14 -1
- package/lib/groups/group-anchor.d.ts +18 -57
- package/lib/groups/group-anchor.js +15 -100
- package/lib/groups/group-crypto.d.ts +36 -13
- package/lib/groups/group-crypto.js +124 -50
- package/lib/groups/group-handle-registry.d.ts +106 -36
- package/lib/groups/group-handle-registry.js +351 -61
- package/lib/groups/group-handlers.d.ts +5 -4
- package/lib/groups/group-handlers.js +155 -29
- package/lib/groups/group-health-monitor.d.ts +1 -1
- package/lib/groups/group-health-monitor.js +1 -1
- package/lib/groups/group-mls.d.ts +35 -4
- package/lib/groups/group-mls.js +594 -62
- package/lib/groups/group-peer-manager.d.ts +212 -19
- package/lib/groups/group-peer-manager.js +1061 -69
- package/lib/groups/group-protocols.d.ts +485 -28
- package/lib/groups/group-protocols.js +289 -15
- package/lib/groups/invite-payload.d.ts +48 -0
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +60 -0
- package/lib/groups/join-utils.js +262 -0
- package/lib/groups/ledger-affected-events.d.ts +11 -6
- package/lib/groups/ledger-affected-events.js +15 -26
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +202 -19
- package/lib/groups/manager.js +623 -106
- package/lib/groups/mls-codec.d.ts +27 -6
- package/lib/groups/mls-codec.js +30 -15
- package/lib/groups/mls-encryptor.js +9 -6
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +12 -4
- package/lib/groups/mls-receive-errors.d.ts +0 -2
- package/lib/groups/mls-receive-errors.js +3 -6
- package/lib/groups/mls-state.d.ts +5 -3
- package/lib/groups/mls-state.js +27 -4
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +4 -3
- package/lib/groups/store-received-grant.js +80 -35
- package/lib/groups/store-received-revocation.d.ts +16 -13
- package/lib/groups/store-received-revocation.js +23 -22
- package/lib/hub/http-client.d.ts +10 -27
- package/lib/hub/http-client.js +2 -19
- package/lib/hub/hub-like.d.ts +57 -6
- package/lib/hub/hub-like.js +539 -52
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +51 -94
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +71 -14
- package/lib/hub/wiring.js +80 -23
- package/lib/index.d.ts +48 -13
- package/lib/index.js +257 -86
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +213 -0
- package/lib/protocol.js +268 -0
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +805 -131
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +57 -3
- package/lib/sync/authorize.js +45 -4
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +2 -3
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +55 -39
- package/lib/sync/errors.d.ts +9 -0
- package/lib/sync/errors.js +10 -0
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +13 -19
- package/lib/sync/handlers.js +54 -14
- package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
- package/lib/sync/hub-tunnel-sync-listener.js +126 -37
- package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
- package/lib/sync/hub-tunnel-sync-provider.js +68 -19
- package/lib/sync/merkle-channel.d.ts +9 -1
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/peer-registry.d.ts +14 -10
- package/lib/sync/peer-registry.js +0 -25
- package/lib/sync/sync-client.d.ts +11 -1
- package/lib/sync/sync-client.js +72 -10
- package/lib/sync/sync-manager.d.ts +22 -4
- package/lib/sync/sync-manager.js +39 -10
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +699 -28
- package/lib/types.js +0 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +37 -3
- package/package.json +56 -52
- package/lib/groups/ledger-entry.d.ts +0 -37
- package/lib/groups/ledger-entry.js +0 -56
- package/lib/groups/ledger-fold.d.ts +0 -56
- package/lib/groups/ledger-fold.js +0 -53
- package/lib/hub/did-observing-transport.d.ts +0 -75
- package/lib/hub/did-observing-transport.js +0 -126
- package/lib/hub/errors.d.ts +0 -24
- 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
|
-
*
|
|
21
|
-
* privilege
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
64
|
-
role:
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
277
|
+
input RequestSetMemberRoleInput {
|
|
148
278
|
groupID: ID!
|
|
149
279
|
memberDID: DID!
|
|
150
280
|
role: GroupMemberRole!
|
|
151
281
|
}
|
|
152
282
|
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
189
|
-
|
|
358
|
+
input RequestDeleteCircleInput {
|
|
359
|
+
circleID: ID!
|
|
190
360
|
}
|
|
191
361
|
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
674
|
+
requestInviteToGroup(input: RequestInviteToGroupInput!): RequestInviteToGroupPayload!
|
|
330
675
|
joinGroup(invitePayload: String!, joinRequestPayload: String!): JoinGroupPayload!
|
|
331
676
|
prepareJoinRequest: PrepareJoinRequestPayload!
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
689
|
+
requestRemoveGroupMember(input: RequestRemoveGroupMemberInput!): RequestRemoveGroupMemberPayload!
|
|
690
|
+
requestSetMemberRole(input: RequestSetMemberRoleInput!): RequestSetMemberRolePayload!
|
|
336
691
|
updateCatalog(input: UpdateCatalogInput!): PeerCatalog!
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
|
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
|
|
414
|
-
//
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
1073
|
+
requestSetMemberRole: async (_source, args, context)=>{
|
|
516
1074
|
const input = args.input;
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
1083
|
+
request: toControlRequestSDL(request)
|
|
524
1084
|
};
|
|
525
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
1114
|
+
requestUpdateCircle: async (_source, args, context)=>{
|
|
541
1115
|
const input = args.input;
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
|
|
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
|
|
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;
|