@kubun/plugin-p2p 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- 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 +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- 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 +55 -0
- package/lib/groups/admin-roster.js +69 -0
- 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-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- 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 +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- 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 +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- 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 +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- 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 +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- 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 +155 -1
- 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 +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- 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 +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- 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 +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- 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 +704 -22
- package/lib/types.js +1 -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 +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -89
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
package/lib/schema.js
CHANGED
|
@@ -1,4 +1,152 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import { TransactionFatalError } from '@kubun/engine';
|
|
2
|
+
import { PluginNodeID } from '@kubun/id';
|
|
3
|
+
import { getKubunLogger } from '@kubun/logger';
|
|
4
|
+
import { GraphQLError } from 'graphql';
|
|
5
|
+
import { createFilteredGenerator } from './groups/events.js';
|
|
6
|
+
function requireP2P(ctx) {
|
|
7
|
+
if (ctx.p2p == null) {
|
|
8
|
+
throw new Error('p2p plugin is not wired into this context');
|
|
9
|
+
}
|
|
10
|
+
return ctx.p2p;
|
|
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
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Re-raise any failure from a membership/circle operation as the engine's
|
|
41
|
+
* transaction-rollback marker so `mutateGraph` rolls back the whole transaction.
|
|
42
|
+
* These ops do multi-step writes (an MLS ratchet advance plus a roster write, or
|
|
43
|
+
* a ledger append plus a reprojection); graphql-js would otherwise swallow a
|
|
44
|
+
* follow-on throw into `result.errors` and commit a partial result, leaving e.g.
|
|
45
|
+
* a removed member untombstoned and still writable. The original error's message
|
|
46
|
+
* is forwarded unchanged so an expected validation error (e.g. access-denied)
|
|
47
|
+
* still reaches the client as before; the cause carries the full original error.
|
|
48
|
+
*
|
|
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.
|
|
53
|
+
*
|
|
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.
|
|
60
|
+
*/ function failTransactionFatal(operation, error) {
|
|
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;
|
|
68
|
+
throw new TransactionFatalError(message, {
|
|
69
|
+
cause: error,
|
|
70
|
+
...extensions == null ? {} : {
|
|
71
|
+
extensions
|
|
72
|
+
}
|
|
73
|
+
});
|
|
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
|
+
}
|
|
93
|
+
// Internal lowercase-kebab health values -> SDL enum names.
|
|
94
|
+
const HEALTH_STATE_TO_SDL = {
|
|
95
|
+
healthy: 'HEALTHY',
|
|
96
|
+
degraded: 'DEGRADED',
|
|
97
|
+
recovering: 'RECOVERING',
|
|
98
|
+
recovered: 'RECOVERED',
|
|
99
|
+
'recovery-failed': 'RECOVERY_FAILED'
|
|
100
|
+
};
|
|
101
|
+
const HEALTH_CONDITION_TO_SDL = {
|
|
102
|
+
'epoch-stale': 'EPOCH_STALE'
|
|
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
|
+
}
|
|
146
|
+
async function* emptyAsyncGenerator() {
|
|
147
|
+
// no-op: closes immediately so subscribers without a viewer get no events
|
|
148
|
+
}
|
|
149
|
+
const sdl = `
|
|
2
150
|
type PeerGroup {
|
|
3
151
|
id: ID!
|
|
4
152
|
name: String!
|
|
@@ -9,8 +157,8 @@ type PeerGroup {
|
|
|
9
157
|
|
|
10
158
|
type PeerGroupMember {
|
|
11
159
|
groupID: ID!
|
|
12
|
-
|
|
13
|
-
role:
|
|
160
|
+
account: AccountNode!
|
|
161
|
+
role: GroupMemberRole!
|
|
14
162
|
createdAt: DateTimeISO!
|
|
15
163
|
}
|
|
16
164
|
|
|
@@ -19,9 +167,20 @@ type PeerCircle {
|
|
|
19
167
|
groupID: ID!
|
|
20
168
|
name: String!
|
|
21
169
|
description: String
|
|
170
|
+
"""Admin-authored: every group member reaches an open circle implicitly."""
|
|
171
|
+
open: Boolean!
|
|
22
172
|
createdAt: DateTimeISO!
|
|
23
173
|
}
|
|
24
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
|
+
|
|
25
184
|
type PeerCircleMember {
|
|
26
185
|
circleID: ID!
|
|
27
186
|
memberDID: DID!
|
|
@@ -35,6 +194,9 @@ type PeerCatalog {
|
|
|
35
194
|
name: String!
|
|
36
195
|
description: String
|
|
37
196
|
filterCriteria: String!
|
|
197
|
+
active: Boolean!
|
|
198
|
+
sourceGroupID: ID
|
|
199
|
+
sourceCircleID: ID
|
|
38
200
|
createdAt: DateTimeISO!
|
|
39
201
|
}
|
|
40
202
|
|
|
@@ -62,8 +224,21 @@ type CreateGroupPayload {
|
|
|
62
224
|
group: PeerGroup!
|
|
63
225
|
}
|
|
64
226
|
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
67
242
|
groupID: ID!
|
|
68
243
|
groupName: String!
|
|
69
244
|
}
|
|
@@ -84,8 +259,30 @@ type LeaveGroupPayload {
|
|
|
84
259
|
groupID: ID!
|
|
85
260
|
}
|
|
86
261
|
|
|
87
|
-
|
|
262
|
+
input RequestRemoveGroupMemberInput {
|
|
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!
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
enum GroupMemberRole {
|
|
273
|
+
admin
|
|
274
|
+
member
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
input RequestSetMemberRoleInput {
|
|
88
278
|
groupID: ID!
|
|
279
|
+
memberDID: DID!
|
|
280
|
+
role: GroupMemberRole!
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
"""The request, not the role: it moves with the commit. Watch it."""
|
|
284
|
+
type RequestSetMemberRolePayload {
|
|
285
|
+
request: ControlRequest!
|
|
89
286
|
}
|
|
90
287
|
|
|
91
288
|
input UpdateCatalogInput {
|
|
@@ -94,14 +291,49 @@ input UpdateCatalogInput {
|
|
|
94
291
|
filterCriteria: String
|
|
95
292
|
}
|
|
96
293
|
|
|
97
|
-
input
|
|
294
|
+
input RequestUpdateGroupInput {
|
|
98
295
|
groupID: ID!
|
|
99
296
|
name: String
|
|
100
297
|
description: String
|
|
298
|
+
openCircleIDs: [String!]
|
|
299
|
+
commitHub: SuggestedHubInput
|
|
101
300
|
}
|
|
102
301
|
|
|
103
|
-
|
|
104
|
-
|
|
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!
|
|
105
337
|
}
|
|
106
338
|
|
|
107
339
|
input UpdateCircleInput {
|
|
@@ -111,30 +343,47 @@ input UpdateCircleInput {
|
|
|
111
343
|
catalogIDs: [String!]
|
|
112
344
|
}
|
|
113
345
|
|
|
114
|
-
|
|
115
|
-
|
|
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!
|
|
116
352
|
}
|
|
117
353
|
|
|
118
354
|
type DeleteCatalogPayload {
|
|
119
355
|
deletedID: ID!
|
|
120
356
|
}
|
|
121
357
|
|
|
122
|
-
|
|
123
|
-
|
|
358
|
+
input RequestDeleteCircleInput {
|
|
359
|
+
circleID: ID!
|
|
124
360
|
}
|
|
125
361
|
|
|
126
|
-
|
|
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 {
|
|
127
371
|
circleID: ID!
|
|
128
372
|
memberDID: DID!
|
|
129
373
|
}
|
|
130
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
|
+
|
|
131
383
|
type SyncPeer {
|
|
132
384
|
id: String!
|
|
133
385
|
peerDID: DID!
|
|
134
386
|
endpoint: URL!
|
|
135
|
-
mode: String!
|
|
136
|
-
priority: String!
|
|
137
|
-
trustLevel: String!
|
|
138
387
|
createdAt: Int!
|
|
139
388
|
updatedAt: Int!
|
|
140
389
|
}
|
|
@@ -155,6 +404,20 @@ type RemoveSyncPeerPayload {
|
|
|
155
404
|
success: Boolean!
|
|
156
405
|
}
|
|
157
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
|
+
|
|
158
421
|
input CreateHubInput {
|
|
159
422
|
url: URL!
|
|
160
423
|
label: String
|
|
@@ -188,10 +451,8 @@ input DelegationTokenCovers {
|
|
|
188
451
|
}
|
|
189
452
|
|
|
190
453
|
"""
|
|
191
|
-
A verified revocation of a delegation token addressed to the viewer.
|
|
192
|
-
|
|
193
|
-
revocations whose cap was deleted or never arrived are excluded by the
|
|
194
|
-
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.
|
|
195
456
|
"""
|
|
196
457
|
type RevokedDelegationToken {
|
|
197
458
|
jti: ID!
|
|
@@ -200,22 +461,114 @@ type RevokedDelegationToken {
|
|
|
200
461
|
revokerDID: DID!
|
|
201
462
|
"Issuer's revocation-record timestamp (iat), Unix epoch seconds."
|
|
202
463
|
revokedAt: Int!
|
|
203
|
-
""
|
|
204
|
-
Wall-clock time the revocation became binding, Unix epoch seconds. For
|
|
205
|
-
self-issued revocations this equals revokedAt (verified at mint time);
|
|
206
|
-
for received revocations it is the receiver's clock at the moment the
|
|
207
|
-
cap-vs-revoker cross-check succeeded.
|
|
208
|
-
"""
|
|
464
|
+
"When the revocation became binding here, Unix epoch seconds."
|
|
209
465
|
verifiedAt: Int!
|
|
210
466
|
capExp: Int
|
|
211
467
|
groupID: ID!
|
|
212
468
|
}
|
|
213
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
|
+
|
|
214
555
|
extend type Query {
|
|
215
556
|
groups: [PeerGroup!]!
|
|
216
557
|
group(id: ID!): PeerGroup
|
|
217
558
|
groupMembers(groupID: ID!): [PeerGroupMember!]!
|
|
218
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!]!
|
|
219
572
|
hubs: [PeerHub!]!
|
|
220
573
|
hub(id: ID!): PeerHub
|
|
221
574
|
syncPeers: [SyncPeer!]!
|
|
@@ -223,6 +576,11 @@ extend type Query {
|
|
|
223
576
|
ownDelegationTokens(grantor: ID, covers: DelegationTokenCovers): [DelegationToken!]!
|
|
224
577
|
ownRevokedDelegationTokens(grantor: ID): [RevokedDelegationToken!]!
|
|
225
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!]!
|
|
226
584
|
}
|
|
227
585
|
|
|
228
586
|
enum GroupHealthCondition {
|
|
@@ -254,27 +612,93 @@ type RejoinGroupPayload {
|
|
|
254
612
|
state: GroupHealthState!
|
|
255
613
|
}
|
|
256
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
|
+
|
|
257
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!
|
|
258
670
|
createGroup(input: CreateGroupInput!): CreateGroupPayload!
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
addCircleMember(circleID: ID!, memberDID: DID!, role: String!): PeerCircleMember!
|
|
671
|
+
requestCreateCircle(input: RequestCreateCircleInput!): RequestCreateCirclePayload!
|
|
672
|
+
requestAddCircleMember(input: RequestAddCircleMemberInput!): RequestAddCircleMemberPayload!
|
|
262
673
|
createCatalog(name: String!, description: String, filterCriteria: String!): PeerCatalog!
|
|
263
|
-
|
|
674
|
+
requestInviteToGroup(input: RequestInviteToGroupInput!): RequestInviteToGroupPayload!
|
|
264
675
|
joinGroup(invitePayload: String!, joinRequestPayload: String!): JoinGroupPayload!
|
|
265
676
|
prepareJoinRequest: PrepareJoinRequestPayload!
|
|
266
|
-
|
|
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!
|
|
267
688
|
leaveGroup(groupID: ID!): LeaveGroupPayload!
|
|
268
|
-
|
|
689
|
+
requestRemoveGroupMember(input: RequestRemoveGroupMemberInput!): RequestRemoveGroupMemberPayload!
|
|
690
|
+
requestSetMemberRole(input: RequestSetMemberRoleInput!): RequestSetMemberRolePayload!
|
|
269
691
|
updateCatalog(input: UpdateCatalogInput!): PeerCatalog!
|
|
270
|
-
|
|
271
|
-
|
|
692
|
+
activateCatalogSync(catalogID: ID!): PeerCatalog!
|
|
693
|
+
deactivateCatalogSync(catalogID: ID!): PeerCatalog!
|
|
694
|
+
requestUpdateGroup(input: RequestUpdateGroupInput!): RequestUpdateGroupPayload!
|
|
695
|
+
requestUpdateCircle(input: UpdateCircleInput!): RequestUpdateCirclePayload!
|
|
272
696
|
deleteCatalog(catalogID: ID!): DeleteCatalogPayload!
|
|
273
|
-
|
|
274
|
-
|
|
697
|
+
requestDeleteCircle(input: RequestDeleteCircleInput!): RequestDeleteCirclePayload!
|
|
698
|
+
requestRemoveCircleMember(input: RequestRemoveCircleMemberInput!): RequestRemoveCircleMemberPayload!
|
|
275
699
|
grantWriteCapability(to: DID!, res: String!, groupID: ID!, expiresIn: Int): String!
|
|
276
700
|
revokeCapability(jti: ID!): Boolean!
|
|
277
|
-
addSyncPeer(peerDID: DID!, endpoint: URL
|
|
701
|
+
addSyncPeer(peerDID: DID!, endpoint: URL!): Boolean!
|
|
278
702
|
removeSyncPeer(peerDID: DID!): RemoveSyncPeerPayload!
|
|
279
703
|
createHub(input: CreateHubInput!): PeerHub!
|
|
280
704
|
updateHub(input: UpdateHubInput!): PeerHub!
|
|
@@ -282,10 +706,26 @@ extend type Mutation {
|
|
|
282
706
|
bindHubToGroup(hubID: ID!, groupID: ID!): Boolean!
|
|
283
707
|
unbindHubFromGroup(hubID: ID!, groupID: ID!): Boolean!
|
|
284
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!
|
|
285
715
|
}
|
|
286
716
|
|
|
287
717
|
extend type PeerGroup {
|
|
288
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!]!
|
|
289
729
|
}
|
|
290
730
|
|
|
291
731
|
extend type PeerHub {
|
|
@@ -298,6 +738,8 @@ extend type Subscription {
|
|
|
298
738
|
groupDataChanged(groupID: ID!): PeerGroup!
|
|
299
739
|
groupMemberJoined(groupID: ID!): PeerGroupMember!
|
|
300
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!
|
|
301
743
|
circleCreated(groupID: ID!): PeerCircle!
|
|
302
744
|
circleDeleted(groupID: ID!): PeerCircle!
|
|
303
745
|
circleDataChanged(circleID: ID!): PeerCircle!
|
|
@@ -307,5 +749,674 @@ extend type Subscription {
|
|
|
307
749
|
ownDelegationTokenAdded: DelegationToken!
|
|
308
750
|
ownDelegationTokenRevoked: RevokedDelegationToken!
|
|
309
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!
|
|
761
|
+
}
|
|
762
|
+
`;
|
|
763
|
+
export function createP2PSchemaExtension(emitter, syncManager, logger = getKubunLogger('p2p-schema')) {
|
|
764
|
+
return {
|
|
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
|
+
],
|
|
794
|
+
resolvers: {
|
|
795
|
+
queryFields: {
|
|
796
|
+
groups: async (_source, _args, context)=>{
|
|
797
|
+
return await requireP2P(context).group.list();
|
|
798
|
+
},
|
|
799
|
+
group: async (_source, args, context)=>{
|
|
800
|
+
return await requireP2P(context).group.get(args.id);
|
|
801
|
+
},
|
|
802
|
+
groupMembers: async (_source, args, context)=>{
|
|
803
|
+
return await requireP2P(context).group.listMembers(args.groupID);
|
|
804
|
+
},
|
|
805
|
+
circles: async (_source, args, context)=>{
|
|
806
|
+
return await requireP2P(context).group.listCircles(args.groupID);
|
|
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
|
+
},
|
|
820
|
+
hubs: async (_source, _args, context)=>{
|
|
821
|
+
return await requireP2P(context).hub.list();
|
|
822
|
+
},
|
|
823
|
+
hub: async (_source, args, context)=>{
|
|
824
|
+
return await requireP2P(context).hub.get(args.id);
|
|
825
|
+
},
|
|
826
|
+
syncPeers: async (_source, _args, context)=>{
|
|
827
|
+
return await requireP2P(context).sync.listPeers();
|
|
828
|
+
},
|
|
829
|
+
syncStatus: (_source, _args, context)=>{
|
|
830
|
+
const status = requireP2P(context).sync.getStatus();
|
|
831
|
+
return {
|
|
832
|
+
...status,
|
|
833
|
+
lastSyncByPeer: JSON.stringify(status.lastSyncByPeer)
|
|
834
|
+
};
|
|
835
|
+
},
|
|
836
|
+
// INERT: always reports `healthy` — nothing signals the monitor. Live
|
|
837
|
+
// scaffolding for epoch-stale recovery, kept so the surface exists once
|
|
838
|
+
// something drives it.
|
|
839
|
+
groupHealth: (_source, args, context)=>{
|
|
840
|
+
const groupID = args.groupID;
|
|
841
|
+
const { state, lastTransitionAt } = requireP2P(context).hub.getGroupHealth(groupID);
|
|
842
|
+
return {
|
|
843
|
+
groupID,
|
|
844
|
+
state: HEALTH_STATE_TO_SDL[state],
|
|
845
|
+
lastTransitionAt: lastTransitionAt == null ? null : new Date(lastTransitionAt).toISOString()
|
|
846
|
+
};
|
|
847
|
+
},
|
|
848
|
+
controlRequests: async (_source, args, context)=>{
|
|
849
|
+
const requests = await requireP2P(context).group.listControlRequests(args.groupID);
|
|
850
|
+
return requests.map(toControlRequestSDL);
|
|
851
|
+
},
|
|
852
|
+
ownDelegationTokens: async (_source, args, context)=>{
|
|
853
|
+
const viewerDID = context.getViewer();
|
|
854
|
+
if (viewerDID == null || context.p2p == null) {
|
|
855
|
+
return [];
|
|
856
|
+
}
|
|
857
|
+
const grantor = args.grantor ?? null;
|
|
858
|
+
const covers = args.covers ?? null;
|
|
859
|
+
return await context.p2p.delegation.listOwnTokens({
|
|
860
|
+
viewerDID,
|
|
861
|
+
grantor,
|
|
862
|
+
covers
|
|
863
|
+
});
|
|
864
|
+
},
|
|
865
|
+
ownRevokedDelegationTokens: async (_source, args, context)=>{
|
|
866
|
+
const viewerDID = context.getViewer();
|
|
867
|
+
if (viewerDID == null || context.p2p == null) {
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
const grantor = args.grantor ?? null;
|
|
871
|
+
return await context.p2p.delegation.listOwnRevokedTokens({
|
|
872
|
+
viewerDID,
|
|
873
|
+
grantor
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
},
|
|
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
|
+
},
|
|
955
|
+
createGroup: async (_source, args, context)=>{
|
|
956
|
+
const input = args.input;
|
|
957
|
+
const group = await requireP2P(context).group.create(input);
|
|
958
|
+
return {
|
|
959
|
+
group
|
|
960
|
+
};
|
|
961
|
+
},
|
|
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
|
+
};
|
|
981
|
+
});
|
|
982
|
+
},
|
|
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
|
+
});
|
|
992
|
+
},
|
|
993
|
+
createCatalog: async (_source, args, context)=>{
|
|
994
|
+
return await requireP2P(context).group.createCatalog({
|
|
995
|
+
name: args.name,
|
|
996
|
+
description: args.description,
|
|
997
|
+
filterCriteria: args.filterCriteria
|
|
998
|
+
});
|
|
999
|
+
},
|
|
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
|
+
};
|
|
1011
|
+
},
|
|
1012
|
+
joinGroup: async (_source, args, context)=>{
|
|
1013
|
+
return await requireP2P(context).group.join(args.invitePayload, args.joinRequestPayload);
|
|
1014
|
+
},
|
|
1015
|
+
prepareJoinRequest: async (_source, _args, context)=>{
|
|
1016
|
+
return await requireP2P(context).join.prepareRequest();
|
|
1017
|
+
},
|
|
1018
|
+
completeJoin: async (_source, args, context)=>{
|
|
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;
|
|
1049
|
+
},
|
|
1050
|
+
leaveGroup: async (_source, args, context)=>{
|
|
1051
|
+
// leave tears down the MLS handle and emits a leave event: any throw
|
|
1052
|
+
// must roll back so a partial teardown is never committed.
|
|
1053
|
+
try {
|
|
1054
|
+
return await requireP2P(context).group.leave(args.groupID);
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
failTransactionFatal('leaveGroup', error);
|
|
1057
|
+
}
|
|
1058
|
+
},
|
|
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
|
+
});
|
|
1072
|
+
},
|
|
1073
|
+
requestSetMemberRole: async (_source, args, context)=>{
|
|
1074
|
+
const input = args.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);
|
|
1082
|
+
return {
|
|
1083
|
+
request: toControlRequestSDL(request)
|
|
1084
|
+
};
|
|
1085
|
+
});
|
|
1086
|
+
},
|
|
1087
|
+
updateCatalog: async (_source, args, context)=>{
|
|
1088
|
+
const input = args.input;
|
|
1089
|
+
return await requireP2P(context).group.updateCatalog(input);
|
|
1090
|
+
},
|
|
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)=>{
|
|
1098
|
+
const input = args.input;
|
|
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
|
+
});
|
|
1113
|
+
},
|
|
1114
|
+
requestUpdateCircle: async (_source, args, context)=>{
|
|
1115
|
+
const input = args.input;
|
|
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
|
+
});
|
|
1124
|
+
},
|
|
1125
|
+
deleteCatalog: async (_source, args, context)=>{
|
|
1126
|
+
return await requireP2P(context).group.deleteCatalog(args.catalogID);
|
|
1127
|
+
},
|
|
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
|
+
});
|
|
1141
|
+
},
|
|
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
|
+
});
|
|
1153
|
+
},
|
|
1154
|
+
grantWriteCapability: async (_source, args, context)=>{
|
|
1155
|
+
return await requireP2P(context).group.grantWriteCapability({
|
|
1156
|
+
to: args.to,
|
|
1157
|
+
res: args.res,
|
|
1158
|
+
groupID: args.groupID,
|
|
1159
|
+
expiresIn: args.expiresIn
|
|
1160
|
+
});
|
|
1161
|
+
},
|
|
1162
|
+
revokeCapability: async (_source, args, context)=>{
|
|
1163
|
+
return await requireP2P(context).group.revokeCapability({
|
|
1164
|
+
jti: args.jti
|
|
1165
|
+
});
|
|
1166
|
+
},
|
|
1167
|
+
addSyncPeer: async (_source, args, context)=>{
|
|
1168
|
+
await requireP2P(context).sync.addPeer({
|
|
1169
|
+
peerDID: args.peerDID,
|
|
1170
|
+
endpoint: args.endpoint
|
|
1171
|
+
});
|
|
1172
|
+
return true;
|
|
1173
|
+
},
|
|
1174
|
+
removeSyncPeer: async (_source, args, context)=>{
|
|
1175
|
+
await requireP2P(context).sync.removePeer(args.peerDID);
|
|
1176
|
+
return {
|
|
1177
|
+
success: true
|
|
1178
|
+
};
|
|
1179
|
+
},
|
|
1180
|
+
createHub: async (_source, args, context)=>{
|
|
1181
|
+
const input = args.input;
|
|
1182
|
+
return await requireP2P(context).hub.create(input);
|
|
1183
|
+
},
|
|
1184
|
+
updateHub: async (_source, args, context)=>{
|
|
1185
|
+
const input = args.input;
|
|
1186
|
+
return await requireP2P(context).hub.update(input);
|
|
1187
|
+
},
|
|
1188
|
+
deleteHub: async (_source, args, context)=>{
|
|
1189
|
+
return await requireP2P(context).hub.delete(args.id, args.force ?? false);
|
|
1190
|
+
},
|
|
1191
|
+
bindHubToGroup: async (_source, args, context)=>{
|
|
1192
|
+
return await requireP2P(context).hub.bind({
|
|
1193
|
+
hubID: args.hubID,
|
|
1194
|
+
groupID: args.groupID
|
|
1195
|
+
});
|
|
1196
|
+
},
|
|
1197
|
+
unbindHubFromGroup: async (_source, args, context)=>{
|
|
1198
|
+
return await requireP2P(context).hub.unbind({
|
|
1199
|
+
hubID: args.hubID,
|
|
1200
|
+
groupID: args.groupID
|
|
1201
|
+
});
|
|
1202
|
+
},
|
|
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.
|
|
1217
|
+
rejoinGroup: (_source, args, context)=>{
|
|
1218
|
+
const groupID = args.groupID;
|
|
1219
|
+
const hub = requireP2P(context).hub;
|
|
1220
|
+
const { state } = hub.getGroupHealth(groupID);
|
|
1221
|
+
// Async kickoff (when wired): already-current is a no-op; an in-flight
|
|
1222
|
+
// rejoin is not restarted. The terminal outcome would arrive via the
|
|
1223
|
+
// groupHealthChanged stream.
|
|
1224
|
+
if (state !== 'healthy' && state !== 'recovering') {
|
|
1225
|
+
// Log-only: catch so a throw (e.g. no active channel) can't leak.
|
|
1226
|
+
hub.rejoinGroup(groupID).catch((error)=>{
|
|
1227
|
+
logger.error('rejoinGroup kickoff failed', {
|
|
1228
|
+
groupID,
|
|
1229
|
+
error
|
|
1230
|
+
});
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
const reported = state === 'healthy' ? 'HEALTHY' : 'RECOVERING';
|
|
1234
|
+
return {
|
|
1235
|
+
groupID,
|
|
1236
|
+
state: reported
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
},
|
|
1240
|
+
typeFields: {
|
|
1241
|
+
PeerGroup: {
|
|
1242
|
+
hubs: async (source, _args, context)=>{
|
|
1243
|
+
const group = source;
|
|
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);
|
|
1266
|
+
}
|
|
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
|
+
},
|
|
1273
|
+
PeerHub: {
|
|
1274
|
+
groups: async (source, _args, context)=>{
|
|
1275
|
+
const hub = source;
|
|
1276
|
+
return await requireP2P(context).hub.listGroups(hub.id);
|
|
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
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
},
|
|
1295
|
+
subscriptionFields: {
|
|
1296
|
+
groupJoined: {
|
|
1297
|
+
resolve: (group)=>group,
|
|
1298
|
+
subscribe: ()=>createFilteredGenerator(emitter, 'groupJoined')
|
|
1299
|
+
},
|
|
1300
|
+
groupLeft: {
|
|
1301
|
+
resolve: (group)=>group,
|
|
1302
|
+
subscribe: ()=>createFilteredGenerator(emitter, 'groupLeft')
|
|
1303
|
+
},
|
|
1304
|
+
groupDataChanged: {
|
|
1305
|
+
resolve: (group)=>group,
|
|
1306
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'groupDataChanged', (d)=>d.groupID === args.groupID)
|
|
1307
|
+
},
|
|
1308
|
+
groupMemberJoined: {
|
|
1309
|
+
resolve: (member)=>member,
|
|
1310
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'groupMemberJoined', (d)=>d.groupID === args.groupID)
|
|
1311
|
+
},
|
|
1312
|
+
groupMemberLeft: {
|
|
1313
|
+
resolve: (member)=>member,
|
|
1314
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'groupMemberLeft', (d)=>d.groupID === args.groupID)
|
|
1315
|
+
},
|
|
1316
|
+
groupMemberRoleChanged: {
|
|
1317
|
+
resolve: (member)=>member,
|
|
1318
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'groupMemberRoleChanged', (d)=>d.groupID === args.groupID)
|
|
1319
|
+
},
|
|
1320
|
+
circleCreated: {
|
|
1321
|
+
resolve: (circle)=>circle,
|
|
1322
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleCreated', (d)=>d.groupID === args.groupID)
|
|
1323
|
+
},
|
|
1324
|
+
circleDeleted: {
|
|
1325
|
+
resolve: (circle)=>circle,
|
|
1326
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleDeleted', (d)=>d.groupID === args.groupID)
|
|
1327
|
+
},
|
|
1328
|
+
circleDataChanged: {
|
|
1329
|
+
resolve: (circle)=>circle,
|
|
1330
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleDataChanged', (d)=>d.circleID === args.circleID)
|
|
1331
|
+
},
|
|
1332
|
+
circleMemberAdded: {
|
|
1333
|
+
resolve: (member)=>member,
|
|
1334
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleMemberAdded', (d)=>d.circleID === args.circleID)
|
|
1335
|
+
},
|
|
1336
|
+
circleMemberRemoved: {
|
|
1337
|
+
resolve: (member)=>member,
|
|
1338
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleMemberRemoved', (d)=>d.circleID === args.circleID)
|
|
1339
|
+
},
|
|
1340
|
+
circleCatalogsChanged: {
|
|
1341
|
+
resolve: (circle)=>circle,
|
|
1342
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleCatalogsChanged', (d)=>d.circleID === args.circleID)
|
|
1343
|
+
},
|
|
1344
|
+
ownDelegationTokenAdded: {
|
|
1345
|
+
resolve: (data)=>data,
|
|
1346
|
+
subscribe: (_src, _args, context)=>{
|
|
1347
|
+
const viewerDID = context.getViewer();
|
|
1348
|
+
if (viewerDID == null) {
|
|
1349
|
+
return emptyAsyncGenerator();
|
|
1350
|
+
}
|
|
1351
|
+
return createFilteredGenerator(emitter, 'delegationTokenAdded', (data)=>data.audience === viewerDID);
|
|
1352
|
+
}
|
|
1353
|
+
},
|
|
1354
|
+
ownDelegationTokenRevoked: {
|
|
1355
|
+
resolve: (data)=>data,
|
|
1356
|
+
subscribe: (_src, _args, context)=>{
|
|
1357
|
+
const viewerDID = context.getViewer();
|
|
1358
|
+
if (viewerDID == null) {
|
|
1359
|
+
return emptyAsyncGenerator();
|
|
1360
|
+
}
|
|
1361
|
+
// Audience-side filter mirrors `ownDelegationTokenAdded` — the
|
|
1362
|
+
// viewer subscribes to revocations of caps they hold (or held).
|
|
1363
|
+
return createFilteredGenerator(emitter, 'delegationTokenRevoked', (data)=>data.audience === viewerDID);
|
|
1364
|
+
}
|
|
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
|
+
},
|
|
1403
|
+
// INERT: this subscription can never fire — `groupHealthChanged` has no
|
|
1404
|
+
// live producer. Reserved for epoch-stale recovery.
|
|
1405
|
+
groupHealthChanged: {
|
|
1406
|
+
resolve: (data)=>{
|
|
1407
|
+
const event = data;
|
|
1408
|
+
return {
|
|
1409
|
+
groupID: event.groupID,
|
|
1410
|
+
condition: HEALTH_CONDITION_TO_SDL[event.condition],
|
|
1411
|
+
state: HEALTH_STATE_TO_SDL[event.state]
|
|
1412
|
+
};
|
|
1413
|
+
},
|
|
1414
|
+
subscribe: (_src, args)=>{
|
|
1415
|
+
const groupID = args.groupID;
|
|
1416
|
+
return createFilteredGenerator(emitter, 'groupHealthChanged', groupID == null ? undefined : (d)=>d.groupID === groupID);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
310
1422
|
}
|
|
311
|
-
`;export function createP2PSchemaExtension(n){return{sdl:i,resolvers:{queryFields:{groups:async(e,t,o)=>await r(o).group.list(),group:async(e,t,o)=>await r(o).group.get(t.id),groupMembers:async(e,t,o)=>await r(o).group.listMembers(t.groupID),circles:async(e,t,o)=>await r(o).group.listCircles(t.groupID),hubs:async(e,t,o)=>await r(o).hub.list(),hub:async(e,t,o)=>await r(o).hub.get(t.id),syncPeers:async(e,t,o)=>await r(o).sync.listPeers(),syncStatus:(e,t,o)=>{let a=r(o).sync.getStatus();return{...a,lastSyncByPeer:JSON.stringify(a.lastSyncByPeer)}},groupHealth:(e,o,a)=>{let i=o.groupID,{state:n,lastTransitionAt:u}=r(a).hub.getGroupHealth(i);return{groupID:i,state:t[n],lastTransitionAt:null==u?null:new Date(u).toISOString()}},ownDelegationTokens:async(e,r,t)=>{let o=t.getViewer();if(null==o||null==t.p2p)return[];let a=r.grantor??null,i=r.covers??null;return await t.p2p.delegation.listOwnTokens({viewerDID:o,grantor:a,covers:i})},ownRevokedDelegationTokens:async(e,r,t)=>{let o=t.getViewer();if(null==o||null==t.p2p)return[];let a=r.grantor??null;return await t.p2p.delegation.listOwnRevokedTokens({viewerDID:o,grantor:a})}},mutationFields:{createGroup:async(e,t,o)=>{let a=t.input;return{group:await r(o).group.create(a)}},addGroupMember:async(e,t,o)=>await r(o).group.addMember(t.groupID,t.memberDID,t.role),createCircle:async(e,t,o)=>await r(o).group.createCircle({groupID:t.groupID,name:t.name,description:t.description}),addCircleMember:async(e,t,o)=>await r(o).group.addCircleMember(t.circleID,t.memberDID,t.role),createCatalog:async(e,t,o)=>await r(o).group.createCatalog({name:t.name,description:t.description,filterCriteria:t.filterCriteria}),inviteToGroup:async(e,t,o)=>await r(o).group.invite(t.groupID,t.joinRequest,t.grants,t.revocations),joinGroup:async(e,t,o)=>await r(o).group.join(t.invitePayload,t.joinRequestPayload),prepareJoinRequest:async(e,t,o)=>await r(o).join.prepareRequest(),completeJoin:async(e,t,o)=>await r(o).join.complete(t.invitePayload),leaveGroup:async(e,t,o)=>await r(o).group.leave(t.groupID),removeGroupMember:async(e,t,o)=>await r(o).group.removeMember(t.groupID,t.memberDID),updateCatalog:async(e,t,o)=>{let a=t.input;return await r(o).group.updateCatalog(a)},updateGroup:async(e,t,o)=>{let a=t.input;return{group:await r(o).group.update(a)}},updateCircle:async(e,t,o)=>{let a=t.input;return{circle:await r(o).group.updateCircle(a)}},deleteCatalog:async(e,t,o)=>await r(o).group.deleteCatalog(t.catalogID),deleteCircle:async(e,t,o)=>await r(o).group.deleteCircle(t.circleID),removeCircleMember:async(e,t,o)=>await r(o).group.removeCircleMember(t.circleID,t.memberDID),grantWriteCapability:async(e,t,o)=>await r(o).group.grantWriteCapability({to:t.to,res:t.res,groupID:t.groupID,expiresIn:t.expiresIn}),revokeCapability:async(e,t,o)=>await r(o).group.revokeCapability({jti:t.jti}),addSyncPeer:async(e,t,o)=>(await r(o).sync.addPeer({peerDID:t.peerDID,endpoint:t.endpoint,mode:t.mode??"on-demand",allowedUsers:{all:!0},priority:t.priority??"0",trustLevel:t.trustLevel??"trusted"}),!0),removeSyncPeer:async(e,t,o)=>(await r(o).sync.removePeer(t.peerDID),{success:!0}),createHub:async(e,t,o)=>{let a=t.input;return await r(o).hub.create(a)},updateHub:async(e,t,o)=>{let a=t.input;return await r(o).hub.update(a)},deleteHub:async(e,t,o)=>await r(o).hub.delete(t.id,t.force??!1),bindHubToGroup:async(e,t,o)=>await r(o).hub.bind({hubID:t.hubID,groupID:t.groupID}),unbindHubFromGroup:async(e,t,o)=>await r(o).hub.unbind({hubID:t.hubID,groupID:t.groupID}),rejoinGroup:(e,t,o)=>{let a=t.groupID,i=r(o).hub,{state:n}=i.getGroupHealth(a);return"healthy"!==n&&"recovering"!==n&&i.rejoinGroup(a),{groupID:a,state:"healthy"===n?"HEALTHY":"RECOVERING"}}},typeFields:{PeerGroup:{hubs:async(e,t,o)=>await r(o).hub.listByGroup(e.id)},PeerHub:{groups:async(e,t,o)=>await r(o).hub.listGroups(e.id)}},subscriptionFields:{groupJoined:{resolve:e=>e,subscribe:()=>e(n,"groupJoined")},groupLeft:{resolve:e=>e,subscribe:()=>e(n,"groupLeft")},groupDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(n,"groupDataChanged",e=>e.groupID===t.groupID)},groupMemberJoined:{resolve:e=>e,subscribe:(r,t)=>e(n,"groupMemberJoined",e=>e.groupID===t.groupID)},groupMemberLeft:{resolve:e=>e,subscribe:(r,t)=>e(n,"groupMemberLeft",e=>e.groupID===t.groupID)},circleCreated:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleCreated",e=>e.groupID===t.groupID)},circleDeleted:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleDeleted",e=>e.groupID===t.groupID)},circleDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleDataChanged",e=>e.circleID===t.circleID)},circleMemberAdded:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleMemberAdded",e=>e.circleID===t.circleID)},circleMemberRemoved:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleMemberRemoved",e=>e.circleID===t.circleID)},circleCatalogsChanged:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleCatalogsChanged",e=>e.circleID===t.circleID)},ownDelegationTokenAdded:{resolve:e=>e,subscribe:(r,t,o)=>{let i=o.getViewer();return null==i?a():e(n,"delegationTokenAdded",e=>e.audience===i)}},ownDelegationTokenRevoked:{resolve:e=>e,subscribe:(r,t,o)=>{let i=o.getViewer();return null==i?a():e(n,"delegationTokenRevoked",e=>e.audience===i)}},groupHealthChanged:{resolve:e=>({groupID:e.groupID,condition:o[e.condition],state:t[e.state]}),subscribe:(r,t)=>{let o=t.groupID;return e(n,"groupHealthChanged",null==o?void 0:e=>e.groupID===o)}}}}}}
|