@kubun/plugin-p2p 0.10.0 → 0.11.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 +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +12 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +122 -38
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +7 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +69 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +56 -0
- package/lib/groups/group-health-monitor.js +139 -0
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +29 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +4 -8
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +28 -41
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +29 -13
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +8 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +471 -2
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +73 -0
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +12 -0
- package/lib/sync/errors.js +19 -0
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.d.ts +3 -22
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -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 +19 -0
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +8 -1
- package/lib/sync/peer-registry.js +107 -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 +5 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +41 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +21 -0
- package/package.json +48 -41
- package/lib/groups/broadcast-service.d.ts +0 -189
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -32
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.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 -67
- 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 -33
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -127
- 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,55 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import { TransactionFatalError } from '@kubun/engine';
|
|
2
|
+
import { getKubunLogger } from '@kubun/logger';
|
|
3
|
+
import { createFilteredGenerator } from './groups/events.js';
|
|
4
|
+
function requireP2P(ctx) {
|
|
5
|
+
if (ctx.p2p == null) {
|
|
6
|
+
throw new Error('p2p plugin is not wired into this context');
|
|
7
|
+
}
|
|
8
|
+
return ctx.p2p;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Re-raise any failure from a membership/circle operation as the engine's
|
|
12
|
+
* transaction-rollback marker so `mutateGraph` rolls back the whole transaction.
|
|
13
|
+
* These ops do multi-step writes (an MLS ratchet advance plus a roster write, or
|
|
14
|
+
* a ledger append plus a reprojection); graphql-js would otherwise swallow a
|
|
15
|
+
* follow-on throw into `result.errors` and commit a partial result, leaving e.g.
|
|
16
|
+
* a removed member untombstoned and still writable. The original error's message
|
|
17
|
+
* is forwarded unchanged so an expected validation error (e.g. access-denied)
|
|
18
|
+
* still reaches the client as before; the cause carries the full original error.
|
|
19
|
+
*
|
|
20
|
+
* Only multi-write ops whose partial commit would fail OPEN (toward more
|
|
21
|
+
* privilege — leaving a removed/demoted DID authoritative) are wrapped:
|
|
22
|
+
* `inviteToGroup`, `removeGroupMember`, `leaveGroup`, `setMemberRole`,
|
|
23
|
+
* `removeCircleMember`. Ops that fail toward less privilege / benign metadata
|
|
24
|
+
* (circle create/update, capability writes) self-heal on the next idempotent
|
|
25
|
+
* reprojection and are intentionally left unwrapped — a new remove/demote op
|
|
26
|
+
* should add a wrap here.
|
|
27
|
+
*
|
|
28
|
+
* Caveat: a thrown `GraphQLError`'s `extensions` (e.g. a `code`) are not carried
|
|
29
|
+
* onto the re-raised top-level error (they remain on `.cause`); none of the
|
|
30
|
+
* wrapped resolvers currently throw coded GraphQLErrors, so no client-visible
|
|
31
|
+
* code is lost today.
|
|
32
|
+
*/ function failTransactionFatal(operation, error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : `${operation} failed`;
|
|
34
|
+
throw new TransactionFatalError(message, {
|
|
35
|
+
cause: error
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// Internal lowercase-kebab health values -> SDL enum names.
|
|
39
|
+
const HEALTH_STATE_TO_SDL = {
|
|
40
|
+
healthy: 'HEALTHY',
|
|
41
|
+
degraded: 'DEGRADED',
|
|
42
|
+
recovering: 'RECOVERING',
|
|
43
|
+
recovered: 'RECOVERED',
|
|
44
|
+
'recovery-failed': 'RECOVERY_FAILED'
|
|
45
|
+
};
|
|
46
|
+
const HEALTH_CONDITION_TO_SDL = {
|
|
47
|
+
'epoch-stale': 'EPOCH_STALE'
|
|
48
|
+
};
|
|
49
|
+
async function* emptyAsyncGenerator() {
|
|
50
|
+
// no-op: closes immediately so subscribers without a viewer get no events
|
|
51
|
+
}
|
|
52
|
+
const sdl = `
|
|
2
53
|
type PeerGroup {
|
|
3
54
|
id: ID!
|
|
4
55
|
name: String!
|
|
@@ -88,6 +139,21 @@ type RemoveGroupMemberPayload {
|
|
|
88
139
|
groupID: ID!
|
|
89
140
|
}
|
|
90
141
|
|
|
142
|
+
enum GroupMemberRole {
|
|
143
|
+
admin
|
|
144
|
+
member
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
input SetMemberRoleInput {
|
|
148
|
+
groupID: ID!
|
|
149
|
+
memberDID: DID!
|
|
150
|
+
role: GroupMemberRole!
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type SetMemberRolePayload {
|
|
154
|
+
member: PeerGroupMember!
|
|
155
|
+
}
|
|
156
|
+
|
|
91
157
|
input UpdateCatalogInput {
|
|
92
158
|
catalogID: ID!
|
|
93
159
|
name: String
|
|
@@ -222,6 +288,36 @@ extend type Query {
|
|
|
222
288
|
syncStatus: SyncStatus!
|
|
223
289
|
ownDelegationTokens(grantor: ID, covers: DelegationTokenCovers): [DelegationToken!]!
|
|
224
290
|
ownRevokedDelegationTokens(grantor: ID): [RevokedDelegationToken!]!
|
|
291
|
+
groupHealth(groupID: ID!): GroupHealthStatus!
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
enum GroupHealthCondition {
|
|
295
|
+
EPOCH_STALE
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
enum GroupHealthState {
|
|
299
|
+
HEALTHY
|
|
300
|
+
DEGRADED
|
|
301
|
+
RECOVERING
|
|
302
|
+
RECOVERED
|
|
303
|
+
RECOVERY_FAILED
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
type GroupHealthEvent {
|
|
307
|
+
groupID: ID!
|
|
308
|
+
condition: GroupHealthCondition!
|
|
309
|
+
state: GroupHealthState!
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
type GroupHealthStatus {
|
|
313
|
+
groupID: ID!
|
|
314
|
+
state: GroupHealthState!
|
|
315
|
+
lastTransitionAt: DateTimeISO
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
type RejoinGroupPayload {
|
|
319
|
+
groupID: ID!
|
|
320
|
+
state: GroupHealthState!
|
|
225
321
|
}
|
|
226
322
|
|
|
227
323
|
extend type Mutation {
|
|
@@ -236,6 +332,7 @@ extend type Mutation {
|
|
|
236
332
|
completeJoin(invitePayload: String!): CompleteJoinPayload!
|
|
237
333
|
leaveGroup(groupID: ID!): LeaveGroupPayload!
|
|
238
334
|
removeGroupMember(groupID: ID!, memberDID: DID!): RemoveGroupMemberPayload!
|
|
335
|
+
setMemberRole(input: SetMemberRoleInput!): SetMemberRolePayload!
|
|
239
336
|
updateCatalog(input: UpdateCatalogInput!): PeerCatalog!
|
|
240
337
|
updateGroup(input: UpdateGroupInput!): UpdateGroupPayload!
|
|
241
338
|
updateCircle(input: UpdateCircleInput!): UpdateCirclePayload!
|
|
@@ -251,6 +348,7 @@ extend type Mutation {
|
|
|
251
348
|
deleteHub(id: ID!, force: Boolean): DeleteHubPayload!
|
|
252
349
|
bindHubToGroup(hubID: ID!, groupID: ID!): Boolean!
|
|
253
350
|
unbindHubFromGroup(hubID: ID!, groupID: ID!): Boolean!
|
|
351
|
+
rejoinGroup(groupID: ID!): RejoinGroupPayload!
|
|
254
352
|
}
|
|
255
353
|
|
|
256
354
|
extend type PeerGroup {
|
|
@@ -275,5 +373,376 @@ extend type Subscription {
|
|
|
275
373
|
circleCatalogsChanged(circleID: ID!): PeerCircle!
|
|
276
374
|
ownDelegationTokenAdded: DelegationToken!
|
|
277
375
|
ownDelegationTokenRevoked: RevokedDelegationToken!
|
|
376
|
+
groupHealthChanged(groupID: ID): GroupHealthEvent!
|
|
377
|
+
}
|
|
378
|
+
`;
|
|
379
|
+
export function createP2PSchemaExtension(emitter, logger = getKubunLogger('p2p-schema')) {
|
|
380
|
+
return {
|
|
381
|
+
sdl,
|
|
382
|
+
resolvers: {
|
|
383
|
+
queryFields: {
|
|
384
|
+
groups: async (_source, _args, context)=>{
|
|
385
|
+
return await requireP2P(context).group.list();
|
|
386
|
+
},
|
|
387
|
+
group: async (_source, args, context)=>{
|
|
388
|
+
return await requireP2P(context).group.get(args.id);
|
|
389
|
+
},
|
|
390
|
+
groupMembers: async (_source, args, context)=>{
|
|
391
|
+
return await requireP2P(context).group.listMembers(args.groupID);
|
|
392
|
+
},
|
|
393
|
+
circles: async (_source, args, context)=>{
|
|
394
|
+
return await requireP2P(context).group.listCircles(args.groupID);
|
|
395
|
+
},
|
|
396
|
+
hubs: async (_source, _args, context)=>{
|
|
397
|
+
return await requireP2P(context).hub.list();
|
|
398
|
+
},
|
|
399
|
+
hub: async (_source, args, context)=>{
|
|
400
|
+
return await requireP2P(context).hub.get(args.id);
|
|
401
|
+
},
|
|
402
|
+
syncPeers: async (_source, _args, context)=>{
|
|
403
|
+
return await requireP2P(context).sync.listPeers();
|
|
404
|
+
},
|
|
405
|
+
syncStatus: (_source, _args, context)=>{
|
|
406
|
+
const status = requireP2P(context).sync.getStatus();
|
|
407
|
+
return {
|
|
408
|
+
...status,
|
|
409
|
+
lastSyncByPeer: JSON.stringify(status.lastSyncByPeer)
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
// INERT: always reports `healthy` — nothing signals the monitor. Live
|
|
413
|
+
// scaffolding for epoch-stale recovery; see
|
|
414
|
+
// docs/agents/plans/backlog/welcome-based-deep-recovery.md.
|
|
415
|
+
groupHealth: (_source, args, context)=>{
|
|
416
|
+
const groupID = args.groupID;
|
|
417
|
+
const { state, lastTransitionAt } = requireP2P(context).hub.getGroupHealth(groupID);
|
|
418
|
+
return {
|
|
419
|
+
groupID,
|
|
420
|
+
state: HEALTH_STATE_TO_SDL[state],
|
|
421
|
+
lastTransitionAt: lastTransitionAt == null ? null : new Date(lastTransitionAt).toISOString()
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
ownDelegationTokens: async (_source, args, context)=>{
|
|
425
|
+
const viewerDID = context.getViewer();
|
|
426
|
+
if (viewerDID == null || context.p2p == null) {
|
|
427
|
+
return [];
|
|
428
|
+
}
|
|
429
|
+
const grantor = args.grantor ?? null;
|
|
430
|
+
const covers = args.covers ?? null;
|
|
431
|
+
return await context.p2p.delegation.listOwnTokens({
|
|
432
|
+
viewerDID,
|
|
433
|
+
grantor,
|
|
434
|
+
covers
|
|
435
|
+
});
|
|
436
|
+
},
|
|
437
|
+
ownRevokedDelegationTokens: async (_source, args, context)=>{
|
|
438
|
+
const viewerDID = context.getViewer();
|
|
439
|
+
if (viewerDID == null || context.p2p == null) {
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
const grantor = args.grantor ?? null;
|
|
443
|
+
return await context.p2p.delegation.listOwnRevokedTokens({
|
|
444
|
+
viewerDID,
|
|
445
|
+
grantor
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
mutationFields: {
|
|
450
|
+
createGroup: async (_source, args, context)=>{
|
|
451
|
+
const input = args.input;
|
|
452
|
+
const group = await requireP2P(context).group.create(input);
|
|
453
|
+
return {
|
|
454
|
+
group
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
addGroupMember: async (_source, args, context)=>{
|
|
458
|
+
return await requireP2P(context).group.addMember(args.groupID, args.memberDID, args.role);
|
|
459
|
+
},
|
|
460
|
+
createCircle: async (_source, args, context)=>{
|
|
461
|
+
return await requireP2P(context).group.createCircle({
|
|
462
|
+
groupID: args.groupID,
|
|
463
|
+
name: args.name,
|
|
464
|
+
description: args.description
|
|
465
|
+
});
|
|
466
|
+
},
|
|
467
|
+
addCircleMember: async (_source, args, context)=>{
|
|
468
|
+
return await requireP2P(context).group.addCircleMember(args.circleID, args.memberDID, args.role);
|
|
469
|
+
},
|
|
470
|
+
createCatalog: async (_source, args, context)=>{
|
|
471
|
+
return await requireP2P(context).group.createCatalog({
|
|
472
|
+
name: args.name,
|
|
473
|
+
description: args.description,
|
|
474
|
+
filterCriteria: args.filterCriteria
|
|
475
|
+
});
|
|
476
|
+
},
|
|
477
|
+
inviteToGroup: async (_source, args, context)=>{
|
|
478
|
+
// invite advances the MLS ratchet and writes the local roster: any
|
|
479
|
+
// throw must roll back the whole transaction so the eagerly-swapped
|
|
480
|
+
// MLS epoch can never outlive a failed roster write.
|
|
481
|
+
try {
|
|
482
|
+
return await requireP2P(context).group.invite(args.groupID, args.joinRequest, args.grants, args.revocations);
|
|
483
|
+
} catch (error) {
|
|
484
|
+
failTransactionFatal('inviteToGroup', error);
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
joinGroup: async (_source, args, context)=>{
|
|
488
|
+
return await requireP2P(context).group.join(args.invitePayload, args.joinRequestPayload);
|
|
489
|
+
},
|
|
490
|
+
prepareJoinRequest: async (_source, _args, context)=>{
|
|
491
|
+
return await requireP2P(context).join.prepareRequest();
|
|
492
|
+
},
|
|
493
|
+
completeJoin: async (_source, args, context)=>{
|
|
494
|
+
return await requireP2P(context).join.complete(args.invitePayload);
|
|
495
|
+
},
|
|
496
|
+
leaveGroup: async (_source, args, context)=>{
|
|
497
|
+
// leave tears down the MLS handle and emits a leave event: any throw
|
|
498
|
+
// must roll back so a partial teardown is never committed.
|
|
499
|
+
try {
|
|
500
|
+
return await requireP2P(context).group.leave(args.groupID);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
failTransactionFatal('leaveGroup', error);
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
removeGroupMember: async (_source, args, context)=>{
|
|
506
|
+
// removeMember advances the MLS ratchet and tombstones the removed
|
|
507
|
+
// member: any throw must roll back so a removed member can never be
|
|
508
|
+
// left untombstoned (and still writable) after a partial commit.
|
|
509
|
+
try {
|
|
510
|
+
return await requireP2P(context).group.removeMember(args.groupID, args.memberDID);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
failTransactionFatal('removeGroupMember', error);
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
setMemberRole: async (_source, args, context)=>{
|
|
516
|
+
const input = args.input;
|
|
517
|
+
// setMemberRole appends a ledger entry and reprojects the admin
|
|
518
|
+
// overlay: any throw must roll back so the durable ledger and the
|
|
519
|
+
// projected role column can never diverge across a partial commit.
|
|
520
|
+
try {
|
|
521
|
+
const member = await requireP2P(context).group.setMemberRole(input);
|
|
522
|
+
return {
|
|
523
|
+
member
|
|
524
|
+
};
|
|
525
|
+
} catch (error) {
|
|
526
|
+
failTransactionFatal('setMemberRole', error);
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
updateCatalog: async (_source, args, context)=>{
|
|
530
|
+
const input = args.input;
|
|
531
|
+
return await requireP2P(context).group.updateCatalog(input);
|
|
532
|
+
},
|
|
533
|
+
updateGroup: async (_source, args, context)=>{
|
|
534
|
+
const input = args.input;
|
|
535
|
+
const group = await requireP2P(context).group.update(input);
|
|
536
|
+
return {
|
|
537
|
+
group
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
updateCircle: async (_source, args, context)=>{
|
|
541
|
+
const input = args.input;
|
|
542
|
+
const circle = await requireP2P(context).group.updateCircle(input);
|
|
543
|
+
return {
|
|
544
|
+
circle
|
|
545
|
+
};
|
|
546
|
+
},
|
|
547
|
+
deleteCatalog: async (_source, args, context)=>{
|
|
548
|
+
return await requireP2P(context).group.deleteCatalog(args.catalogID);
|
|
549
|
+
},
|
|
550
|
+
deleteCircle: async (_source, args, context)=>{
|
|
551
|
+
return await requireP2P(context).group.deleteCircle(args.circleID);
|
|
552
|
+
},
|
|
553
|
+
removeCircleMember: async (_source, args, context)=>{
|
|
554
|
+
// removeCircleMember appends a tombstone ledger entry and reprojects
|
|
555
|
+
// the circle-member overlay: any throw must roll back so the durable
|
|
556
|
+
// ledger and the projected row can never diverge across a partial
|
|
557
|
+
// commit.
|
|
558
|
+
try {
|
|
559
|
+
return await requireP2P(context).group.removeCircleMember(args.circleID, args.memberDID);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
failTransactionFatal('removeCircleMember', error);
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
grantWriteCapability: async (_source, args, context)=>{
|
|
565
|
+
return await requireP2P(context).group.grantWriteCapability({
|
|
566
|
+
to: args.to,
|
|
567
|
+
res: args.res,
|
|
568
|
+
groupID: args.groupID,
|
|
569
|
+
expiresIn: args.expiresIn
|
|
570
|
+
});
|
|
571
|
+
},
|
|
572
|
+
revokeCapability: async (_source, args, context)=>{
|
|
573
|
+
return await requireP2P(context).group.revokeCapability({
|
|
574
|
+
jti: args.jti
|
|
575
|
+
});
|
|
576
|
+
},
|
|
577
|
+
addSyncPeer: async (_source, args, context)=>{
|
|
578
|
+
await requireP2P(context).sync.addPeer({
|
|
579
|
+
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'
|
|
587
|
+
});
|
|
588
|
+
return true;
|
|
589
|
+
},
|
|
590
|
+
removeSyncPeer: async (_source, args, context)=>{
|
|
591
|
+
await requireP2P(context).sync.removePeer(args.peerDID);
|
|
592
|
+
return {
|
|
593
|
+
success: true
|
|
594
|
+
};
|
|
595
|
+
},
|
|
596
|
+
createHub: async (_source, args, context)=>{
|
|
597
|
+
const input = args.input;
|
|
598
|
+
return await requireP2P(context).hub.create(input);
|
|
599
|
+
},
|
|
600
|
+
updateHub: async (_source, args, context)=>{
|
|
601
|
+
const input = args.input;
|
|
602
|
+
return await requireP2P(context).hub.update(input);
|
|
603
|
+
},
|
|
604
|
+
deleteHub: async (_source, args, context)=>{
|
|
605
|
+
return await requireP2P(context).hub.delete(args.id, args.force ?? false);
|
|
606
|
+
},
|
|
607
|
+
bindHubToGroup: async (_source, args, context)=>{
|
|
608
|
+
return await requireP2P(context).hub.bind({
|
|
609
|
+
hubID: args.hubID,
|
|
610
|
+
groupID: args.groupID
|
|
611
|
+
});
|
|
612
|
+
},
|
|
613
|
+
unbindHubFromGroup: async (_source, args, context)=>{
|
|
614
|
+
return await requireP2P(context).hub.unbind({
|
|
615
|
+
hubID: args.hubID,
|
|
616
|
+
groupID: args.groupID
|
|
617
|
+
});
|
|
618
|
+
},
|
|
619
|
+
// INERT: `getGroupHealth` always returns `healthy` (nothing signals the
|
|
620
|
+
// monitor), so the kickoff branch below is currently unreachable and
|
|
621
|
+
// `hub.rejoinGroup` terminates in the deferred `recovery-failed` stub.
|
|
622
|
+
// Reserved for epoch-stale recovery; see
|
|
623
|
+
// docs/agents/plans/backlog/welcome-based-deep-recovery.md.
|
|
624
|
+
rejoinGroup: (_source, args, context)=>{
|
|
625
|
+
const groupID = args.groupID;
|
|
626
|
+
const hub = requireP2P(context).hub;
|
|
627
|
+
const { state } = hub.getGroupHealth(groupID);
|
|
628
|
+
// Async kickoff (when wired): already-current is a no-op; an in-flight
|
|
629
|
+
// rejoin is not restarted. The terminal outcome would arrive via the
|
|
630
|
+
// groupHealthChanged stream.
|
|
631
|
+
if (state !== 'healthy' && state !== 'recovering') {
|
|
632
|
+
// Log-only: catch so a throw (e.g. no active channel) can't leak.
|
|
633
|
+
hub.rejoinGroup(groupID).catch((error)=>{
|
|
634
|
+
logger.error('rejoinGroup kickoff failed', {
|
|
635
|
+
groupID,
|
|
636
|
+
error
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
const reported = state === 'healthy' ? 'HEALTHY' : 'RECOVERING';
|
|
641
|
+
return {
|
|
642
|
+
groupID,
|
|
643
|
+
state: reported
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
typeFields: {
|
|
648
|
+
PeerGroup: {
|
|
649
|
+
hubs: async (source, _args, context)=>{
|
|
650
|
+
const group = source;
|
|
651
|
+
return await requireP2P(context).hub.listByGroup(group.id);
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
PeerHub: {
|
|
655
|
+
groups: async (source, _args, context)=>{
|
|
656
|
+
const hub = source;
|
|
657
|
+
return await requireP2P(context).hub.listGroups(hub.id);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
subscriptionFields: {
|
|
662
|
+
groupJoined: {
|
|
663
|
+
resolve: (group)=>group,
|
|
664
|
+
subscribe: ()=>createFilteredGenerator(emitter, 'groupJoined')
|
|
665
|
+
},
|
|
666
|
+
groupLeft: {
|
|
667
|
+
resolve: (group)=>group,
|
|
668
|
+
subscribe: ()=>createFilteredGenerator(emitter, 'groupLeft')
|
|
669
|
+
},
|
|
670
|
+
groupDataChanged: {
|
|
671
|
+
resolve: (group)=>group,
|
|
672
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'groupDataChanged', (d)=>d.groupID === args.groupID)
|
|
673
|
+
},
|
|
674
|
+
groupMemberJoined: {
|
|
675
|
+
resolve: (member)=>member,
|
|
676
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'groupMemberJoined', (d)=>d.groupID === args.groupID)
|
|
677
|
+
},
|
|
678
|
+
groupMemberLeft: {
|
|
679
|
+
resolve: (member)=>member,
|
|
680
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'groupMemberLeft', (d)=>d.groupID === args.groupID)
|
|
681
|
+
},
|
|
682
|
+
circleCreated: {
|
|
683
|
+
resolve: (circle)=>circle,
|
|
684
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleCreated', (d)=>d.groupID === args.groupID)
|
|
685
|
+
},
|
|
686
|
+
circleDeleted: {
|
|
687
|
+
resolve: (circle)=>circle,
|
|
688
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleDeleted', (d)=>d.groupID === args.groupID)
|
|
689
|
+
},
|
|
690
|
+
circleDataChanged: {
|
|
691
|
+
resolve: (circle)=>circle,
|
|
692
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleDataChanged', (d)=>d.circleID === args.circleID)
|
|
693
|
+
},
|
|
694
|
+
circleMemberAdded: {
|
|
695
|
+
resolve: (member)=>member,
|
|
696
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleMemberAdded', (d)=>d.circleID === args.circleID)
|
|
697
|
+
},
|
|
698
|
+
circleMemberRemoved: {
|
|
699
|
+
resolve: (member)=>member,
|
|
700
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleMemberRemoved', (d)=>d.circleID === args.circleID)
|
|
701
|
+
},
|
|
702
|
+
circleCatalogsChanged: {
|
|
703
|
+
resolve: (circle)=>circle,
|
|
704
|
+
subscribe: (_src, args)=>createFilteredGenerator(emitter, 'circleCatalogsChanged', (d)=>d.circleID === args.circleID)
|
|
705
|
+
},
|
|
706
|
+
ownDelegationTokenAdded: {
|
|
707
|
+
resolve: (data)=>data,
|
|
708
|
+
subscribe: (_src, _args, context)=>{
|
|
709
|
+
const viewerDID = context.getViewer();
|
|
710
|
+
if (viewerDID == null) {
|
|
711
|
+
return emptyAsyncGenerator();
|
|
712
|
+
}
|
|
713
|
+
return createFilteredGenerator(emitter, 'delegationTokenAdded', (data)=>data.audience === viewerDID);
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
ownDelegationTokenRevoked: {
|
|
717
|
+
resolve: (data)=>data,
|
|
718
|
+
subscribe: (_src, _args, context)=>{
|
|
719
|
+
const viewerDID = context.getViewer();
|
|
720
|
+
if (viewerDID == null) {
|
|
721
|
+
return emptyAsyncGenerator();
|
|
722
|
+
}
|
|
723
|
+
// Audience-side filter mirrors `ownDelegationTokenAdded` — the
|
|
724
|
+
// viewer subscribes to revocations of caps they hold (or held).
|
|
725
|
+
return createFilteredGenerator(emitter, 'delegationTokenRevoked', (data)=>data.audience === viewerDID);
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
// INERT: this subscription can never fire — `groupHealthChanged` has no
|
|
729
|
+
// live producer. Reserved for epoch-stale recovery; see
|
|
730
|
+
// docs/agents/plans/backlog/welcome-based-deep-recovery.md.
|
|
731
|
+
groupHealthChanged: {
|
|
732
|
+
resolve: (data)=>{
|
|
733
|
+
const event = data;
|
|
734
|
+
return {
|
|
735
|
+
groupID: event.groupID,
|
|
736
|
+
condition: HEALTH_CONDITION_TO_SDL[event.condition],
|
|
737
|
+
state: HEALTH_STATE_TO_SDL[event.state]
|
|
738
|
+
};
|
|
739
|
+
},
|
|
740
|
+
subscribe: (_src, args)=>{
|
|
741
|
+
const groupID = args.groupID;
|
|
742
|
+
return createFilteredGenerator(emitter, 'groupHealthChanged', groupID == null ? undefined : (d)=>d.groupID === groupID);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
};
|
|
278
748
|
}
|
|
279
|
-
`;export function createP2PSchemaExtension(i){return{sdl:o,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 i=r(o).sync.getStatus();return{...i,lastSyncByPeer:JSON.stringify(i.lastSyncByPeer)}},ownDelegationTokens:async(e,r,t)=>{let o=t.getViewer();if(null==o||null==t.p2p)return[];let i=r.grantor??null,a=r.covers??null;return await t.p2p.delegation.listOwnTokens({viewerDID:o,grantor:i,covers:a})},ownRevokedDelegationTokens:async(e,r,t)=>{let o=t.getViewer();if(null==o||null==t.p2p)return[];let i=r.grantor??null;return await t.p2p.delegation.listOwnRevokedTokens({viewerDID:o,grantor:i})}},mutationFields:{createGroup:async(e,t,o)=>{let i=t.input;return{group:await r(o).group.create(i)}},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 i=t.input;return await r(o).group.updateCatalog(i)},updateGroup:async(e,t,o)=>{let i=t.input;return{group:await r(o).group.update(i)}},updateCircle:async(e,t,o)=>{let i=t.input;return{circle:await r(o).group.updateCircle(i)}},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 i=t.input;return await r(o).hub.create(i)},updateHub:async(e,t,o)=>{let i=t.input;return await r(o).hub.update(i)},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})},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(i,"groupJoined")},groupLeft:{resolve:e=>e,subscribe:()=>e(i,"groupLeft")},groupDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(i,"groupDataChanged",e=>e.groupID===t.groupID)},groupMemberJoined:{resolve:e=>e,subscribe:(r,t)=>e(i,"groupMemberJoined",e=>e.groupID===t.groupID)},groupMemberLeft:{resolve:e=>e,subscribe:(r,t)=>e(i,"groupMemberLeft",e=>e.groupID===t.groupID)},circleCreated:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleCreated",e=>e.groupID===t.groupID)},circleDeleted:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleDeleted",e=>e.groupID===t.groupID)},circleDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleDataChanged",e=>e.circleID===t.circleID)},circleMemberAdded:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleMemberAdded",e=>e.circleID===t.circleID)},circleMemberRemoved:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleMemberRemoved",e=>e.circleID===t.circleID)},circleCatalogsChanged:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleCatalogsChanged",e=>e.circleID===t.circleID)},ownDelegationTokenAdded:{resolve:e=>e,subscribe:(r,o,a)=>{let n=a.getViewer();return null==n?t():e(i,"delegationTokenAdded",e=>e.audience===n)}},ownDelegationTokenRevoked:{resolve:e=>e,subscribe:(r,o,a)=>{let n=a.getViewer();return null==n?t():e(i,"delegationTokenRevoked",e=>e.audience===n)}}}}}}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type VerifyTokenHook } from '@kokuin/capability';
|
|
2
|
+
/**
|
|
3
|
+
* Check if delegation tokens grant the viewer read access to a user's documents.
|
|
4
|
+
*
|
|
5
|
+
* @param viewerDID - The DID of the requesting viewer
|
|
6
|
+
* @param ownerDID - The DID of the document owner
|
|
7
|
+
* @param delegationTokens - Array of delegation JWT tokens
|
|
8
|
+
* @param revocationChecker - Optional per-leaf-capability verification hook.
|
|
9
|
+
* When set, capabilities whose `jti` has been revoked cause the check to
|
|
10
|
+
* fail closed.
|
|
11
|
+
* @returns true if any token grants read access
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkSyncDelegation(viewerDID: string, ownerDID: string, delegationTokens: Array<string>, revocationChecker?: VerifyTokenHook): Promise<boolean>;
|
|
14
|
+
export type AuthorizeScopeParams = {
|
|
15
|
+
viewerDID: string;
|
|
16
|
+
ownerDID: string;
|
|
17
|
+
delegationTokens: Array<string>;
|
|
18
|
+
revocationChecker?: VerifyTokenHook;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Authorize a single sync scope: the viewer must be the owner, or hold a
|
|
22
|
+
* delegation token granting read access to the owner's documents. Shared by
|
|
23
|
+
* negotiate and merkle-sync so the two access gates cannot drift apart.
|
|
24
|
+
*/
|
|
25
|
+
export declare function authorizeScope(params: AuthorizeScopeParams): Promise<boolean>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { checkCapability } from '@kokuin/capability';
|
|
2
|
+
/**
|
|
3
|
+
* Check if delegation tokens grant the viewer read access to a user's documents.
|
|
4
|
+
*
|
|
5
|
+
* @param viewerDID - The DID of the requesting viewer
|
|
6
|
+
* @param ownerDID - The DID of the document owner
|
|
7
|
+
* @param delegationTokens - Array of delegation JWT tokens
|
|
8
|
+
* @param revocationChecker - Optional per-leaf-capability verification hook.
|
|
9
|
+
* When set, capabilities whose `jti` has been revoked cause the check to
|
|
10
|
+
* fail closed.
|
|
11
|
+
* @returns true if any token grants read access
|
|
12
|
+
*/ export async function checkSyncDelegation(viewerDID, ownerDID, delegationTokens, revocationChecker) {
|
|
13
|
+
if (!delegationTokens || delegationTokens.length === 0) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
// Resource patterns to check (from most general to specific)
|
|
17
|
+
// We need at least read access to the user's documents
|
|
18
|
+
const resources = [
|
|
19
|
+
'*',
|
|
20
|
+
'urn:kubun:user:*',
|
|
21
|
+
`urn:kubun:user:${ownerDID}`
|
|
22
|
+
];
|
|
23
|
+
const action = 'document/read';
|
|
24
|
+
// Try delegation chain first (tokens are a chain: A->B->C)
|
|
25
|
+
for (const res of resources){
|
|
26
|
+
try {
|
|
27
|
+
await checkCapability({
|
|
28
|
+
act: action,
|
|
29
|
+
res
|
|
30
|
+
}, {
|
|
31
|
+
iss: viewerDID,
|
|
32
|
+
sub: ownerDID,
|
|
33
|
+
cap: delegationTokens
|
|
34
|
+
}, {
|
|
35
|
+
verifyToken: revocationChecker
|
|
36
|
+
});
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
// Continue checking other resources
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Try each token independently (multiple independent grants)
|
|
43
|
+
for (const token of delegationTokens){
|
|
44
|
+
for (const res of resources){
|
|
45
|
+
try {
|
|
46
|
+
await checkCapability({
|
|
47
|
+
act: action,
|
|
48
|
+
res
|
|
49
|
+
}, {
|
|
50
|
+
iss: viewerDID,
|
|
51
|
+
sub: ownerDID,
|
|
52
|
+
cap: token
|
|
53
|
+
}, {
|
|
54
|
+
verifyToken: revocationChecker
|
|
55
|
+
});
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
// Continue checking
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Authorize a single sync scope: the viewer must be the owner, or hold a
|
|
66
|
+
* delegation token granting read access to the owner's documents. Shared by
|
|
67
|
+
* negotiate and merkle-sync so the two access gates cannot drift apart.
|
|
68
|
+
*/ export async function authorizeScope(params) {
|
|
69
|
+
if (params.viewerDID === params.ownerDID) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return checkSyncDelegation(params.viewerDID, params.ownerDID, params.delegationTokens, params.revocationChecker);
|
|
73
|
+
}
|