@kubun/plugin-p2p 0.13.1 → 0.15.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 (51) hide show
  1. package/lib/context/peer.js +250 -3
  2. package/lib/context/sync.js +134 -25
  3. package/lib/context/types.d.ts +7 -0
  4. package/lib/groups/broadcast-message.d.ts +29 -0
  5. package/lib/groups/broadcast.d.ts +11 -1
  6. package/lib/groups/broadcast.js +44 -2
  7. package/lib/groups/credential-apply.d.ts +64 -2
  8. package/lib/groups/credential-apply.js +215 -30
  9. package/lib/groups/credential-grant.d.ts +22 -0
  10. package/lib/groups/credential-grant.js +76 -2
  11. package/lib/groups/credential-manifest-token.d.ts +31 -0
  12. package/lib/groups/credential-manifest-token.js +49 -0
  13. package/lib/groups/credential-readiness.d.ts +69 -0
  14. package/lib/groups/credential-readiness.js +172 -0
  15. package/lib/groups/credential-wrapping-deps.d.ts +23 -0
  16. package/lib/groups/credential-wrapping-deps.js +25 -0
  17. package/lib/groups/grantor-authority.d.ts +65 -0
  18. package/lib/groups/grantor-authority.js +107 -0
  19. package/lib/groups/group-handlers.js +7 -0
  20. package/lib/groups/group-peer-manager.d.ts +25 -0
  21. package/lib/groups/group-peer-manager.js +71 -0
  22. package/lib/groups/group-protocols.d.ts +47 -0
  23. package/lib/groups/group-protocols.js +28 -0
  24. package/lib/hub/wiring.d.ts +24 -0
  25. package/lib/hub/wiring.js +17 -1
  26. package/lib/index.d.ts +14 -0
  27. package/lib/index.js +157 -6
  28. package/lib/peer/blob-fetch.d.ts +2 -18
  29. package/lib/protocol.d.ts +30 -0
  30. package/lib/protocol.js +36 -0
  31. package/lib/schema.d.ts +16 -1
  32. package/lib/schema.js +113 -4
  33. package/lib/sync/group-sync-workflow.d.ts +77 -0
  34. package/lib/sync/group-sync-workflow.js +96 -0
  35. package/lib/sync/handlers.js +21 -2
  36. package/lib/sync/held-delegations.d.ts +14 -0
  37. package/lib/sync/held-delegations.js +34 -0
  38. package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
  39. package/lib/sync/hub-tunnel-service-listener.js +289 -0
  40. package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
  41. package/lib/sync/hub-tunnel-service-provider.js +100 -0
  42. package/lib/sync/service-tunnel-listeners.d.ts +35 -0
  43. package/lib/sync/service-tunnel-listeners.js +165 -0
  44. package/lib/sync/sync-manager.d.ts +7 -0
  45. package/lib/sync/sync-manager.js +4 -1
  46. package/lib/sync/tunnel-topics.d.ts +19 -1
  47. package/lib/sync/tunnel-topics.js +7 -3
  48. package/lib/types.d.ts +182 -7
  49. package/lib/util/handler-error.d.ts +8 -5
  50. package/lib/util/handler-error.js +10 -23
  51. package/package.json +51 -46
@@ -1,24 +1,8 @@
1
+ import type { BlobAPI } from '@kubun/plugin-blob-api';
1
2
  import type { Fetch } from '@sozai/runtime';
2
3
  import type { BlobManifestResult } from '../protocol.js';
3
4
  import type { PeerConnection, PeerConnectionRegistry } from './connection-registry.js';
4
- export type BlobFetchAPI = {
5
- getAttachment(attachmentID: string): Promise<{
6
- state: string;
7
- } | null>;
8
- beginFetch(params: {
9
- attachmentID: string;
10
- chunkSize: number;
11
- chunkDigests: Array<Uint8Array>;
12
- }): Promise<void>;
13
- getPresentChunks(attachmentID: string): Promise<Array<number>>;
14
- stageChunk(params: {
15
- attachmentID: string;
16
- index: number;
17
- offset: number;
18
- bytes: Uint8Array;
19
- }): Promise<void>;
20
- completeFetch(attachmentID: string): Promise<void>;
21
- };
5
+ export type BlobFetchAPI = Pick<BlobAPI, 'getAttachment' | 'beginFetch' | 'getPresentChunks' | 'stageChunk' | 'completeFetch'>;
22
6
  export type PeerConnectionLister = {
23
7
  list(): Array<PeerConnection>;
24
8
  };
package/lib/protocol.d.ts CHANGED
@@ -229,6 +229,13 @@ export declare const syncProtocol: {
229
229
  };
230
230
  readonly description: "Signed subject states the caller holds, for updates and revocation GC";
231
231
  };
232
+ readonly owners: {
233
+ readonly type: "array";
234
+ readonly items: {
235
+ readonly type: "string";
236
+ };
237
+ readonly description: "Owner DIDs to fetch the latest durable manifest for, addressed to the caller — the PULL repair channel independent of LIVE broadcast.";
238
+ };
232
239
  };
233
240
  readonly required: readonly ["held"];
234
241
  readonly additionalProperties: false;
@@ -327,6 +334,9 @@ export declare const syncProtocol: {
327
334
  readonly additionalProperties: false;
328
335
  };
329
336
  };
337
+ readonly grantorAuthority: {
338
+ readonly type: "string";
339
+ };
330
340
  };
331
341
  readonly required: readonly ["keyID", "keyVersion", "suite", "ownerDID", "keyOp", "keyBranches", "wrapping", "entries"];
332
342
  readonly additionalProperties: false;
@@ -362,6 +372,26 @@ export declare const syncProtocol: {
362
372
  };
363
373
  readonly description: "Tombstones for advertised held IDs that are now revoked (GC)";
364
374
  };
375
+ readonly manifests: {
376
+ readonly type: "array";
377
+ readonly items: {
378
+ readonly type: "object";
379
+ readonly properties: {
380
+ readonly manifest: {
381
+ readonly type: "string";
382
+ };
383
+ readonly delegationTokens: {
384
+ readonly type: "array";
385
+ readonly items: {
386
+ readonly type: "string";
387
+ };
388
+ };
389
+ };
390
+ readonly required: readonly ["manifest", "delegationTokens"];
391
+ readonly additionalProperties: false;
392
+ };
393
+ readonly description: "The latest stored manifest addressed to the caller for each requested owner — the durable repair channel independent of LIVE broadcast.";
394
+ };
365
395
  };
366
396
  readonly required: readonly ["bundles", "tombstones"];
367
397
  readonly additionalProperties: false;
package/lib/protocol.js CHANGED
@@ -290,6 +290,13 @@ export const syncProtocol = {
290
290
  additionalProperties: false
291
291
  },
292
292
  description: 'Signed subject states the caller holds, for updates and revocation GC'
293
+ },
294
+ owners: {
295
+ type: 'array',
296
+ items: {
297
+ type: 'string'
298
+ },
299
+ description: 'Owner DIDs to fetch the latest durable manifest for, addressed to the caller — the PULL repair channel independent of LIVE broadcast.'
293
300
  }
294
301
  },
295
302
  required: [
@@ -406,6 +413,12 @@ export const syncProtocol = {
406
413
  ],
407
414
  additionalProperties: false
408
415
  }
416
+ },
417
+ // Persisted grantor-authority envelope for a delegate-signed
418
+ // wrapping (absent for owner-signed); declared so it isn't an
419
+ // `additionalProperties: false` violation if result validation is enabled.
420
+ grantorAuthority: {
421
+ type: 'string'
409
422
  }
410
423
  },
411
424
  required: [
@@ -457,6 +470,29 @@ export const syncProtocol = {
457
470
  additionalProperties: false
458
471
  },
459
472
  description: 'Tombstones for advertised held IDs that are now revoked (GC)'
473
+ },
474
+ manifests: {
475
+ type: 'array',
476
+ items: {
477
+ type: 'object',
478
+ properties: {
479
+ manifest: {
480
+ type: 'string'
481
+ },
482
+ delegationTokens: {
483
+ type: 'array',
484
+ items: {
485
+ type: 'string'
486
+ }
487
+ }
488
+ },
489
+ required: [
490
+ 'manifest',
491
+ 'delegationTokens'
492
+ ],
493
+ additionalProperties: false
494
+ },
495
+ description: 'The latest stored manifest addressed to the caller for each requested owner — the durable repair channel independent of LIVE broadcast.'
460
496
  }
461
497
  },
462
498
  required: [
package/lib/schema.d.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  import type { SchemaExtension } from '@kubun/engine';
2
2
  import { type Logger } from '@kubun/logger';
3
+ import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
3
4
  import { type P2PEventEmitter } from './groups/events.js';
4
5
  import type { SyncManager } from './sync/sync-manager.js';
5
- export declare function createP2PSchemaExtension(emitter: P2PEventEmitter, syncManager: SyncManager, logger?: Logger): SchemaExtension;
6
+ /**
7
+ * Recurring group catch-up control the GraphQL resolvers call, threaded from the
8
+ * plugin so they reach the same workflow-backed schedule the runtime API arms.
9
+ * Results are the workflow plugin's PeriodicSync projection (opaque here).
10
+ */
11
+ export type GroupPeriodicSyncControl = {
12
+ enableGroupPeriodicSync(groupID: string, policy?: AdaptivePolicy): Promise<unknown>;
13
+ disableGroupPeriodicSync(groupID: string): Promise<unknown | null>;
14
+ getGroupPeriodicSync(groupID: string): Promise<unknown | null>;
15
+ };
16
+ /** Deploy-config the p2p schema extension reads. */
17
+ export type P2PSchemaConfig = {
18
+ periodicSync?: boolean;
19
+ };
20
+ export declare function createP2PSchemaExtension(emitter: P2PEventEmitter, syncManager: SyncManager, logger?: Logger, control?: GroupPeriodicSyncControl, config?: P2PSchemaConfig): SchemaExtension;
package/lib/schema.js CHANGED
@@ -581,6 +581,12 @@ extend type Query {
581
581
  one group. The store is per-device.
582
582
  """
583
583
  controlRequests(groupID: ID): [ControlRequest!]!
584
+ """
585
+ Prove this device holds the complete credential set the owner's manifests
586
+ promised it: the union of every per-grantor manifest's keys, minus those that
587
+ actually decrypt-verify here. An empty missing list means the pull is complete.
588
+ """
589
+ credentialProvisioningStatus(ownerDID: DID!): CredentialProvisioningStatus!
584
590
  }
585
591
 
586
592
  enum GroupHealthCondition {
@@ -644,6 +650,48 @@ type ControlRequest implements Node {
644
650
  settledAt: DateTimeISO
645
651
  }
646
652
 
653
+ """
654
+ One key's outcome from a grantDeviceCredentials batch: what happened when this
655
+ device tried to hand one of the owner's active credential keys to the recipient
656
+ device. Never an error thrown out of the batch — every key reports its own fate.
657
+ """
658
+ type CredentialGrantOutcome {
659
+ keyID: ID!
660
+ """
661
+ The key's version after this call, or null when nothing was granted
662
+ (not-held / error outcomes carry no version).
663
+ """
664
+ version: Int
665
+ outcome: String!
666
+ """Present on not-held and error; the caught error's message."""
667
+ reason: String
668
+ }
669
+
670
+ type GrantDeviceCredentialsPayload {
671
+ results: [CredentialGrantOutcome!]!
672
+ """A grantor-signed manifest claiming exactly the granted/refreshed keys above."""
673
+ manifest: String!
674
+ """The provisioning epoch this manifest was allocated under."""
675
+ epoch: Int!
676
+ }
677
+
678
+ """
679
+ Readiness of this device's credential provisioning for one owner. A key counts
680
+ materialized only when its wrapping decrypt-verifies (or, for a zero-entry key,
681
+ merely opens) — presence and a valid signature alone never suffice.
682
+ """
683
+ type CredentialProvisioningStatus {
684
+ complete: Boolean!
685
+ """The expected keyIDs not yet materialized on this device."""
686
+ missing: [ID!]!
687
+ """
688
+ Why complete is false: not-initiated (no expectation on record yet),
689
+ pending (an attempt is in flight, its floor not yet bound), or incomplete
690
+ (asked for, not yet fully materialized/proven). Absent when complete.
691
+ """
692
+ reason: String
693
+ }
694
+
647
695
  extend type Mutation {
648
696
  connectPeer(url: String!): ConnectPeerPayload!
649
697
  sharePeerGroup(peerDID: ID!, groupID: ID, name: String, send: ShareInput, receive: ShareReceiveInput): SharePeerGroupPayload!
@@ -654,7 +702,15 @@ extend type Mutation {
654
702
  DID comes from the request's KeyPackage credential; a disagreeing did field, or a
655
703
  DID already on the roster, is refused.
656
704
  """
657
- admitJoinRequest(groupID: ID!, joinRequest: String!, send: ShareInput, receive: ShareReceiveInput): AdmitJoinRequestPayload!
705
+ admitJoinRequest(groupID: ID!, joinRequest: String!, grants: [String!], send: ShareInput, receive: ShareReceiveInput): AdmitJoinRequestPayload!
706
+ """
707
+ Grant a device recipientDID this device's active credential keys for ownerDID,
708
+ resolving the recipient from the group's MLS roster. Returns every key's
709
+ outcome plus a grantor-signed manifest the recipient checks its store against.
710
+ minEpoch floors the allocated epoch at the recipient's held epoch, so a
711
+ reprovision attempt always supersedes a manifest it already holds. Defaults to 0.
712
+ """
713
+ grantDeviceCredentials(groupID: ID!, recipientDID: DID!, ownerDID: DID!, minEpoch: Int): GrantDeviceCredentialsPayload!
658
714
  joinPeerGroup(peerDID: ID!, groupID: ID!): JoinPeerGroupPayload!
659
715
  """
660
716
  Apply one circle's desired sync end state. A null argument leaves that dimension
@@ -760,9 +816,30 @@ extend type Subscription {
760
816
  controlRequestSettled(groupID: ID, requestID: ID): ControlRequest!
761
817
  }
762
818
  `;
763
- export function createP2PSchemaExtension(emitter, syncManager, logger = getKubunLogger('p2p-schema')) {
819
+ // Recurring-sync control fields, appended only when the deploy opted in. Gated
820
+ // because they reference `PeriodicSync`, defined by the co-deployed workflow
821
+ // plugin — referencing it on a p2p-only graph would fail schema build.
822
+ const periodicSyncSDL = `
823
+ extend type Query {
824
+ groupPeriodicSync(groupID: ID!): PeriodicSync
825
+ }
826
+
827
+ extend type Mutation {
828
+ enableGroupPeriodicSync(groupID: ID!, policy: JSON): PeriodicSync!
829
+ disableGroupPeriodicSync(groupID: ID!): PeriodicSync
830
+ }
831
+ `;
832
+ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubunLogger('p2p-schema'), control, config) {
833
+ const periodicSyncEnabled = config?.periodicSync === true && control != null;
834
+ const periodicSyncQueryFields = periodicSyncEnabled ? {
835
+ groupPeriodicSync: (_source, args, _context)=>control.getGroupPeriodicSync(args.groupID)
836
+ } : {};
837
+ const periodicSyncMutationFields = periodicSyncEnabled ? {
838
+ enableGroupPeriodicSync: (_source, args, _context)=>control.enableGroupPeriodicSync(args.groupID, args.policy),
839
+ disableGroupPeriodicSync: (_source, args, _context)=>control.disableGroupPeriodicSync(args.groupID)
840
+ } : {};
764
841
  return {
765
- sdl,
842
+ sdl: periodicSyncEnabled ? sdl + periodicSyncSDL : sdl,
766
843
  // These mutations perform network I/O (peer discovery, the MLS dance,
767
844
  // merkle sync) and/or apply received mutations in their own per-step
768
845
  // transactions. Running them inside `mutateGraph`'s write transaction would
@@ -789,10 +866,20 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
789
866
  // Waits for its Add commit to land. The lane reads the group's handle on
790
867
  // the registry's own connection, so holding a write transaction across
791
868
  // that wait deadlocks single-connection SQLite.
792
- 'requestInviteToGroup'
869
+ 'requestInviteToGroup',
870
+ // Write the workflow store synchronously and must return the resulting
871
+ // projection; running them inside `mutateGraph`'s write transaction would
872
+ // hold the single-connection DB across the workflow-store write and
873
+ // deadlock (as the connector's enable/disable are declared for the same
874
+ // reason).
875
+ ...periodicSyncEnabled ? [
876
+ 'enableGroupPeriodicSync',
877
+ 'disableGroupPeriodicSync'
878
+ ] : []
793
879
  ],
794
880
  resolvers: {
795
881
  queryFields: {
882
+ ...periodicSyncQueryFields,
796
883
  groups: async (_source, _args, context)=>{
797
884
  return await requireP2P(context).group.list();
798
885
  },
@@ -849,6 +936,11 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
849
936
  const requests = await requireP2P(context).group.listControlRequests(args.groupID);
850
937
  return requests.map(toControlRequestSDL);
851
938
  },
939
+ credentialProvisioningStatus: async (_source, args, context)=>{
940
+ return await requireP2P(context).peer.credentialProvisioningStatus({
941
+ ownerDID: args.ownerDID
942
+ });
943
+ },
852
944
  ownDelegationTokens: async (_source, args, context)=>{
853
945
  const viewerDID = context.getViewer();
854
946
  if (viewerDID == null || context.p2p == null) {
@@ -875,6 +967,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
875
967
  }
876
968
  },
877
969
  mutationFields: {
970
+ ...periodicSyncMutationFields,
878
971
  connectPeer: async (_source, args, context)=>{
879
972
  const peer = await requireP2P(context).peer.connect(args.url);
880
973
  return {
@@ -916,6 +1009,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
916
1009
  return await requireP2P(context).peer.admitJoinRequest({
917
1010
  groupID: args.groupID,
918
1011
  joinRequest: args.joinRequest,
1012
+ grants: args.grants ?? undefined,
919
1013
  sendModels: send?.models ?? null,
920
1014
  receiveActivate: receive?.activate ?? false,
921
1015
  // Same owner-signed executor `sharePeerGroup` binds, so the rows this
@@ -934,6 +1028,21 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
934
1028
  }
935
1029
  });
936
1030
  },
1031
+ grantDeviceCredentials: async (_source, args, context)=>{
1032
+ const { results, manifest, epoch } = await requireP2P(context).peer.grantDeviceCredentials({
1033
+ groupID: args.groupID,
1034
+ recipientDID: args.recipientDID,
1035
+ ownerDID: args.ownerDID,
1036
+ ...args.minEpoch == null ? {} : {
1037
+ minEpoch: args.minEpoch
1038
+ }
1039
+ });
1040
+ return {
1041
+ results,
1042
+ manifest,
1043
+ epoch
1044
+ };
1045
+ },
937
1046
  joinPeerGroup: async (_source, args, context)=>{
938
1047
  return await requireP2P(context).peer.joinPeerGroup({
939
1048
  peerDID: args.peerDID,
@@ -0,0 +1,77 @@
1
+ import type { Logger } from '@kubun/logger';
2
+ import type { AdaptivePolicy, WorkflowOutcome } from '@kubun/plugin-workflow-api';
3
+ import type { P2PEventEmitter } from '../groups/events.js';
4
+ import type { PeerCatchUpData } from '../types.js';
5
+ /** Queue lane (and workflow name) for group catch-up sync. */
6
+ export declare const GROUP_SYNC_WORKFLOW = "group-sync";
7
+ /** How many group catch-up passes may run at once on this peer. */
8
+ export declare const GROUP_SYNC_CONCURRENCY = 4;
9
+ type HandlerContext = {
10
+ state: Record<string, unknown>;
11
+ params?: unknown;
12
+ signal?: AbortSignal;
13
+ };
14
+ type HandlerResult = {
15
+ status: 'end';
16
+ state: Record<string, unknown>;
17
+ outcome?: WorkflowOutcome;
18
+ };
19
+ type GroupSyncHandler = (ctx: HandlerContext) => Promise<HandlerResult>;
20
+ export type GroupSyncWorkflowDefinition = {
21
+ name: string;
22
+ initialAction: {
23
+ name: string;
24
+ };
25
+ handlers: Record<string, GroupSyncHandler>;
26
+ };
27
+ /**
28
+ * Map one catch-up pass to the outcome that paces the next run. A reachable peer
29
+ * that shipped mutations is productive (`changed`); a reachable peer with nothing
30
+ * new, or no scope to pull, is `idle`; no peer answered (none known, or none on)
31
+ * is `offline` so the scheduler backs off. A thrown apply never reaches here —
32
+ * the engine forces `error` on the failed terminal.
33
+ */
34
+ export declare function classifyCatchUp(data: PeerCatchUpData): WorkflowOutcome;
35
+ export type GroupSyncWorkflowParams = {
36
+ catchUpWithBestPeer: (groupID: string) => Promise<PeerCatchUpData>;
37
+ logger: Logger;
38
+ };
39
+ /**
40
+ * Build the `group-sync` workflow definition. The single `start` handler runs one
41
+ * `catchUpWithBestPeer` pass and classifies its result; a genuine throw is left to
42
+ * propagate so the engine forces the terminal `error` (and its backoff), never
43
+ * swallowed into a false success.
44
+ */
45
+ export declare function createGroupSyncWorkflow(params: GroupSyncWorkflowParams): {
46
+ definition: GroupSyncWorkflowDefinition;
47
+ };
48
+ export type PeriodicSyncArmingAPI = {
49
+ scheduleAdaptive(name: string, params: unknown, opts: {
50
+ policy: AdaptivePolicy;
51
+ subjectKey: string;
52
+ }): Promise<{
53
+ id: string;
54
+ }>;
55
+ getPeriodicSync(scheduleID: string): Promise<unknown | null>;
56
+ };
57
+ /**
58
+ * Arm a group's recurring catch-up the first time it is joined — INSERT-IF-ABSENT.
59
+ * `scheduleAdaptive` is insert-OR-reactivate, so guarding on the existing
60
+ * projection is what stops a re-join or reboot resurrecting a schedule the user
61
+ * explicitly disabled. Deliberately the opposite of the explicit enable, which
62
+ * always (re)activates.
63
+ */
64
+ export declare function armGroupPeriodicSyncIfAbsent(api: PeriodicSyncArmingAPI, groupID: string, policy: AdaptivePolicy): Promise<void>;
65
+ /**
66
+ * Subscribe implicit arming to `groupJoined`. The listener is fire-and-forget and
67
+ * never throws into the emitter (`emit` rethrows listener failures, and this rides
68
+ * the group-create/join commit): its async work is detached and its failure logged.
69
+ * Returns the unsubscribe.
70
+ */
71
+ export declare function wireGroupPeriodicSyncArming(params: {
72
+ emitter: P2PEventEmitter;
73
+ getWorkflowAPI: () => Promise<PeriodicSyncArmingAPI | undefined>;
74
+ policy: AdaptivePolicy;
75
+ logger: Logger;
76
+ }): () => void;
77
+ export {};
@@ -0,0 +1,96 @@
1
+ /** Queue lane (and workflow name) for group catch-up sync. */ export const GROUP_SYNC_WORKFLOW = 'group-sync';
2
+ /** How many group catch-up passes may run at once on this peer. */ export const GROUP_SYNC_CONCURRENCY = 4;
3
+ /**
4
+ * Map one catch-up pass to the outcome that paces the next run. A reachable peer
5
+ * that shipped mutations is productive (`changed`); a reachable peer with nothing
6
+ * new, or no scope to pull, is `idle`; no peer answered (none known, or none on)
7
+ * is `offline` so the scheduler backs off. A thrown apply never reaches here —
8
+ * the engine forces `error` on the failed terminal.
9
+ */ export function classifyCatchUp(data) {
10
+ switch(data.outcome){
11
+ case 'synced':
12
+ return data.messagesReceived > 0 ? 'changed' : 'idle';
13
+ case 'no-scopes':
14
+ return 'idle';
15
+ case 'no-candidates':
16
+ case 'no-route':
17
+ return 'offline';
18
+ }
19
+ }
20
+ /**
21
+ * Build the `group-sync` workflow definition. The single `start` handler runs one
22
+ * `catchUpWithBestPeer` pass and classifies its result; a genuine throw is left to
23
+ * propagate so the engine forces the terminal `error` (and its backoff), never
24
+ * swallowed into a false success.
25
+ */ export function createGroupSyncWorkflow(params) {
26
+ const { catchUpWithBestPeer, logger } = params;
27
+ const start = async (ctx)=>{
28
+ const { groupID } = ctx.params;
29
+ const data = await catchUpWithBestPeer(groupID);
30
+ const outcome = classifyCatchUp(data);
31
+ logger.debug('group catch-up classified', {
32
+ groupID,
33
+ outcome,
34
+ peerDID: data.peerDID
35
+ });
36
+ return {
37
+ status: 'end',
38
+ state: {
39
+ groupID
40
+ },
41
+ outcome
42
+ };
43
+ };
44
+ return {
45
+ definition: {
46
+ name: GROUP_SYNC_WORKFLOW,
47
+ initialAction: {
48
+ name: 'start'
49
+ },
50
+ handlers: {
51
+ start
52
+ }
53
+ }
54
+ };
55
+ }
56
+ /**
57
+ * Arm a group's recurring catch-up the first time it is joined — INSERT-IF-ABSENT.
58
+ * `scheduleAdaptive` is insert-OR-reactivate, so guarding on the existing
59
+ * projection is what stops a re-join or reboot resurrecting a schedule the user
60
+ * explicitly disabled. Deliberately the opposite of the explicit enable, which
61
+ * always (re)activates.
62
+ */ export async function armGroupPeriodicSyncIfAbsent(api, groupID, policy) {
63
+ const id = `${GROUP_SYNC_WORKFLOW}:${groupID}`;
64
+ if (await api.getPeriodicSync(id) == null) {
65
+ await api.scheduleAdaptive(GROUP_SYNC_WORKFLOW, {
66
+ groupID
67
+ }, {
68
+ policy,
69
+ subjectKey: groupID
70
+ });
71
+ }
72
+ }
73
+ /**
74
+ * Subscribe implicit arming to `groupJoined`. The listener is fire-and-forget and
75
+ * never throws into the emitter (`emit` rethrows listener failures, and this rides
76
+ * the group-create/join commit): its async work is detached and its failure logged.
77
+ * Returns the unsubscribe.
78
+ */ export function wireGroupPeriodicSyncArming(params) {
79
+ const { emitter, getWorkflowAPI, policy, logger } = params;
80
+ return emitter.on('groupJoined', (groupData)=>{
81
+ void (async ()=>{
82
+ try {
83
+ const api = await getWorkflowAPI();
84
+ if (api == null) {
85
+ return;
86
+ }
87
+ await armGroupPeriodicSyncIfAbsent(api, groupData.id, policy);
88
+ } catch (error) {
89
+ logger.error('implicit group periodic-sync arm failed', {
90
+ groupID: groupData.id,
91
+ error
92
+ });
93
+ }
94
+ })();
95
+ });
96
+ }
@@ -283,14 +283,33 @@ export { checkSyncDelegation };
283
283
  ...toWireOp(op),
284
284
  subjectID: op.subject_id
285
285
  }));
286
+ // Durable PULL repair, independent of live broadcast: manifests addressed
287
+ // to the authenticated `callerDID`, never an owner-supplied recipient,
288
+ // so a manifest never leaks to a device other than the one it names.
289
+ let manifests;
290
+ if (ctx.param.owners != null && ctx.param.owners.length > 0) {
291
+ // Independent per-owner lookups — query concurrently, preserve owner order.
292
+ const perOwner = await Promise.all(ctx.param.owners.map((owner)=>store.listManifests({
293
+ ownerDID: owner,
294
+ recipientDID: callerDID
295
+ })));
296
+ manifests = perOwner.flatMap((rows)=>rows.map((row)=>({
297
+ manifest: row.token,
298
+ delegationTokens: row.delegationTokens
299
+ })));
300
+ }
286
301
  logger.debug('sync/reconcile-credentials served', {
287
302
  callerDID,
288
303
  bundles: bundles.length,
289
- tombstones: tombstones.length
304
+ tombstones: tombstones.length,
305
+ manifests: manifests?.length ?? 0
290
306
  });
291
307
  return {
292
308
  bundles,
293
- tombstones
309
+ tombstones,
310
+ ...manifests != null && {
311
+ manifests
312
+ }
294
313
  };
295
314
  }
296
315
  });
@@ -0,0 +1,14 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ /**
3
+ * The device's own held read delegations for a set of scope owners, as the JWT
4
+ * strings an outbound catch-up rides in `delegationTokens`.
5
+ *
6
+ * Each token is a capability the owner (grantor) issued to this device
7
+ * (audience); presenting it lets the peer's `checkSyncDelegation` arm of
8
+ * `authorizeScope` authorize a pull of that owner's documents — e.g. a paired
9
+ * device reading a controller's documents via the pairing-time delegation.
10
+ *
11
+ * Keyed `grantor: ownerDID, audience: selfDID` (the direction the gate checks).
12
+ * Owners equal to `selfDID` are skipped; results are de-duplicated.
13
+ */
14
+ export declare function resolveHeldDelegationTokens(stores: StoreProvider, selfDID: string, owners: Iterable<string>): Promise<Array<string>>;
@@ -0,0 +1,34 @@
1
+ import { DELEGATION_STORE, getDelegationStore } from '@kubun/store-delegation';
2
+ /**
3
+ * The device's own held read delegations for a set of scope owners, as the JWT
4
+ * strings an outbound catch-up rides in `delegationTokens`.
5
+ *
6
+ * Each token is a capability the owner (grantor) issued to this device
7
+ * (audience); presenting it lets the peer's `checkSyncDelegation` arm of
8
+ * `authorizeScope` authorize a pull of that owner's documents — e.g. a paired
9
+ * device reading a controller's documents via the pairing-time delegation.
10
+ *
11
+ * Keyed `grantor: ownerDID, audience: selfDID` (the direction the gate checks).
12
+ * Owners equal to `selfDID` are skipped; results are de-duplicated.
13
+ */ export async function resolveHeldDelegationTokens(stores, selfDID, owners) {
14
+ const distinctOwners = new Set();
15
+ for (const owner of owners){
16
+ if (owner !== selfDID) {
17
+ distinctOwners.add(owner);
18
+ }
19
+ }
20
+ if (distinctOwners.size === 0 || !stores.hasStore(DELEGATION_STORE)) {
21
+ return [];
22
+ }
23
+ const delegationStore = await getDelegationStore(stores);
24
+ // Independent per-owner lookups — query concurrently; the Set dedups regardless of order.
25
+ const perOwner = await Promise.all([
26
+ ...distinctOwners
27
+ ].map((ownerDID)=>delegationStore.getDelegationTokens({
28
+ grantor: ownerDID,
29
+ audience: selfDID
30
+ })));
31
+ return [
32
+ ...new Set(perOwner.flat().map((row)=>row.token))
33
+ ];
34
+ }