@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.
Files changed (136) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +12 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +122 -38
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +7 -2
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +69 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +29 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +4 -8
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
package/lib/schema.js CHANGED
@@ -1,4 +1,55 @@
1
- import{createFilteredGenerator as e}from"./groups/events.js";function r(e){if(null==e.p2p)throw Error("p2p plugin is not wired into this context");return e.p2p}async function*t(){}let o=`
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
+ }