@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.
- package/lib/context/peer.js +250 -3
- package/lib/context/sync.js +134 -25
- package/lib/context/types.d.ts +7 -0
- package/lib/groups/broadcast-message.d.ts +29 -0
- package/lib/groups/broadcast.d.ts +11 -1
- package/lib/groups/broadcast.js +44 -2
- package/lib/groups/credential-apply.d.ts +64 -2
- package/lib/groups/credential-apply.js +215 -30
- package/lib/groups/credential-grant.d.ts +22 -0
- package/lib/groups/credential-grant.js +76 -2
- package/lib/groups/credential-manifest-token.d.ts +31 -0
- package/lib/groups/credential-manifest-token.js +49 -0
- package/lib/groups/credential-readiness.d.ts +69 -0
- package/lib/groups/credential-readiness.js +172 -0
- package/lib/groups/credential-wrapping-deps.d.ts +23 -0
- package/lib/groups/credential-wrapping-deps.js +25 -0
- package/lib/groups/grantor-authority.d.ts +65 -0
- package/lib/groups/grantor-authority.js +107 -0
- package/lib/groups/group-handlers.js +7 -0
- package/lib/groups/group-peer-manager.d.ts +25 -0
- package/lib/groups/group-peer-manager.js +71 -0
- package/lib/groups/group-protocols.d.ts +47 -0
- package/lib/groups/group-protocols.js +28 -0
- package/lib/hub/wiring.d.ts +24 -0
- package/lib/hub/wiring.js +17 -1
- package/lib/index.d.ts +14 -0
- package/lib/index.js +157 -6
- package/lib/peer/blob-fetch.d.ts +2 -18
- package/lib/protocol.d.ts +30 -0
- package/lib/protocol.js +36 -0
- package/lib/schema.d.ts +16 -1
- package/lib/schema.js +113 -4
- package/lib/sync/group-sync-workflow.d.ts +77 -0
- package/lib/sync/group-sync-workflow.js +96 -0
- package/lib/sync/handlers.js +21 -2
- package/lib/sync/held-delegations.d.ts +14 -0
- package/lib/sync/held-delegations.js +34 -0
- package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
- package/lib/sync/hub-tunnel-service-listener.js +289 -0
- package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
- package/lib/sync/hub-tunnel-service-provider.js +100 -0
- package/lib/sync/service-tunnel-listeners.d.ts +35 -0
- package/lib/sync/service-tunnel-listeners.js +165 -0
- package/lib/sync/sync-manager.d.ts +7 -0
- package/lib/sync/sync-manager.js +4 -1
- package/lib/sync/tunnel-topics.d.ts +19 -1
- package/lib/sync/tunnel-topics.js +7 -3
- package/lib/types.d.ts +182 -7
- package/lib/util/handler-error.d.ts +8 -5
- package/lib/util/handler-error.js +10 -23
- package/package.json +51 -46
package/lib/peer/blob-fetch.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/sync/handlers.js
CHANGED
|
@@ -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
|
+
}
|