@kubun/plugin-p2p 0.15.1 → 0.16.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/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/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/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 +46 -45
|
@@ -6,26 +6,25 @@ import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
|
6
6
|
* port: epoch, an epoch-bound topic-derivation secret, and byte-level
|
|
7
7
|
* encrypt/decrypt over the live MLS handle.
|
|
8
8
|
*
|
|
9
|
-
* `wrap`/`unwrap`/`exportSecret` route through `registry.readHandle`,
|
|
10
|
-
* per-group mutex
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
9
|
+
* `wrap`/`unwrap`/`exportSecret` route through `registry.readHandle`, under the
|
|
10
|
+
* per-group mutex, framed as in `mls-codec.ts`. `exportSecret` passes the
|
|
11
|
+
* caller's label through untouched: the labels belong to the package deriving
|
|
12
|
+
* topics from the result, so substituting one moves every topic ID away from
|
|
13
|
+
* what the caller addressed.
|
|
14
14
|
*
|
|
15
15
|
* `sealEntries`/`openEntries` are a SECOND seal, NOT interchangeable with
|
|
16
16
|
* `wrap`/`unwrap`: those consume a ratchet generation and mutate the handle, so
|
|
17
17
|
* they cannot serve an open running inside the apply of the commit carrying the
|
|
18
18
|
* blob. The bytes must match `@kumiai/mls-rpc` exactly — the seal is agreed
|
|
19
|
-
* without exchange, so a divergence
|
|
20
|
-
*
|
|
19
|
+
* without exchange, so a divergence silently stops members reading each other's
|
|
20
|
+
* commits rather than surfacing as a decode error.
|
|
21
21
|
*
|
|
22
22
|
* The epoch is READ FROM THE REGISTRY, never cached: applying someone else's
|
|
23
23
|
* commit advances the handle without touching this file, and a stale number
|
|
24
24
|
* classifies every later frame as `ahead`, so the cursor skips it forever.
|
|
25
25
|
* `initialEpoch` covers only construction before the registry has observed a
|
|
26
26
|
* handle — the caller MUST read the handle's epoch first, or a returning peer
|
|
27
|
-
* answering `0` steps over every commit it missed.
|
|
28
|
-
* and eager, leaving no window to prime it afterwards.
|
|
27
|
+
* answering `0` steps over every commit it missed.
|
|
29
28
|
*/
|
|
30
29
|
export type GroupCryptoParams = {
|
|
31
30
|
registry: GroupHandleRegistry;
|
|
@@ -10,20 +10,19 @@ import { mlsDecryptFramed, mlsEncryptFramed } from './mls-codec.js';
|
|
|
10
10
|
// `@kumiai/mls-rpc` uses for the ledger-entry seal (`new Uint8Array()`), which is
|
|
11
11
|
// what lets a blob sealed here open there.
|
|
12
12
|
const TOPIC_SECRET_CONTEXT = new Uint8Array(0);
|
|
13
|
-
// Default when a caller names no length. 32
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// the ciphersuite instead would return 48 on SHA-384 and break every seal.
|
|
13
|
+
// Default when a caller names no length. 32 feeds the HKDF topic derivation, is
|
|
14
|
+
// XChaCha20-Poly1305's key size, and is what the ledger-entry seal exports at.
|
|
15
|
+
// Fixed, not ciphersuite-derived: RFC 9420 §8.5 binds the length into the
|
|
16
|
+
// exporter's KDFLabel, so 48 on SHA-384 would break every seal.
|
|
18
17
|
const DEFAULT_SECRET_LENGTH = 32;
|
|
19
18
|
// XChaCha20-Poly1305's nonce, carried in the clear ahead of the ciphertext.
|
|
20
19
|
const ENTRY_NONCE_BYTES = 24;
|
|
21
20
|
// Sealed blob format version, first byte, in the clear:
|
|
22
21
|
// [ VERSION(1) | NONCE(24) | CIPHERTEXT ]
|
|
23
|
-
// Unauthenticated by necessity —
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
22
|
+
// Unauthenticated by necessity — read to decide how to open, so it cannot sit
|
|
23
|
+
// under the seal. Buys diagnosis, not compatibility: a v2 blob fails a v1 reader
|
|
24
|
+
// either way, but reads as "unsupported version" rather than an AEAD refusal
|
|
25
|
+
// indistinguishable from a wrong epoch or a tampered frame.
|
|
27
26
|
const ENTRY_VERSION = 1;
|
|
28
27
|
export function createGroupCrypto(params) {
|
|
29
28
|
const { registry, groupID, initialEpoch, runtime = createRuntime() } = params;
|
|
@@ -34,11 +33,10 @@ export function createGroupCrypto(params) {
|
|
|
34
33
|
return await mlsExporter(handle.state.keySchedule.exporterSecret, ENTRY_SEAL_LABEL, TOPIC_SECRET_CONTEXT, DEFAULT_SECRET_LENGTH, handle.context.cipherSuite);
|
|
35
34
|
});
|
|
36
35
|
const exportSecret = (label, length)=>{
|
|
37
|
-
// Reusing the ledger-entry label
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// group's control ledger. Refused here rather than left to the doc.
|
|
36
|
+
// Reusing the ledger-entry label is not an independent export: it is the
|
|
37
|
+
// exact exporter call `sealEntries`/`openEntries` make (same context, same
|
|
38
|
+
// length), so it would hand back the seal key under another name and make
|
|
39
|
+
// every holder of a topic secret a reader of the control ledger.
|
|
42
40
|
if (label === ENTRY_SEAL_LABEL) {
|
|
43
41
|
throw new Error(`exportSecret: label '${label}' is reserved for the ledger-entry seal`);
|
|
44
42
|
}
|
|
@@ -51,52 +49,50 @@ export function createGroupCrypto(params) {
|
|
|
51
49
|
// MLS state outside the per-group mutex. `initialEpoch` only covers the window
|
|
52
50
|
// before the registry has observed a handle for this group.
|
|
53
51
|
const epoch = ()=>registry.groupEpoch(groupID) ?? initialEpoch;
|
|
54
|
-
const wrap = (bytes)=>registry.readHandle(groupID, (handle)=>mlsEncryptFramed(handle, bytes));
|
|
52
|
+
const wrap = (bytes, opts)=>registry.readHandle(groupID, (handle)=>mlsEncryptFramed(handle, bytes, opts?.aad));
|
|
55
53
|
// In-flight decrypt cache, keyed by the ciphertext bytes.
|
|
56
54
|
//
|
|
57
|
-
// group-rpc subscribes TWO broadcast transports per app-protocol topic
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// settled, so the map holds only concurrent in-flight decrypts and never an
|
|
66
|
-
// evicted pending one; distinct ciphertexts miss and decrypt normally, so
|
|
67
|
-
// forward secrecy across messages is preserved.
|
|
55
|
+
// group-rpc subscribes TWO broadcast transports per app-protocol topic and the
|
|
56
|
+
// hub mux fans every frame to both, so `unwrap` runs twice in one synchronous
|
|
57
|
+
// burst. MLS decryption is single-use: `handle.decrypt` consumes a
|
|
58
|
+
// forward-secret ratchet generation, so the second call fails and the frame
|
|
59
|
+
// never reaches its handler. Caching the in-flight promise per ciphertext makes
|
|
60
|
+
// `unwrap` idempotent — the sibling replays the plaintext instead. Entries drop
|
|
61
|
+
// once settled, so only concurrent in-flight decrypts are held; distinct
|
|
62
|
+
// ciphertexts miss and decrypt normally, preserving forward secrecy.
|
|
68
63
|
//
|
|
69
|
-
// The replayed value carries an IDENTITY, not just bytes.
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// anyone but the leaf that sealed it.
|
|
64
|
+
// The replayed value carries an IDENTITY, not just bytes. Sound only because
|
|
65
|
+
// the key is the ciphertext: one ciphertext at one epoch has exactly one
|
|
66
|
+
// MLS-authenticated sender, so a replay cannot misattribute the frame.
|
|
73
67
|
const unwrapCache = new Map();
|
|
74
|
-
const unwrap = (bytes)=>{
|
|
75
|
-
const
|
|
68
|
+
const unwrap = (bytes, opts)=>{
|
|
69
|
+
const expectedAAD = opts?.expectedAAD;
|
|
70
|
+
// Key on ciphertext AND expectedAAD: both siblings of the dedup'd frame carry
|
|
71
|
+
// the same expectedAAD, so they still collapse to one decrypt. Keying on the
|
|
72
|
+
// bytes alone would let the same ciphertext on a DIFFERENT topic reuse the
|
|
73
|
+
// first success, defeating the wrong-topic check the binding exists for.
|
|
74
|
+
const key = `${toB64(bytes)}:${expectedAAD == null ? '' : toB64(expectedAAD)}`;
|
|
76
75
|
const cached = unwrapCache.get(key);
|
|
77
76
|
if (cached != null) {
|
|
78
77
|
return cached;
|
|
79
78
|
}
|
|
80
79
|
const promise = registry.readHandle(groupID, async (handle)=>{
|
|
81
|
-
const { payload, senderDID } = await mlsDecryptFramed(handle, bytes);
|
|
80
|
+
const { payload, senderDID } = await mlsDecryptFramed(handle, bytes, expectedAAD);
|
|
82
81
|
return {
|
|
83
82
|
payload,
|
|
84
83
|
senderDID
|
|
85
84
|
};
|
|
86
85
|
});
|
|
87
|
-
// Drop the entry once it settles
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// decrypt afresh.
|
|
86
|
+
// Drop the entry once it settles: the sibling has already taken the in-flight
|
|
87
|
+
// promise, and a genuine failure must not be cached so a later valid frame
|
|
88
|
+
// with the same bytes (e.g. after a resync) can decrypt afresh.
|
|
91
89
|
//
|
|
92
90
|
// `.finally()` returns a NEW promise that rejects with the same reason, so
|
|
93
|
-
// this cleanup branch needs
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// returned below still rejects to the caller, which is what decides the frame
|
|
99
|
-
// is dead.
|
|
91
|
+
// this cleanup branch needs its own handler or it escapes as an unhandled
|
|
92
|
+
// rejection — and a frame this handle cannot open (another epoch's, another
|
|
93
|
+
// group's) is ordinary on a shared log, so rejection is the common path. Only
|
|
94
|
+
// this branch is absorbed; the promise returned below still rejects to the
|
|
95
|
+
// caller, which is what decides the frame is dead.
|
|
100
96
|
unwrapCache.set(key, promise);
|
|
101
97
|
void promise.finally(()=>{
|
|
102
98
|
unwrapCache.delete(key);
|
|
@@ -104,23 +100,23 @@ export function createGroupCrypto(params) {
|
|
|
104
100
|
return promise;
|
|
105
101
|
};
|
|
106
102
|
// Reads the epoch every MLSMessage carries in its own cleartext. Needs no
|
|
107
|
-
// handle
|
|
108
|
-
// holds, most
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
103
|
+
// handle or key and must never throw: it is asked about every frame a log
|
|
104
|
+
// holds, most not this member's to open. `null` for anything ts-mls will not
|
|
105
|
+
// decode, which lets a caller tell a frame sealed AHEAD (openable once it
|
|
106
|
+
// catches up) from one sealed BELOW (gone forever, since MLS ratchets forward)
|
|
107
|
+
// — the distinction the app lane's durable cursor rests on. Untrusted: the
|
|
108
|
+
// publisher's word via an untrusted hub, so it decides what to TRY, never that
|
|
109
|
+
// bytes are authentic.
|
|
114
110
|
const frameEpoch = (bytes)=>{
|
|
115
111
|
const epochValue = readMessageEpoch(bytes);
|
|
116
112
|
return epochValue == null ? null : Number(epochValue);
|
|
117
113
|
};
|
|
118
114
|
// Seals the ledger-entry blob a Commit carries, under a key derived from this
|
|
119
115
|
// epoch's exporter secret rather than the message ratchet `wrap` consumes.
|
|
120
|
-
// Derived-key sealing
|
|
121
|
-
// the
|
|
122
|
-
//
|
|
123
|
-
//
|
|
116
|
+
// Derived-key sealing makes the open PURE, so it can run inside the apply of
|
|
117
|
+
// the very commit whose blob it opens — the only place it runs, which the
|
|
118
|
+
// ratchet-backed pair cannot serve. Byte format is fixed by `@kumiai/mls-rpc`;
|
|
119
|
+
// a one-byte drift silently partitions the group.
|
|
124
120
|
const sealEntries = async (bytes)=>{
|
|
125
121
|
const key = await exportEntryKey();
|
|
126
122
|
// Random per seal: two members can frame a commit at the same epoch, and a
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ProcedureHandlers } from '@enkaku/server';
|
|
2
|
+
import type { WorkflowAPI } from '@kubun/plugin-workflow-api';
|
|
2
3
|
import type { ProcessBroadcastParams } from './broadcast.js';
|
|
3
|
-
import type { ControlProtocol, PeerProtocol, SyncProtocol } from './group-protocols.js';
|
|
4
|
+
import type { ControlProtocol, PeerProtocol, SyncProtocol, WorkflowProtocol } from './group-protocols.js';
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* Per-group handler maps wired into `createGroupPeer`, keyed to
|
|
7
|
+
* {@link groupProtocols} so the peer dispatches each decoded procedure payload
|
|
7
8
|
* to the matching handler.
|
|
8
9
|
*/
|
|
9
10
|
export type GroupHandlers = {
|
|
@@ -11,20 +12,55 @@ export type GroupHandlers = {
|
|
|
11
12
|
sync: ProcedureHandlers<SyncProtocol>;
|
|
12
13
|
peer: ProcedureHandlers<PeerProtocol>;
|
|
13
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* The subset of the workflow API the observe handlers read: enumerate this
|
|
17
|
+
* device's instances (`list`), resolve one by id (`get`), and test each name for
|
|
18
|
+
* remote-observe opt-in. Threaded live via `engine.getAPI('workflow')`, so the
|
|
19
|
+
* protocol layer keeps its type-only dependency on `@kubun/plugin-workflow-api`.
|
|
20
|
+
* `get` resolves `workflow/status` in one lookup rather than scanning `list()`.
|
|
21
|
+
*/
|
|
22
|
+
export type WorkflowDiscoverAPI = Pick<WorkflowAPI, 'list' | 'get' | 'isRemotelyObservable'>;
|
|
23
|
+
/**
|
|
24
|
+
* The observe subset plus the three directed COMMAND methods and the command
|
|
25
|
+
* opt-in predicate. `buildWorkflowHandlers` serves all six `workflow` procedures
|
|
26
|
+
* off one map, so it takes this superset; threaded live the same way, so the
|
|
27
|
+
* protocol layer keeps its type-only dependency on `@kubun/plugin-workflow-api`.
|
|
28
|
+
*/
|
|
29
|
+
export type WorkflowCommandAPI = WorkflowDiscoverAPI & Pick<WorkflowAPI, 'enqueue' | 'cancel' | 'retry' | 'isRemotelyCommandable'>;
|
|
30
|
+
/**
|
|
31
|
+
* The `workflow/discover` handler for one group. Enumerates this device's own
|
|
32
|
+
* instances and returns ONLY those whose workflow opted into remote observation.
|
|
33
|
+
* The reply is a bare `InstanceStatus[]`: the gather envelope carries the
|
|
34
|
+
* authenticated `senderDID`, so the body attributes nothing — exactly as
|
|
35
|
+
* `peer/query` returns its bare announcement.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildWorkflowHandlers(api: WorkflowCommandAPI): {
|
|
38
|
+
workflow: ProcedureHandlers<WorkflowProtocol>;
|
|
39
|
+
};
|
|
14
40
|
/**
|
|
15
41
|
* Build the control + sync procedure handlers for a single group.
|
|
16
42
|
*
|
|
17
|
-
* Each handler is a thin adapter: it maps the typed group-rpc
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* the handler owns only the shape translation.
|
|
43
|
+
* Each handler is a thin adapter: it maps the typed group-rpc payload back to the
|
|
44
|
+
* {@link GroupBroadcastMessage} the pure {@link processBroadcast} apply logic
|
|
45
|
+
* understands, re-adding the `groupID` group-rpc strips from the wire (each topic
|
|
46
|
+
* already encodes the group). The apply logic owns verification, conflict
|
|
47
|
+
* resolution, and storage; the handler owns only shape translation.
|
|
23
48
|
*
|
|
24
|
-
* Event handlers are fire-and-forget. `processBroadcast`
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
49
|
+
* Event handlers are fire-and-forget. `processBroadcast` drops an unverifiable or
|
|
50
|
+
* malformed payload and returns `{ applied: false }` rather than throwing, so the
|
|
51
|
+
* result is ignored. A rethrow (or unexpected store fault) is caught and logged
|
|
52
|
+
* here so a single bad frame never escapes the receive loop as an unhandled
|
|
53
|
+
* rejection.
|
|
29
54
|
*/
|
|
30
55
|
export declare function buildGroupHandlers(params: ProcessBroadcastParams, groupID: string): GroupHandlers;
|
|
56
|
+
/**
|
|
57
|
+
* The group handlers with the `workflow` observe + command handlers
|
|
58
|
+
* conditionally added, paired with {@link composeGroupProtocols}. A FRESH object
|
|
59
|
+
* — the base handlers are never mutated. Absent api the base is returned
|
|
60
|
+
* unchanged, so the workflow lane has no handler (and no protocol was mounted
|
|
61
|
+
* either).
|
|
62
|
+
*
|
|
63
|
+
* Returns the base type: the extra handlers are served by name at runtime, but
|
|
64
|
+
* the peer stays typed at the base shape the manager routes.
|
|
65
|
+
*/
|
|
66
|
+
export declare function composeGroupHandlers(base: GroupHandlers, api: WorkflowCommandAPI | undefined): GroupHandlers;
|
|
@@ -1,30 +1,182 @@
|
|
|
1
|
+
import { RETRY_REFUSAL_REASONS } from '@kubun/plugin-workflow-api';
|
|
1
2
|
import { signAccessDefaultSet } from './access-default-token.js';
|
|
2
3
|
import { processBroadcast } from './broadcast.js';
|
|
3
4
|
/**
|
|
4
5
|
* Why this device is not answering a `peer/query`.
|
|
5
6
|
*
|
|
6
7
|
* Declining is a THROW because that is the only silence the lane offers: the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* reports, not of what crosses the hub.
|
|
8
|
+
* responder writes a reply for whatever a handler returns, and the gather drops
|
|
9
|
+
* errored replies and keeps the rest. So a decline bounds the RESULT, not the
|
|
10
|
+
* traffic — every member still publishes one reply frame, and "only matching
|
|
11
|
+
* devices reply" is true of what a gather reports, not of what crosses the hub.
|
|
12
12
|
*/ class PeerQueryDeclined extends Error {
|
|
13
13
|
constructor(reason){
|
|
14
14
|
super(`peer/query declined: ${reason}`);
|
|
15
15
|
this.name = 'PeerQueryDeclined';
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
// The engine's own retry refusals, recovered structurally from the thrown
|
|
19
|
+
// WorkflowRetryError: catching a runtime instance would need a value import the
|
|
20
|
+
// type-only boundary forbids. Sourced from the api package's
|
|
21
|
+
// RETRY_REFUSAL_REASONS so it cannot drift; a recognized reason maps through
|
|
22
|
+
// verbatim, anything else is a genuine fault and is rethrown (→ generic
|
|
23
|
+
// HANDLER_ERROR).
|
|
24
|
+
const KNOWN_RETRY_REFUSAL_REASONS = new Set(RETRY_REFUSAL_REASONS);
|
|
25
|
+
function retryRefusalReason(error) {
|
|
26
|
+
const reason = error?.reason;
|
|
27
|
+
return typeof reason === 'string' && KNOWN_RETRY_REFUSAL_REASONS.has(reason) ? reason : undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A `workflow/status` refusal. One error for BOTH an unknown id and a
|
|
31
|
+
* non-observable instance, so the caller cannot tell them apart — the throw
|
|
32
|
+
* surfaces to `.to()` as the enkaku-generic handler-error pair regardless of
|
|
33
|
+
* what was thrown, so the two cases are already wire-indistinguishable.
|
|
34
|
+
*/ class WorkflowInstanceNotObservable extends Error {
|
|
35
|
+
constructor(){
|
|
36
|
+
super('workflow instance not found');
|
|
37
|
+
this.name = 'WorkflowInstanceNotObservable';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The `workflow/discover` handler for one group. Enumerates this device's own
|
|
42
|
+
* instances and returns ONLY those whose workflow opted into remote observation.
|
|
43
|
+
* The reply is a bare `InstanceStatus[]`: the gather envelope carries the
|
|
44
|
+
* authenticated `senderDID`, so the body attributes nothing — exactly as
|
|
45
|
+
* `peer/query` returns its bare announcement.
|
|
46
|
+
*/ export function buildWorkflowHandlers(api) {
|
|
47
|
+
// cancel/retry resolve get(instanceID)→name and refuse identically when the
|
|
48
|
+
// instance is missing OR not commandable: a non-commandable instance must be
|
|
49
|
+
// indistinguishable from a nonexistent one, or a probe could confirm a hidden
|
|
50
|
+
// instance exists by the difference in the reply. Returns the resolved status
|
|
51
|
+
// when the gate passes, else the shared `not_found` refusal.
|
|
52
|
+
const resolveCommandable = async (instanceID)=>{
|
|
53
|
+
const instance = await api.get(instanceID);
|
|
54
|
+
if (instance == null || !api.isRemotelyCommandable(instance.name)) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
result: {
|
|
58
|
+
ok: false,
|
|
59
|
+
reason: 'not_found'
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
status: instance
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
workflow: {
|
|
70
|
+
'workflow/discover': async ()=>{
|
|
71
|
+
const instances = await api.list();
|
|
72
|
+
return instances.filter((instance)=>api.isRemotelyObservable(instance.name));
|
|
73
|
+
},
|
|
74
|
+
// Same policy as discover, reached directed via `.to(peer)` rather than by
|
|
75
|
+
// gather. The serving side is identical; only the transport differs.
|
|
76
|
+
'workflow/list': async ()=>{
|
|
77
|
+
const instances = await api.list();
|
|
78
|
+
return instances.filter((instance)=>api.isRemotelyObservable(instance.name));
|
|
79
|
+
},
|
|
80
|
+
'workflow/status': async ({ param })=>{
|
|
81
|
+
const instance = await api.get(param.instanceID);
|
|
82
|
+
// A non-observable instance must be indistinguishable from a missing one:
|
|
83
|
+
// the same refusal for both, or a probe could confirm a hidden instance
|
|
84
|
+
// exists by the difference in the reply.
|
|
85
|
+
if (instance == null || !api.isRemotelyObservable(instance.name)) {
|
|
86
|
+
throw new WorkflowInstanceNotObservable();
|
|
87
|
+
}
|
|
88
|
+
return instance;
|
|
89
|
+
},
|
|
90
|
+
// Directed: exactly one device runs this. A business refusal is RETURNED as
|
|
91
|
+
// data (enkaku collapses a throw to a generic error), so a non-commandable
|
|
92
|
+
// name refuses with `not_commandable` rather than throwing.
|
|
93
|
+
'workflow/enqueue': async ({ param })=>{
|
|
94
|
+
if (!api.isRemotelyCommandable(param.name)) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: 'not_commandable'
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const handle = await api.enqueue(param.name, param.params, param.opts);
|
|
101
|
+
// Normalize the enqueue handle to the InstanceStatus the wire carries, as
|
|
102
|
+
// cancelWorkflow does. A miss right after enqueue is an infra fault, not a
|
|
103
|
+
// business refusal, so it throws (→ generic error).
|
|
104
|
+
const status = await api.get(handle.instanceID);
|
|
105
|
+
if (status == null) {
|
|
106
|
+
throw new Error(`Workflow instance "${handle.instanceID}" not found after enqueue`);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
status
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
'workflow/cancel': async ({ param })=>{
|
|
114
|
+
const resolved = await resolveCommandable(param.instanceID);
|
|
115
|
+
if (!resolved.ok) {
|
|
116
|
+
return resolved.result;
|
|
117
|
+
}
|
|
118
|
+
await api.cancel(param.instanceID);
|
|
119
|
+
const status = await api.get(param.instanceID);
|
|
120
|
+
if (status == null) {
|
|
121
|
+
throw new Error(`Workflow instance "${param.instanceID}" not found after cancel`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
status
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
'workflow/retry': async ({ param })=>{
|
|
129
|
+
const resolved = await resolveCommandable(param.instanceID);
|
|
130
|
+
if (!resolved.ok) {
|
|
131
|
+
return resolved.result;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const status = await api.retry(param.instanceID);
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
status
|
|
138
|
+
};
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// The engine's own retry refusals map through as data; a genuine fault
|
|
141
|
+
// is rethrown to surface as the generic handler error.
|
|
142
|
+
const reason = retryRefusalReason(error);
|
|
143
|
+
if (reason == null) {
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
// Reconcile an ambiguous at-least-once retry: the directed lane resends
|
|
147
|
+
// to the same mailbox when a request throws, so a SUCCESSFUL first retry
|
|
148
|
+
// whose response leg was lost is resent and then refused `not_failed`
|
|
149
|
+
// (the first attempt already re-armed the instance). If it is now
|
|
150
|
+
// runnable, the transition already happened — report idempotent success
|
|
151
|
+
// rather than a false refusal. A terminal instance never satisfied the
|
|
152
|
+
// request, so its `not_failed` stands.
|
|
153
|
+
if (reason === 'not_failed') {
|
|
154
|
+
const current = await api.get(param.instanceID);
|
|
155
|
+
if (current != null && (current.status === 'pending' || current.status === 'running')) {
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
status: current
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
reason
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
18
171
|
/**
|
|
19
|
-
* The MLS-authenticated sender of a frame,
|
|
20
|
-
* `message.payload.iss`.
|
|
21
|
-
* `unwrap` recovered, so `iss` is
|
|
22
|
-
* one.
|
|
172
|
+
* The MLS-authenticated sender of a frame, surfaced by group-rpc at
|
|
173
|
+
* `message.payload.iss`. group-rpc builds that context message from the sender
|
|
174
|
+
* `unwrap` recovered, so `iss` is authenticated, not transport-claimed.
|
|
23
175
|
*
|
|
24
|
-
* Absent when the frame opened at a leaf that could not be named
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
176
|
+
* Absent when the frame opened at a leaf that could not be named — `undefined`,
|
|
177
|
+
* never a sentinel: a placeholder DID would be an identity claim nothing vouched
|
|
178
|
+
* for, and a guard against it would compare against a never-matching value rather
|
|
179
|
+
* than one meaning "unknown".
|
|
28
180
|
*/ function authenticatedSender(message) {
|
|
29
181
|
const payload = message.payload;
|
|
30
182
|
const iss = payload.iss;
|
|
@@ -33,18 +185,17 @@ import { processBroadcast } from './broadcast.js';
|
|
|
33
185
|
/**
|
|
34
186
|
* Build the control + sync procedure handlers for a single group.
|
|
35
187
|
*
|
|
36
|
-
* Each handler is a thin adapter: it maps the typed group-rpc
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* the handler owns only the shape translation.
|
|
188
|
+
* Each handler is a thin adapter: it maps the typed group-rpc payload back to the
|
|
189
|
+
* {@link GroupBroadcastMessage} the pure {@link processBroadcast} apply logic
|
|
190
|
+
* understands, re-adding the `groupID` group-rpc strips from the wire (each topic
|
|
191
|
+
* already encodes the group). The apply logic owns verification, conflict
|
|
192
|
+
* resolution, and storage; the handler owns only shape translation.
|
|
42
193
|
*
|
|
43
|
-
* Event handlers are fire-and-forget. `processBroadcast`
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
194
|
+
* Event handlers are fire-and-forget. `processBroadcast` drops an unverifiable or
|
|
195
|
+
* malformed payload and returns `{ applied: false }` rather than throwing, so the
|
|
196
|
+
* result is ignored. A rethrow (or unexpected store fault) is caught and logged
|
|
197
|
+
* here so a single bad frame never escapes the receive loop as an unhandled
|
|
198
|
+
* rejection.
|
|
48
199
|
*/ export function buildGroupHandlers(params, groupID) {
|
|
49
200
|
const safeApply = async (apply)=>{
|
|
50
201
|
try {
|
|
@@ -246,3 +397,18 @@ import { processBroadcast } from './broadcast.js';
|
|
|
246
397
|
}
|
|
247
398
|
};
|
|
248
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* The group handlers with the `workflow` observe + command handlers
|
|
402
|
+
* conditionally added, paired with {@link composeGroupProtocols}. A FRESH object
|
|
403
|
+
* — the base handlers are never mutated. Absent api the base is returned
|
|
404
|
+
* unchanged, so the workflow lane has no handler (and no protocol was mounted
|
|
405
|
+
* either).
|
|
406
|
+
*
|
|
407
|
+
* Returns the base type: the extra handlers are served by name at runtime, but
|
|
408
|
+
* the peer stays typed at the base shape the manager routes.
|
|
409
|
+
*/ export function composeGroupHandlers(base, api) {
|
|
410
|
+
return api != null ? {
|
|
411
|
+
...base,
|
|
412
|
+
...buildWorkflowHandlers(api)
|
|
413
|
+
} : base;
|
|
414
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ProcedureHandlers } from '@enkaku/server';
|
|
2
|
-
import type
|
|
2
|
+
import { type DIDCache, type OwnIdentity } from '@kokuin/token';
|
|
3
3
|
import type { StoreProvider } from '@kubun/db';
|
|
4
4
|
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
5
5
|
import type { HLC } from '@kubun/hlc';
|
|
6
6
|
import type { Logger } from '@kubun/logger';
|
|
7
|
+
import type { WorkflowDiscoverOptions, WorkflowDiscoverReply } from '@kubun/plugin-p2p-api';
|
|
7
8
|
import type { ServiceConfig } from '@kubun/plugin-service-api';
|
|
8
9
|
import type { GraphStoreAPI } from '@kubun/store-graph';
|
|
9
10
|
import { type GroupPeer, type LaneResult, type PendingCommit } from '@kumiai/rpc';
|
|
@@ -20,6 +21,7 @@ import { type SettleLostControlRequestDeps } from './commit-adoption.js';
|
|
|
20
21
|
import { type ControllerResolverFor } from './credential-wrapping-deps.js';
|
|
21
22
|
import type { P2PEventEmitter } from './events.js';
|
|
22
23
|
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
24
|
+
import { type WorkflowCommandAPI } from './group-handlers.js';
|
|
23
25
|
import { type GroupProtocols } from './group-protocols.js';
|
|
24
26
|
import { type PeerPresence } from './peer-presence.js';
|
|
25
27
|
/**
|
|
@@ -64,6 +66,22 @@ export type GroupPeerManagerParams = {
|
|
|
64
66
|
graph: GraphInternals;
|
|
65
67
|
/** Device-wide monotonic clock shared with the engine and MLS receive path. */
|
|
66
68
|
hlc: HLC;
|
|
69
|
+
/**
|
|
70
|
+
* The engine's identity gate, forwarded to the presence coordinator so a
|
|
71
|
+
* self-started announce awaits verification before it mints under `hlc`.
|
|
72
|
+
* Absent for a by-hand test manager, whose ungated clock needs no wait.
|
|
73
|
+
*/
|
|
74
|
+
ready?: () => Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the device's workflow API, if the workflow plugin is loaded. When it
|
|
77
|
+
* resolves non-null a `workflow` namespace is mounted on each hub peer so
|
|
78
|
+
* co-members can gather this device's remotely-observable instances; null
|
|
79
|
+
* leaves the peer's protocol map unchanged. Async because the engine resolves
|
|
80
|
+
* plugin APIs only after every factory has returned — a peer is created on
|
|
81
|
+
* `groupJoined`, well after that, so it is normally already resolved. Absent
|
|
82
|
+
* for a by-hand test manager, which mounts no workflow lane.
|
|
83
|
+
*/
|
|
84
|
+
getWorkflowAPI?: () => Promise<WorkflowCommandAPI | undefined>;
|
|
67
85
|
/**
|
|
68
86
|
* The engine's future-drift bound, forwarded to the apply path. Optional: a
|
|
69
87
|
* suite that builds a manager by hand falls back to the same default the
|
|
@@ -141,17 +159,8 @@ export type GroupPeerManagerParams = {
|
|
|
141
159
|
/** @see TunnelListenersParams.idleTimeoutMs */
|
|
142
160
|
tunnelIdleTimeoutMs?: number;
|
|
143
161
|
};
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
*
|
|
147
|
-
* The manager owns one reconnecting `HubLike` per hub URL (created lazily on
|
|
148
|
-
* first use, shared across every group bound to that hub via the multi-subscriber
|
|
149
|
-
* adapter) and one `GroupPeer` per (group, hub) pair. A peer exists iff the group
|
|
150
|
-
* is joined AND bound to that hub. Lifecycle mirrors the old `HubRelayManager`,
|
|
151
|
-
* but per (group, hub) instead of single-hub: the wiring phase drives `addGroup`
|
|
152
|
-
* / `removeGroup` / `addBinding` / `removeBinding` from the emitter and holds the
|
|
153
|
-
* unsubscribes. The manager does not subscribe to the emitter itself.
|
|
154
|
-
*/
|
|
162
|
+
/** The directed (non-gather) `workflow/*` procedures {@link GroupPeerManager.requestWorkflow} carries. */
|
|
163
|
+
export type WorkflowRequestMethod = 'workflow/list' | 'workflow/status' | 'workflow/enqueue' | 'workflow/cancel' | 'workflow/retry';
|
|
155
164
|
export type GroupPeerManager = {
|
|
156
165
|
/** Bring up peers for each already-bound hub of each joined group. */
|
|
157
166
|
start: (groupIDs: Array<string>) => Promise<void>;
|
|
@@ -174,6 +183,13 @@ export type GroupPeerManager = {
|
|
|
174
183
|
reconcileTunnelListeners: (groupID: string) => Promise<void>;
|
|
175
184
|
/** Fan a broadcast message out across every hub-peer of a group. */
|
|
176
185
|
broadcast: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
|
|
186
|
+
/**
|
|
187
|
+
* Ask every hub-peer of the group to re-drive a subscription its hub refused — the recovery for a
|
|
188
|
+
* device whose group topics were subscribed on join, before its app-level authorization landed,
|
|
189
|
+
* and so were refused `AuthorizationDeniedError` and latched. Synchronous and idempotent (a no-op
|
|
190
|
+
* on a peer holding nothing refused); the app calls it once it knows the device is authorized.
|
|
191
|
+
*/
|
|
192
|
+
reauthorize: (groupID: string) => void;
|
|
177
193
|
/**
|
|
178
194
|
* This device's presence on the peer lane: what it advertises, and who it asks.
|
|
179
195
|
* The manager owns it because every internal trigger fires from here — a hub
|
|
@@ -181,6 +197,24 @@ export type GroupPeerManager = {
|
|
|
181
197
|
* the group's live peers.
|
|
182
198
|
*/
|
|
183
199
|
presence: PeerPresence;
|
|
200
|
+
/**
|
|
201
|
+
* Fan `workflow/discover` out across every hub-peer of the group and return one
|
|
202
|
+
* entry per DISTINCT responder, deduped by the authenticated `senderDID` the
|
|
203
|
+
* gather envelope carried (self dropped), each `{ senderDID, instances }`. The
|
|
204
|
+
* multi-hub transport behind the caller-facing control-plane API — a member
|
|
205
|
+
* reachable through two hubs answers on both and is counted once.
|
|
206
|
+
*/
|
|
207
|
+
discoverWorkflows: (groupID: string, options?: WorkflowDiscoverOptions) => Promise<Array<WorkflowDiscoverReply>>;
|
|
208
|
+
/**
|
|
209
|
+
* Directed `workflow/*` request to one co-member over the group's hub:
|
|
210
|
+
* `.to(targetDID).request(method, { param })` on a bound hub-peer, retried
|
|
211
|
+
* across the remaining bindings only when a hub THROWS (an infra fault before
|
|
212
|
+
* anything is delivered) — never on a returned business refusal, which is the
|
|
213
|
+
* answer. Backs list/status/enqueue/cancel/retry; returns the raw wire result
|
|
214
|
+
* for the typed pluginAPI method to shape. Throws when the group has no live
|
|
215
|
+
* hub-peer, or rethrows the last hub's error.
|
|
216
|
+
*/
|
|
217
|
+
requestWorkflow: (groupID: string, targetDID: string, method: WorkflowRequestMethod, param?: Record<string, unknown>) => Promise<unknown>;
|
|
184
218
|
/**
|
|
185
219
|
* The peer for the group's canonical commit hub — the single hub a group
|
|
186
220
|
* commits through. Resolves the designated commit hub; falls back to the live
|