@kubun/plugin-p2p 0.15.0 → 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/did-cache-seed.d.ts +26 -0
- package/lib/groups/did-cache-seed.js +38 -0
- 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 +52 -12
- package/lib/groups/group-peer-manager.js +215 -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/http-client.js +6 -1
- package/lib/hub/hub-like.js +5 -2
- package/lib/hub/loopback-log-hub.js +4 -1
- package/lib/hub/peer-scoped-hub-view.d.ts +6 -0
- package/lib/hub/peer-scoped-hub-view.js +11 -1
- package/lib/hub/wiring.d.ts +36 -7
- package/lib/hub/wiring.js +18 -0
- package/lib/index.js +95 -20
- package/lib/sync/hub-tunnel-service-listener.d.ts +9 -0
- package/lib/sync/hub-tunnel-service-listener.js +6 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -1
- package/lib/sync/hub-tunnel-sync-listener.js +6 -1
- package/lib/sync/service-tunnel-listeners.d.ts +7 -1
- package/lib/sync/service-tunnel-listeners.js +18 -0
- package/lib/sync/sync-manager.d.ts +1 -5
- package/lib/sync/sync-manager.js +0 -5
- package/lib/sync/tunnel-listeners.d.ts +7 -1
- package/lib/sync/tunnel-listeners.js +18 -0
- package/lib/types.d.ts +15 -1
- package/lib/types.js +4 -0
- package/package.json +48 -47
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type DIDCache } from '@kokuin/token';
|
|
2
|
+
import type { StoreProvider } from '@kubun/db';
|
|
3
|
+
import type { Logger } from '@kubun/logger';
|
|
4
|
+
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
5
|
+
export type SeedDIDCacheParams = {
|
|
6
|
+
cache: DIDCache;
|
|
7
|
+
registry: GroupHandleRegistry;
|
|
8
|
+
groupID: string;
|
|
9
|
+
stores?: StoreProvider;
|
|
10
|
+
logger?: Logger;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Seed the shared DID cache with every `did:peer:4` co-member's document, read
|
|
14
|
+
* off the MLS roster's authenticated leaf long forms.
|
|
15
|
+
*
|
|
16
|
+
* A co-member's short-form `iss` on a peer-lane token carries no document, so a
|
|
17
|
+
* freshly-spawned tunnel/sync Server cannot resolve it without this. The roster
|
|
18
|
+
* leaf is the one authenticated source of the long form — the membership row
|
|
19
|
+
* holds only the normalized short form, which carries none.
|
|
20
|
+
*
|
|
21
|
+
* Monotonic and idempotent: a peer:4 doc is content-addressed, so re-seeding an
|
|
22
|
+
* existing entry is a no-op and a departed member's lingering doc is harmless
|
|
23
|
+
* (per-scope authorization gates access downstream, not cache presence). So the
|
|
24
|
+
* membership-change seam only ever adds, never evicts.
|
|
25
|
+
*/
|
|
26
|
+
export declare function seedDIDCacheFromRoster(params: SeedDIDCacheParams): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { decodePeer4, isPeer4 } from '@kokuin/token';
|
|
2
|
+
/**
|
|
3
|
+
* Seed the shared DID cache with every `did:peer:4` co-member's document, read
|
|
4
|
+
* off the MLS roster's authenticated leaf long forms.
|
|
5
|
+
*
|
|
6
|
+
* A co-member's short-form `iss` on a peer-lane token carries no document, so a
|
|
7
|
+
* freshly-spawned tunnel/sync Server cannot resolve it without this. The roster
|
|
8
|
+
* leaf is the one authenticated source of the long form — the membership row
|
|
9
|
+
* holds only the normalized short form, which carries none.
|
|
10
|
+
*
|
|
11
|
+
* Monotonic and idempotent: a peer:4 doc is content-addressed, so re-seeding an
|
|
12
|
+
* existing entry is a no-op and a departed member's lingering doc is harmless
|
|
13
|
+
* (per-scope authorization gates access downstream, not cache presence). So the
|
|
14
|
+
* membership-change seam only ever adds, never evicts.
|
|
15
|
+
*/ export async function seedDIDCacheFromRoster(params) {
|
|
16
|
+
const { cache, registry, groupID, stores, logger } = params;
|
|
17
|
+
const members = await registry.readHandle(groupID, (handle)=>handle.listMembers(), stores == null ? undefined : {
|
|
18
|
+
stores
|
|
19
|
+
});
|
|
20
|
+
for (const member of members){
|
|
21
|
+
// did:key members resolve natively; only a genuine peer:4 long form (≠ the
|
|
22
|
+
// short-form `id`) carries a document worth caching.
|
|
23
|
+
if (!isPeer4(member.longForm) || member.longForm === member.id) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const { shortForm, doc } = decodePeer4(member.longForm);
|
|
28
|
+
await cache.set(shortForm, doc);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
// A single unparseable leaf must not abort seeding the rest of the roster.
|
|
31
|
+
logger?.warn('did-cache roster seed skipped a member', {
|
|
32
|
+
groupID,
|
|
33
|
+
id: member.id,
|
|
34
|
+
error
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -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
|
+
}
|