@kubun/plugin-p2p 0.10.1 → 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 (145) 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 +3 -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 +105 -91
  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 +5 -1
  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 +61 -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 +11 -0
  28. package/lib/groups/group-health-monitor.js +139 -1
  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 +20 -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 +0 -6
  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.js +198 -1
  73. package/lib/hub/wiring.d.ts +17 -48
  74. package/lib/hub/wiring.js +132 -1
  75. package/lib/index.d.ts +17 -16
  76. package/lib/index.js +392 -1
  77. package/lib/protocol.d.ts +1 -1
  78. package/lib/protocol.js +251 -1
  79. package/lib/schema.d.ts +2 -1
  80. package/lib/schema.js +439 -2
  81. package/lib/sync/authorize.d.ts +1 -1
  82. package/lib/sync/authorize.js +73 -1
  83. package/lib/sync/broadcast-queue.js +183 -1
  84. package/lib/sync/broadcast-sender.js +179 -1
  85. package/lib/sync/catalog-scope.js +68 -1
  86. package/lib/sync/errors.d.ts +3 -2
  87. package/lib/sync/errors.js +19 -1
  88. package/lib/sync/forwarder.js +277 -1
  89. package/lib/sync/handlers.js +160 -1
  90. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
  91. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  92. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  93. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  94. package/lib/sync/merkle-apply.d.ts +5 -0
  95. package/lib/sync/merkle-apply.js +194 -1
  96. package/lib/sync/merkle-channel.d.ts +2 -2
  97. package/lib/sync/merkle-channel.js +182 -1
  98. package/lib/sync/merkle-tree.js +120 -1
  99. package/lib/sync/peer-registry.d.ts +1 -1
  100. package/lib/sync/peer-registry.js +107 -1
  101. package/lib/sync/receive-access-gate.js +24 -1
  102. package/lib/sync/scope-resolver.js +41 -1
  103. package/lib/sync/sync-client.d.ts +3 -3
  104. package/lib/sync/sync-client.js +192 -1
  105. package/lib/sync/sync-manager.d.ts +2 -2
  106. package/lib/sync/sync-manager.js +272 -1
  107. package/lib/types.d.ts +11 -0
  108. package/lib/types.js +2 -1
  109. package/lib/util/mutex.js +10 -1
  110. package/lib/utils.js +21 -1
  111. package/package.json +47 -40
  112. package/lib/groups/broadcast-service.d.ts +0 -263
  113. package/lib/groups/broadcast-service.js +0 -1
  114. package/lib/groups/rejoin-codec.d.ts +0 -14
  115. package/lib/groups/rejoin-codec.js +0 -1
  116. package/lib/groups/wire-frame.d.ts +0 -34
  117. package/lib/groups/wire-frame.js +0 -1
  118. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  119. package/lib/hub/circle-catchup-requester.js +0 -1
  120. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  121. package/lib/hub/circle-catchup-responder.js +0 -1
  122. package/lib/hub/connection-pool.d.ts +0 -43
  123. package/lib/hub/connection-pool.js +0 -1
  124. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  125. package/lib/hub/epoch-stale-detector.js +0 -1
  126. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  127. package/lib/hub/forward-remote-broadcast.js +0 -1
  128. package/lib/hub/group-channel.d.ts +0 -89
  129. package/lib/hub/group-channel.js +0 -1
  130. package/lib/hub/hub-connection.d.ts +0 -96
  131. package/lib/hub/hub-connection.js +0 -1
  132. package/lib/hub/receive-handler.d.ts +0 -51
  133. package/lib/hub/receive-handler.js +0 -1
  134. package/lib/hub/rejoin-manager.d.ts +0 -78
  135. package/lib/hub/rejoin-manager.js +0 -1
  136. package/lib/hub/rejoin-responder.d.ts +0 -32
  137. package/lib/hub/rejoin-responder.js +0 -1
  138. package/lib/hub/relay-manager.d.ts +0 -142
  139. package/lib/hub/relay-manager.js +0 -1
  140. package/lib/hub/send-handler.d.ts +0 -40
  141. package/lib/hub/send-handler.js +0 -1
  142. package/lib/hub/tunnel-inbox.d.ts +0 -20
  143. package/lib/hub/tunnel-inbox.js +0 -1
  144. package/lib/hub/wait-for-gate.d.ts +0 -14
  145. package/lib/hub/wait-for-gate.js +0 -1
package/lib/schema.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import type { SchemaExtension } from '@kubun/engine';
2
+ import { type Logger } from '@kubun/logger';
2
3
  import { type P2PEventEmitter } from './groups/events.js';
3
- export declare function createP2PSchemaExtension(emitter: P2PEventEmitter): SchemaExtension;
4
+ export declare function createP2PSchemaExtension(emitter: P2PEventEmitter, logger?: Logger): SchemaExtension;
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}let t={healthy:"HEALTHY",degraded:"DEGRADED",recovering:"RECOVERING",recovered:"RECOVERED","recovery-failed":"RECOVERY_FAILED"},o={"epoch-stale":"EPOCH_STALE"};async function*a(){}let i=`
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
@@ -266,6 +332,7 @@ extend type Mutation {
266
332
  completeJoin(invitePayload: String!): CompleteJoinPayload!
267
333
  leaveGroup(groupID: ID!): LeaveGroupPayload!
268
334
  removeGroupMember(groupID: ID!, memberDID: DID!): RemoveGroupMemberPayload!
335
+ setMemberRole(input: SetMemberRoleInput!): SetMemberRolePayload!
269
336
  updateCatalog(input: UpdateCatalogInput!): PeerCatalog!
270
337
  updateGroup(input: UpdateGroupInput!): UpdateGroupPayload!
271
338
  updateCircle(input: UpdateCircleInput!): UpdateCirclePayload!
@@ -308,4 +375,374 @@ extend type Subscription {
308
375
  ownDelegationTokenRevoked: RevokedDelegationToken!
309
376
  groupHealthChanged(groupID: ID): GroupHealthEvent!
310
377
  }
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)}}}}}}
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
+ };
748
+ }
@@ -1,4 +1,4 @@
1
- import { type VerifyTokenHook } from '@enkaku/capability';
1
+ import { type VerifyTokenHook } from '@kokuin/capability';
2
2
  /**
3
3
  * Check if delegation tokens grant the viewer read access to a user's documents.
4
4
  *
@@ -1 +1,73 @@
1
- import{checkCapability as e}from"@enkaku/capability";export async function checkSyncDelegation(r,t,n,o){if(!n||0===n.length)return!1;let c=["*","urn:kubun:user:*",`urn:kubun:user:${t}`],a="document/read";for(let u of c)try{return await e({act:a,res:u},{iss:r,sub:t,cap:n},{verifyToken:o}),!0}catch{}for(let u of n)for(let n of c)try{return await e({act:a,res:n},{iss:r,sub:t,cap:u},{verifyToken:o}),!0}catch{}return!1}export async function authorizeScope(e){return e.viewerDID===e.ownerDID||checkSyncDelegation(e.viewerDID,e.ownerDID,e.delegationTokens,e.revocationChecker)}
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
+ }