@kubun/plugin-p2p 0.15.1 → 0.16.1
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/group.js +8 -0
- package/lib/context/join.js +27 -0
- package/lib/context/peer.js +2 -2
- package/lib/context/sync.js +1 -1
- package/lib/groups/group-crypto.d.ts +8 -9
- package/lib/groups/group-crypto.js +51 -55
- package/lib/groups/group-handlers.d.ts +50 -14
- package/lib/groups/group-handlers.js +190 -24
- package/lib/groups/group-peer-manager.d.ts +46 -12
- package/lib/groups/group-peer-manager.js +209 -58
- package/lib/groups/group-protocols.d.ts +407 -0
- package/lib/groups/group-protocols.js +279 -11
- package/lib/groups/mls-codec.d.ts +19 -13
- package/lib/groups/mls-codec.js +28 -15
- package/lib/groups/peer-presence.d.ts +6 -0
- package/lib/groups/peer-presence.js +5 -0
- package/lib/hub/hub-like.js +7 -6
- package/lib/hub/loopback-log-hub.js +4 -1
- package/lib/hub/wiring.d.ts +29 -6
- package/lib/hub/wiring.js +15 -0
- package/lib/index.js +79 -16
- package/lib/schema.js +2 -1
- package/lib/sync/sync-manager.d.ts +1 -5
- package/lib/sync/sync-manager.js +0 -5
- package/lib/types.d.ts +15 -1
- package/lib/types.js +4 -0
- package/package.json +48 -47
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { COMMAND_REFUSAL_REASONS } from '@kubun/plugin-workflow-api';
|
|
1
2
|
import { defineGroupProtocol } from '@kumiai/rpc';
|
|
2
3
|
/**
|
|
3
4
|
* App-lane group-rpc protocols carried over a group's rotating broadcast topics.
|
|
@@ -42,10 +43,10 @@ import { defineGroupProtocol } from '@kumiai/rpc';
|
|
|
42
43
|
},
|
|
43
44
|
'control/policyCatchup': {
|
|
44
45
|
type: 'request',
|
|
45
|
-
// Nothing kept: the reply is a snapshot of
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
46
|
+
// Nothing kept: the reply is a snapshot of each responder's current rules,
|
|
47
|
+
// and one that outlived its window would answer for a policy that may since
|
|
48
|
+
// have been rewritten. The durable half of this lane is
|
|
49
|
+
// `control/accessDefaultSet`, which this repairs rather than replaces.
|
|
49
50
|
retain: 'ephemeral',
|
|
50
51
|
description: 'Ask current members to restate their OWN model access-defaults, for a member whose read position fell below the hub retention floor and can no longer read the frames that carried them. Gathered: each member replies with signed tokens for its own rules; the requester verifies every token and applies it under the same sender-bound LWW rules as a live frame.',
|
|
51
52
|
param: {
|
|
@@ -251,13 +252,12 @@ import { defineGroupProtocol } from '@kumiai/rpc';
|
|
|
251
252
|
},
|
|
252
253
|
'control/credentialKeyGrant': {
|
|
253
254
|
type: 'event',
|
|
254
|
-
// Retained, and
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
// costs one log frame, since the depth bound counts frames and not bytes.
|
|
255
|
+
// Retained, and the load-bearing half of the design. A grant is a one-shot
|
|
256
|
+
// push with no background replication or catch-up behind it: ephemeral, a
|
|
257
|
+
// recipient offline at grant time silently never gets access, and the only
|
|
258
|
+
// repair is a human granting again. Retention is what actually delivers to a
|
|
259
|
+
// device away across a roster change, and costs one log frame (the depth
|
|
260
|
+
// bound counts frames, not bytes).
|
|
261
261
|
retain: 'log',
|
|
262
262
|
description: "Hand one co-member what it needs to open a credential key: the key's public record, the single wrapping addressed to that member, and every entry ciphertext at that version. Group-wide because confidentiality is content-level; only the addressed DID applies it. `auth` signs identifiers and content digests, and the receiver recomputes both from this frame.",
|
|
263
263
|
data: {
|
|
@@ -612,6 +612,258 @@ export const peerProtocol = defineGroupProtocol({
|
|
|
612
612
|
result: peerAnnouncementBody
|
|
613
613
|
}
|
|
614
614
|
});
|
|
615
|
+
/**
|
|
616
|
+
* One instance's public status, mirroring `InstanceStatus` from
|
|
617
|
+
* `@kubun/plugin-workflow-api`. Hand-declared as a schema (not derived) because
|
|
618
|
+
* the protocol layer takes no runtime dependency on the workflow plugin — the
|
|
619
|
+
* live shape arrives at the handler through `engine.getAPI('workflow')`. `status`
|
|
620
|
+
* is a bare string here: the wire only carries the value, and the API's narrower
|
|
621
|
+
* union is re-applied where the handler reads it.
|
|
622
|
+
*/ const workflowInstanceStatus = {
|
|
623
|
+
type: 'object',
|
|
624
|
+
properties: {
|
|
625
|
+
instanceID: {
|
|
626
|
+
type: 'string'
|
|
627
|
+
},
|
|
628
|
+
name: {
|
|
629
|
+
type: 'string'
|
|
630
|
+
},
|
|
631
|
+
queue: {
|
|
632
|
+
type: 'string'
|
|
633
|
+
},
|
|
634
|
+
status: {
|
|
635
|
+
type: 'string'
|
|
636
|
+
},
|
|
637
|
+
attempts: {
|
|
638
|
+
type: 'number'
|
|
639
|
+
},
|
|
640
|
+
lastError: {
|
|
641
|
+
type: [
|
|
642
|
+
'string',
|
|
643
|
+
'null'
|
|
644
|
+
]
|
|
645
|
+
},
|
|
646
|
+
createdAt: {
|
|
647
|
+
type: 'number'
|
|
648
|
+
},
|
|
649
|
+
updatedAt: {
|
|
650
|
+
type: 'number'
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
required: [
|
|
654
|
+
'instanceID',
|
|
655
|
+
'name',
|
|
656
|
+
'queue',
|
|
657
|
+
'status',
|
|
658
|
+
'attempts',
|
|
659
|
+
'lastError',
|
|
660
|
+
'createdAt',
|
|
661
|
+
'updatedAt'
|
|
662
|
+
],
|
|
663
|
+
additionalProperties: false
|
|
664
|
+
};
|
|
665
|
+
/**
|
|
666
|
+
* A directed command's typed result, as a discriminated union carried as DATA.
|
|
667
|
+
* Enkaku collapses every handler THROW to one generic `HANDLER_ERROR`, so a
|
|
668
|
+
* business refusal cannot travel as a thrown reason — success returns the
|
|
669
|
+
* resulting `InstanceStatus`, refusal returns a machine reason a caller helper
|
|
670
|
+
* reconstitutes into a typed local throw. Only genuine infra faults still throw
|
|
671
|
+
* (→ generic error).
|
|
672
|
+
*
|
|
673
|
+
* The two branches are declared as their own consts and spread into each
|
|
674
|
+
* procedure's `result: { anyOf: [...] }` inline: a shared `as const` schema
|
|
675
|
+
* assigned whole to `result` is a frozen readonly tuple the protocol definer's
|
|
676
|
+
* `const` generic rejects, but the same consts nested under an inline `anyOf`
|
|
677
|
+
* infer cleanly.
|
|
678
|
+
*/ const workflowCommandOk = {
|
|
679
|
+
type: 'object',
|
|
680
|
+
properties: {
|
|
681
|
+
ok: {
|
|
682
|
+
type: 'boolean',
|
|
683
|
+
const: true
|
|
684
|
+
},
|
|
685
|
+
status: workflowInstanceStatus
|
|
686
|
+
},
|
|
687
|
+
required: [
|
|
688
|
+
'ok',
|
|
689
|
+
'status'
|
|
690
|
+
],
|
|
691
|
+
additionalProperties: false
|
|
692
|
+
};
|
|
693
|
+
// The wire refusal enum derives from the single COMMAND_REFUSAL_REASONS list in
|
|
694
|
+
// @kubun/plugin-workflow-api (`not_commandable` + the engine's own
|
|
695
|
+
// RETRY_REFUSAL_REASONS), so it cannot drift from the caller-facing contract type
|
|
696
|
+
// that aliases the same list.
|
|
697
|
+
const workflowCommandRefusal = {
|
|
698
|
+
type: 'object',
|
|
699
|
+
properties: {
|
|
700
|
+
ok: {
|
|
701
|
+
type: 'boolean',
|
|
702
|
+
const: false
|
|
703
|
+
},
|
|
704
|
+
reason: {
|
|
705
|
+
type: 'string',
|
|
706
|
+
enum: [
|
|
707
|
+
...COMMAND_REFUSAL_REASONS
|
|
708
|
+
]
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
required: [
|
|
712
|
+
'ok',
|
|
713
|
+
'reason'
|
|
714
|
+
],
|
|
715
|
+
additionalProperties: false
|
|
716
|
+
};
|
|
717
|
+
/**
|
|
718
|
+
* Workflow plane: remote observation of a device's durable workflow instances.
|
|
719
|
+
* Mounted only when this device runs the workflow plugin (see the manager's
|
|
720
|
+
* conditional composition) — absent it, no `workflow` topic exists and a gather
|
|
721
|
+
* on this lane routes to nothing.
|
|
722
|
+
*
|
|
723
|
+
* ATTRIBUTION IS THE LANE'S, NOT THE BODY'S, as on the peer plane: the reply is a
|
|
724
|
+
* bare `InstanceStatus[]` and the gather envelope supplies the responder's
|
|
725
|
+
* authenticated `senderDID`, so the body names no device.
|
|
726
|
+
*/ export const workflowProtocol = defineGroupProtocol({
|
|
727
|
+
'workflow/discover': {
|
|
728
|
+
type: 'request',
|
|
729
|
+
// Liveness: a reply is what this device is running right now, and one that
|
|
730
|
+
// outlived its window would answer for state that may since have changed.
|
|
731
|
+
retain: 'ephemeral',
|
|
732
|
+
description: 'Ask co-members which of their workflow instances are remotely observable. Gathered: each device replies with only its own remote.observe instances.',
|
|
733
|
+
param: {
|
|
734
|
+
type: 'object',
|
|
735
|
+
properties: {},
|
|
736
|
+
additionalProperties: false
|
|
737
|
+
},
|
|
738
|
+
result: {
|
|
739
|
+
type: 'array',
|
|
740
|
+
items: workflowInstanceStatus
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
'workflow/list': {
|
|
744
|
+
type: 'request',
|
|
745
|
+
// Directed enumeration: reached via `.to(peer)`, not gather. Same liveness as
|
|
746
|
+
// discover — a reply is what the peer runs right now.
|
|
747
|
+
retain: 'ephemeral',
|
|
748
|
+
description: 'Ask one peer (directed) for its remotely-observable workflow instances. The peer replies with only its own remote.observe instances.',
|
|
749
|
+
param: {
|
|
750
|
+
type: 'object',
|
|
751
|
+
properties: {},
|
|
752
|
+
additionalProperties: false
|
|
753
|
+
},
|
|
754
|
+
result: {
|
|
755
|
+
type: 'array',
|
|
756
|
+
items: workflowInstanceStatus
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
'workflow/status': {
|
|
760
|
+
type: 'request',
|
|
761
|
+
retain: 'ephemeral',
|
|
762
|
+
description: 'Ask one peer (directed) for one instance status by id. A non-observable instance is refused identically to an unknown one, so the reply never reveals that a hidden instance exists.',
|
|
763
|
+
param: {
|
|
764
|
+
type: 'object',
|
|
765
|
+
properties: {
|
|
766
|
+
instanceID: {
|
|
767
|
+
type: 'string'
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
required: [
|
|
771
|
+
'instanceID'
|
|
772
|
+
],
|
|
773
|
+
additionalProperties: false
|
|
774
|
+
},
|
|
775
|
+
result: workflowInstanceStatus
|
|
776
|
+
},
|
|
777
|
+
'workflow/enqueue': {
|
|
778
|
+
type: 'request',
|
|
779
|
+
retain: 'ephemeral',
|
|
780
|
+
description: 'Ask one peer (directed) to enqueue a named workflow. Directed addressing means exactly one device runs it — no double-execution. A workflow that did not opt into remote command is refused with `not_commandable`; success returns the new instance status.',
|
|
781
|
+
param: {
|
|
782
|
+
type: 'object',
|
|
783
|
+
properties: {
|
|
784
|
+
name: {
|
|
785
|
+
type: 'string'
|
|
786
|
+
},
|
|
787
|
+
// Arbitrary workflow input, opaque at this boundary.
|
|
788
|
+
params: {},
|
|
789
|
+
opts: {
|
|
790
|
+
type: 'object',
|
|
791
|
+
properties: {
|
|
792
|
+
queue: {
|
|
793
|
+
type: 'string'
|
|
794
|
+
},
|
|
795
|
+
priority: {
|
|
796
|
+
type: 'number'
|
|
797
|
+
},
|
|
798
|
+
idempotencyKey: {
|
|
799
|
+
type: 'string'
|
|
800
|
+
},
|
|
801
|
+
singletonKey: {
|
|
802
|
+
type: 'string'
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
additionalProperties: false
|
|
806
|
+
}
|
|
807
|
+
},
|
|
808
|
+
required: [
|
|
809
|
+
'name'
|
|
810
|
+
],
|
|
811
|
+
additionalProperties: false
|
|
812
|
+
},
|
|
813
|
+
result: {
|
|
814
|
+
anyOf: [
|
|
815
|
+
workflowCommandOk,
|
|
816
|
+
workflowCommandRefusal
|
|
817
|
+
]
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
'workflow/cancel': {
|
|
821
|
+
type: 'request',
|
|
822
|
+
retain: 'ephemeral',
|
|
823
|
+
description: 'Ask one peer (directed) to cancel one instance by id. A non-commandable instance is refused identically to an unknown one (`not_found`), so the reply never reveals that a hidden instance exists.',
|
|
824
|
+
param: {
|
|
825
|
+
type: 'object',
|
|
826
|
+
properties: {
|
|
827
|
+
instanceID: {
|
|
828
|
+
type: 'string'
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
required: [
|
|
832
|
+
'instanceID'
|
|
833
|
+
],
|
|
834
|
+
additionalProperties: false
|
|
835
|
+
},
|
|
836
|
+
result: {
|
|
837
|
+
anyOf: [
|
|
838
|
+
workflowCommandOk,
|
|
839
|
+
workflowCommandRefusal
|
|
840
|
+
]
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
'workflow/retry': {
|
|
844
|
+
type: 'request',
|
|
845
|
+
retain: 'ephemeral',
|
|
846
|
+
description: "Ask one peer (directed) to re-arm one failed instance by id. A non-commandable instance is refused identically to an unknown one (`not_found`); the engine's own retry refusals (`not_failed`/`no_next_action`/`singleton`) map through verbatim.",
|
|
847
|
+
param: {
|
|
848
|
+
type: 'object',
|
|
849
|
+
properties: {
|
|
850
|
+
instanceID: {
|
|
851
|
+
type: 'string'
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
required: [
|
|
855
|
+
'instanceID'
|
|
856
|
+
],
|
|
857
|
+
additionalProperties: false
|
|
858
|
+
},
|
|
859
|
+
result: {
|
|
860
|
+
anyOf: [
|
|
861
|
+
workflowCommandOk,
|
|
862
|
+
workflowCommandRefusal
|
|
863
|
+
]
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
});
|
|
615
867
|
/**
|
|
616
868
|
* The app-lane protocols, keyed for `createGroupPeer({ protocols })`. Each key
|
|
617
869
|
* names its own topic, so adding one moves no existing lane.
|
|
@@ -620,3 +872,19 @@ export const peerProtocol = defineGroupProtocol({
|
|
|
620
872
|
sync: syncProtocol,
|
|
621
873
|
peer: peerProtocol
|
|
622
874
|
};
|
|
875
|
+
/**
|
|
876
|
+
* The group protocols with the `workflow` lane conditionally mounted. A FRESH
|
|
877
|
+
* object each call — the shared static {@link groupProtocols} is never mutated.
|
|
878
|
+
* When `hasWorkflow` is false the base map is returned unchanged, so no
|
|
879
|
+
* `workflow` key exists and a gather on that lane routes to nothing.
|
|
880
|
+
*
|
|
881
|
+
* The workflow lane is an extra runtime key served by `createGroupPeer`
|
|
882
|
+
* (`Object.entries(protocols)`), but callers stay typed at the base shape: the
|
|
883
|
+
* manager never statically routes `workflow`, and the peer it holds keeps its
|
|
884
|
+
* base type. The paired handler is composed by `composeGroupHandlers`.
|
|
885
|
+
*/ export function composeGroupProtocols(hasWorkflow) {
|
|
886
|
+
return hasWorkflow ? {
|
|
887
|
+
...groupProtocols,
|
|
888
|
+
workflow: workflowProtocol
|
|
889
|
+
} : groupProtocols;
|
|
890
|
+
}
|
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
import type { GroupHandle } from '@kumiai/mls';
|
|
2
2
|
/**
|
|
3
3
|
* Encrypt an application message for the group. `@kumiai/mls` returns framed MLS
|
|
4
|
-
* wire bytes,
|
|
5
|
-
*
|
|
4
|
+
* wire bytes, put on the wire directly — no JSON envelope, no retired secret
|
|
5
|
+
* material crossing the boundary.
|
|
6
|
+
*
|
|
7
|
+
* `aad` becomes the frame's MLS `authenticated_data`: bound into the AEAD but
|
|
8
|
+
* carried in the clear, so the reader authenticates it before opening. The
|
|
9
|
+
* directed lane passes the send topicID here so a frame sealed for one topic
|
|
10
|
+
* cannot be replayed onto another. Absent (broadcast's fixed topic) it defaults
|
|
11
|
+
* to empty.
|
|
6
12
|
*/
|
|
7
|
-
export declare function mlsEncryptFramed(handle: GroupHandle, plaintext: Uint8Array): Promise<Uint8Array>;
|
|
13
|
+
export declare function mlsEncryptFramed(handle: GroupHandle, plaintext: Uint8Array, aad?: Uint8Array): Promise<Uint8Array>;
|
|
8
14
|
/**
|
|
9
15
|
* Decrypt a received application message and recover WHO SENT IT.
|
|
10
16
|
*
|
|
11
|
-
* `handle.decrypt` rather than `processMessage`, which
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
17
|
+
* `handle.decrypt` rather than `processMessage`, which yields plaintext with no
|
|
18
|
+
* sender. The sender is AUTHENTICATED, not claimed: the leaf index rides
|
|
19
|
+
* sender-data encrypted under the epoch's sender-data secret, and the body opens
|
|
20
|
+
* only under the ratchet key derived at that leaf, so a frame naming a leaf it
|
|
21
|
+
* was not sealed at does not open at all.
|
|
16
22
|
*
|
|
17
23
|
* A missing sender is an ERROR, never a filled-in blank. Upstream types
|
|
18
24
|
* `senderDID` optional because the authenticated leaf may hold no parsable
|
|
19
25
|
* credential — "I cannot name the author", never "there is no author". Any
|
|
20
|
-
* placeholder
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
26
|
+
* placeholder would hand callers an unauthenticated value wearing an
|
|
27
|
+
* authenticated one's type, so the open fails instead. Callers already treat a
|
|
28
|
+
* throw here as ordinary control flow: `decrypt` throws for every epoch but its
|
|
29
|
+
* own.
|
|
24
30
|
*/
|
|
25
|
-
export declare function mlsDecryptFramed(handle: GroupHandle, framed: Uint8Array): Promise<{
|
|
31
|
+
export declare function mlsDecryptFramed(handle: GroupHandle, framed: Uint8Array, expectedAAD?: Uint8Array): Promise<{
|
|
26
32
|
payload: Uint8Array;
|
|
27
33
|
senderDID: string;
|
|
28
34
|
}>;
|
package/lib/groups/mls-codec.js
CHANGED
|
@@ -1,28 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Encrypt an application message for the group. `@kumiai/mls` returns framed MLS
|
|
3
|
-
* wire bytes,
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
* wire bytes, put on the wire directly — no JSON envelope, no retired secret
|
|
4
|
+
* material crossing the boundary.
|
|
5
|
+
*
|
|
6
|
+
* `aad` becomes the frame's MLS `authenticated_data`: bound into the AEAD but
|
|
7
|
+
* carried in the clear, so the reader authenticates it before opening. The
|
|
8
|
+
* directed lane passes the send topicID here so a frame sealed for one topic
|
|
9
|
+
* cannot be replayed onto another. Absent (broadcast's fixed topic) it defaults
|
|
10
|
+
* to empty.
|
|
11
|
+
*/ export async function mlsEncryptFramed(handle, plaintext, aad) {
|
|
12
|
+
return await handle.encrypt(plaintext, {
|
|
13
|
+
aad
|
|
14
|
+
});
|
|
7
15
|
}
|
|
8
16
|
/**
|
|
9
17
|
* Decrypt a received application message and recover WHO SENT IT.
|
|
10
18
|
*
|
|
11
|
-
* `handle.decrypt` rather than `processMessage`, which
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
19
|
+
* `handle.decrypt` rather than `processMessage`, which yields plaintext with no
|
|
20
|
+
* sender. The sender is AUTHENTICATED, not claimed: the leaf index rides
|
|
21
|
+
* sender-data encrypted under the epoch's sender-data secret, and the body opens
|
|
22
|
+
* only under the ratchet key derived at that leaf, so a frame naming a leaf it
|
|
23
|
+
* was not sealed at does not open at all.
|
|
16
24
|
*
|
|
17
25
|
* A missing sender is an ERROR, never a filled-in blank. Upstream types
|
|
18
26
|
* `senderDID` optional because the authenticated leaf may hold no parsable
|
|
19
27
|
* credential — "I cannot name the author", never "there is no author". Any
|
|
20
|
-
* placeholder
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*/ export async function mlsDecryptFramed(handle, framed) {
|
|
25
|
-
|
|
28
|
+
* placeholder would hand callers an unauthenticated value wearing an
|
|
29
|
+
* authenticated one's type, so the open fails instead. Callers already treat a
|
|
30
|
+
* throw here as ordinary control flow: `decrypt` throws for every epoch but its
|
|
31
|
+
* own.
|
|
32
|
+
*/ export async function mlsDecryptFramed(handle, framed, expectedAAD) {
|
|
33
|
+
// `expectedAAD` is compared against the frame's cleartext authenticated_data
|
|
34
|
+
// PRE-open, so a wrong-topic frame is refused without spending a ratchet
|
|
35
|
+
// generation — same rejection path as any frame this handle cannot open.
|
|
36
|
+
const { payload, senderDID } = await handle.decrypt(framed, {
|
|
37
|
+
expectedAAD
|
|
38
|
+
});
|
|
26
39
|
if (senderDID == null) {
|
|
27
40
|
throw new Error('MLS application message opened at a leaf with no nameable sender');
|
|
28
41
|
}
|
|
@@ -15,6 +15,12 @@ export type PeerPresenceParams = {
|
|
|
15
15
|
localDID: string;
|
|
16
16
|
/** Device-wide clock. Every announcement is stamped from it and never by a caller. */
|
|
17
17
|
hlc: HLC;
|
|
18
|
+
/**
|
|
19
|
+
* The engine's identity gate. An announce is a self-started mint, so it awaits
|
|
20
|
+
* this before stamping — a settled no-op once the engine is verified. Absent
|
|
21
|
+
* for a by-hand test whose clock has no gate to wait on.
|
|
22
|
+
*/
|
|
23
|
+
ready?: () => Promise<void>;
|
|
18
24
|
/** The epoch stamped onto this device's own row, as the apply path stamps a received one. */
|
|
19
25
|
getGroupEpoch: (groupID: string) => number | undefined;
|
|
20
26
|
/** Fan one broadcast out across every live peer of the group. */
|
|
@@ -84,6 +84,11 @@ export function createPeerPresence(params) {
|
|
|
84
84
|
};
|
|
85
85
|
};
|
|
86
86
|
const announceProfile = async (groupID, profile)=>{
|
|
87
|
+
// An announce is a self-started mint (an epoch advance or hub lifecycle
|
|
88
|
+
// event drives it, off the request path), so it awaits the engine's identity
|
|
89
|
+
// gate before the stamp below. Placed before any store read, so no group lock
|
|
90
|
+
// or transaction is held across the wait.
|
|
91
|
+
await params.ready?.();
|
|
87
92
|
// Stamped once for both the frame and the local row, and never taken from the
|
|
88
93
|
// caller: the anchor decides which of two announcements from this device wins
|
|
89
94
|
// on every receiver, so it has to come from the device's one clock — and the
|
package/lib/hub/hub-like.js
CHANGED
|
@@ -386,16 +386,18 @@ function rethrowHubError(error) {
|
|
|
386
386
|
this.#logger?.debug('hub-like open reached no hub', {
|
|
387
387
|
topics: topics.length
|
|
388
388
|
});
|
|
389
|
-
throw first
|
|
389
|
+
throw first?.reason ?? new Error('hub did not answer any subscribe');
|
|
390
390
|
}
|
|
391
391
|
this.#proven = true;
|
|
392
392
|
const failed = [];
|
|
393
|
-
for(
|
|
394
|
-
const result = results[i];
|
|
393
|
+
for (const [i, result] of results.entries()){
|
|
395
394
|
if (result.status !== 'rejected') {
|
|
396
395
|
continue;
|
|
397
396
|
}
|
|
398
397
|
const topicID = topics[i];
|
|
398
|
+
if (topicID == null) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
399
401
|
const error = asHubError(result.reason);
|
|
400
402
|
this.#logger?.warn('hub-like re-subscribe failed', {
|
|
401
403
|
topicID,
|
|
@@ -444,10 +446,9 @@ function rethrowHubError(error) {
|
|
|
444
446
|
}
|
|
445
447
|
})));
|
|
446
448
|
const stillFailing = [];
|
|
447
|
-
for(
|
|
448
|
-
const result = results[i];
|
|
449
|
+
for (const [i, result] of results.entries()){
|
|
449
450
|
const topicID = pending[i];
|
|
450
|
-
if (result.status !== 'rejected') {
|
|
451
|
+
if (topicID == null || result.status !== 'rejected') {
|
|
451
452
|
continue;
|
|
452
453
|
}
|
|
453
454
|
if (isPermanentSubscribeFailure(asHubError(result.reason))) {
|
|
@@ -51,7 +51,10 @@ import { createMemoryStore } from '@kumiai/hub-server';
|
|
|
51
51
|
set.add(subscriberDID);
|
|
52
52
|
},
|
|
53
53
|
async unsubscribe (subscriberDID, topicID) {
|
|
54
|
-
await store.unsubscribe(
|
|
54
|
+
await store.unsubscribe({
|
|
55
|
+
subscriberDID,
|
|
56
|
+
topicID
|
|
57
|
+
});
|
|
55
58
|
topicSubscribers.get(topicID)?.delete(subscriberDID);
|
|
56
59
|
},
|
|
57
60
|
receive (subscriberDID) {
|
package/lib/hub/wiring.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { KubunDB } from '@kubun/db';
|
|
|
5
5
|
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
6
6
|
import type { HLC } from '@kubun/hlc';
|
|
7
7
|
import type { Logger } from '@kubun/logger';
|
|
8
|
+
import type { WorkflowDiscoverOptions, WorkflowDiscoverReply } from '@kubun/plugin-p2p-api';
|
|
8
9
|
import type { ServiceConfig, ServiceProtocol } from '@kubun/plugin-service-api';
|
|
9
10
|
import type { LaneResult, PendingCommit } from '@kumiai/rpc';
|
|
10
11
|
import type { Runtime } from '@sozai/runtime';
|
|
@@ -12,7 +13,8 @@ import type { GroupBroadcastMessage } from '../groups/broadcast-message.js';
|
|
|
12
13
|
import type { ControllerResolverFor } from '../groups/credential-wrapping-deps.js';
|
|
13
14
|
import type { P2PEventEmitter } from '../groups/events.js';
|
|
14
15
|
import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
|
|
15
|
-
import type {
|
|
16
|
+
import type { WorkflowCommandAPI } from '../groups/group-handlers.js';
|
|
17
|
+
import type { BuildLedgerRedrive, WorkflowRequestMethod } from '../groups/group-peer-manager.js';
|
|
16
18
|
import type { PeerPresence } from '../groups/peer-presence.js';
|
|
17
19
|
import type { SyncProtocol } from '../protocol.js';
|
|
18
20
|
import type { ForwardingConfig } from '../sync/forwarder.js';
|
|
@@ -32,11 +34,10 @@ export type HubWiring = {
|
|
|
32
34
|
broadcastNow: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
|
|
33
35
|
/**
|
|
34
36
|
* Drive a commit through the group's canonical commit hub and await its
|
|
35
|
-
* outcome. Wired to
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* rejects only when the group has no lane at all.
|
|
37
|
+
* outcome. Wired to `GroupPeerManager.commit`: builds against the live handle,
|
|
38
|
+
* publishes to the commit log, adopts the advance only on acceptance. A group
|
|
39
|
+
* with no hub bound commits against its in-process loopback log, so this rejects
|
|
40
|
+
* only when the group has no lane at all.
|
|
40
41
|
*/
|
|
41
42
|
commitToGroup: (groupID: string, build: () => Promise<PendingCommit>) => Promise<LaneResult>;
|
|
42
43
|
/**
|
|
@@ -60,6 +61,12 @@ export type HubWiring = {
|
|
|
60
61
|
presence: () => Promise<PeerPresence>;
|
|
61
62
|
/** See {@link GroupPeerManager.retryHubs}. */
|
|
62
63
|
retryHubs: () => Promise<boolean>;
|
|
64
|
+
/** See {@link GroupPeerManager.discoverWorkflows}. */
|
|
65
|
+
discoverWorkflows: (groupID: string, options?: WorkflowDiscoverOptions) => Promise<Array<WorkflowDiscoverReply>>;
|
|
66
|
+
/** See {@link GroupPeerManager.requestWorkflow}. */
|
|
67
|
+
requestWorkflow: (groupID: string, targetDID: string, method: WorkflowRequestMethod, param?: Record<string, unknown>) => Promise<unknown>;
|
|
68
|
+
/** See {@link GroupPeerManager.reauthorize}. Awaits the live manager, then re-drives refused subscribes. */
|
|
69
|
+
reauthorize: (groupID: string) => Promise<void>;
|
|
63
70
|
/**
|
|
64
71
|
* Build the transport for one directed sync session to a co-member, relayed by
|
|
65
72
|
* the group's hub. `undefined` when the group has no hub bound — there is no
|
|
@@ -118,6 +125,22 @@ export type SetupHubRelayParams = {
|
|
|
118
125
|
* engine's single instance so roster writes share the device's one clock.
|
|
119
126
|
*/
|
|
120
127
|
hlc: HLC;
|
|
128
|
+
/**
|
|
129
|
+
* The engine's identity gate, forwarded to the presence coordinator so a
|
|
130
|
+
* self-started announce awaits verification before it mints under `hlc`.
|
|
131
|
+
* Optional: a suite that builds the relay by hand omits it and the announce
|
|
132
|
+
* mints immediately (its by-hand HLC has no gate to wait on).
|
|
133
|
+
*/
|
|
134
|
+
ready?: () => Promise<void>;
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the device's workflow API when the workflow plugin is loaded,
|
|
137
|
+
* forwarded to the group peer manager so it mounts the `workflow` namespace on
|
|
138
|
+
* each hub peer. A thunk, not the resolved value: this is called during the
|
|
139
|
+
* synchronous plugin-factory body, before the plugin defines its own
|
|
140
|
+
* `getWorkflowAPI` — invoking the thunk is deferred to peer creation, well
|
|
141
|
+
* after the factory returns, so the reference is live by then.
|
|
142
|
+
*/
|
|
143
|
+
getWorkflowAPI?: () => Promise<WorkflowCommandAPI | undefined>;
|
|
121
144
|
/**
|
|
122
145
|
* The engine's future-drift bound, forwarded to the apply path so a peer's
|
|
123
146
|
* stamp is bounded there the same way the graph lane bounds a mutation's.
|
package/lib/hub/wiring.js
CHANGED
|
@@ -44,6 +44,12 @@ export function setupHubRelay(params) {
|
|
|
44
44
|
};
|
|
45
45
|
const presence = async ()=>(await ready).presence;
|
|
46
46
|
const retryHubs = async ()=>await (await ready).retryHubs();
|
|
47
|
+
const discoverWorkflows = async (groupID, options)=>await (await ready).discoverWorkflows(groupID, options);
|
|
48
|
+
const requestWorkflow = async (groupID, targetDID, method, param)=>await (await ready).requestWorkflow(groupID, targetDID, method, param);
|
|
49
|
+
const reauthorize = async (groupID)=>{
|
|
50
|
+
;
|
|
51
|
+
(await ready).reauthorize(groupID);
|
|
52
|
+
};
|
|
47
53
|
const syncTransportTo = async (groupID, peerDID)=>{
|
|
48
54
|
const hub = (await ready).tunnelHub(groupID);
|
|
49
55
|
if (hub == null) {
|
|
@@ -85,6 +91,12 @@ export function setupHubRelay(params) {
|
|
|
85
91
|
graphStore,
|
|
86
92
|
graph,
|
|
87
93
|
hlc,
|
|
94
|
+
...params.ready != null ? {
|
|
95
|
+
ready: params.ready
|
|
96
|
+
} : {},
|
|
97
|
+
...params.getWorkflowAPI != null ? {
|
|
98
|
+
getWorkflowAPI: params.getWorkflowAPI
|
|
99
|
+
} : {},
|
|
88
100
|
maxDriftMS,
|
|
89
101
|
localDID: identity.id,
|
|
90
102
|
identity,
|
|
@@ -187,6 +199,9 @@ export function setupHubRelay(params) {
|
|
|
187
199
|
requestLedgerCatchup,
|
|
188
200
|
presence,
|
|
189
201
|
retryHubs,
|
|
202
|
+
discoverWorkflows,
|
|
203
|
+
requestWorkflow,
|
|
204
|
+
reauthorize,
|
|
190
205
|
syncTransportTo,
|
|
191
206
|
serviceTransportTo,
|
|
192
207
|
dispose: async ()=>{
|