@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.
@@ -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 what each responder's rules are
46
- // right now, and one that outlived its window would be answering for a
47
- // policy that may since have been rewritten. The durable half of this lane
48
- // is `control/accessDefaultSet`, which this repairs rather than replaces.
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 this is the load-bearing half of the design. A grant is an
255
- // explicit one-shot push with no background replication and no catch-up
256
- // procedure behind it: ephemeral, a recipient that was offline at grant time
257
- // silently never gets access, and the only repair is a human granting again.
258
- // Retention is what the app lane actually delivers a device away across a
259
- // roster change still reads the epoch's app topic on its way past it — and it
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, so kubun puts those on the wire directly — no JSON envelope of its
5
- * own, and no retired secret material crossing the boundary.
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 returns an application
12
- * message's plaintext with no sender. The sender is AUTHENTICATED, not claimed:
13
- * the leaf index rides sender-data encrypted under the epoch's sender-data
14
- * secret, and the body opens only under the ratchet key derived at that leaf, so
15
- * a frame naming a leaf it was not sealed at does not open at all.
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 (empty string, a transport-claimed DID) would hand callers an
21
- * unauthenticated value wearing an authenticated one's type, so the open fails
22
- * instead. Callers already treat a throw here as ordinary control flow:
23
- * `decrypt` throws for every epoch but its own.
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
  }>;
@@ -1,28 +1,41 @@
1
1
  /**
2
2
  * Encrypt an application message for the group. `@kumiai/mls` returns framed MLS
3
- * wire bytes, so kubun puts those on the wire directly — no JSON envelope of its
4
- * own, and no retired secret material crossing the boundary.
5
- */ export async function mlsEncryptFramed(handle, plaintext) {
6
- return await handle.encrypt(plaintext);
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 returns an application
12
- * message's plaintext with no sender. The sender is AUTHENTICATED, not claimed:
13
- * the leaf index rides sender-data encrypted under the epoch's sender-data
14
- * secret, and the body opens only under the ratchet key derived at that leaf, so
15
- * a frame naming a leaf it was not sealed at does not open at all.
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 (empty string, a transport-claimed DID) would hand callers an
21
- * unauthenticated value wearing an authenticated one's type, so the open fails
22
- * instead. Callers already treat a throw here as ordinary control flow:
23
- * `decrypt` throws for every epoch but its own.
24
- */ export async function mlsDecryptFramed(handle, framed) {
25
- const { payload, senderDID } = await handle.decrypt(framed);
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
@@ -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.reason ?? new Error('hub did not answer any subscribe');
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(let i = 0; i < results.length; i++){
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(let i = 0; i < results.length; i++){
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(subscriberDID, topicID);
54
+ await store.unsubscribe({
55
+ subscriberDID,
56
+ topicID
57
+ });
55
58
  topicSubscribers.get(topicID)?.delete(subscriberDID);
56
59
  },
57
60
  receive (subscriberDID) {
@@ -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 { BuildLedgerRedrive } from '../groups/group-peer-manager.js';
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
- * `GroupPeerManager.commit`. The lane builds against the live handle,
37
- * publishes to the commit log, and adopts the advance only on acceptance. A
38
- * group with no hub bound commits against its in-process loopback log, so this
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 ()=>{