@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.
Files changed (35) hide show
  1. package/lib/groups/did-cache-seed.d.ts +26 -0
  2. package/lib/groups/did-cache-seed.js +38 -0
  3. package/lib/groups/group-crypto.d.ts +8 -9
  4. package/lib/groups/group-crypto.js +51 -55
  5. package/lib/groups/group-handlers.d.ts +50 -14
  6. package/lib/groups/group-handlers.js +190 -24
  7. package/lib/groups/group-peer-manager.d.ts +52 -12
  8. package/lib/groups/group-peer-manager.js +215 -58
  9. package/lib/groups/group-protocols.d.ts +407 -0
  10. package/lib/groups/group-protocols.js +279 -11
  11. package/lib/groups/mls-codec.d.ts +19 -13
  12. package/lib/groups/mls-codec.js +28 -15
  13. package/lib/groups/peer-presence.d.ts +6 -0
  14. package/lib/groups/peer-presence.js +5 -0
  15. package/lib/hub/http-client.js +6 -1
  16. package/lib/hub/hub-like.js +5 -2
  17. package/lib/hub/loopback-log-hub.js +4 -1
  18. package/lib/hub/peer-scoped-hub-view.d.ts +6 -0
  19. package/lib/hub/peer-scoped-hub-view.js +11 -1
  20. package/lib/hub/wiring.d.ts +36 -7
  21. package/lib/hub/wiring.js +18 -0
  22. package/lib/index.js +95 -20
  23. package/lib/sync/hub-tunnel-service-listener.d.ts +9 -0
  24. package/lib/sync/hub-tunnel-service-listener.js +6 -1
  25. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -1
  26. package/lib/sync/hub-tunnel-sync-listener.js +6 -1
  27. package/lib/sync/service-tunnel-listeners.d.ts +7 -1
  28. package/lib/sync/service-tunnel-listeners.js +18 -0
  29. package/lib/sync/sync-manager.d.ts +1 -5
  30. package/lib/sync/sync-manager.js +0 -5
  31. package/lib/sync/tunnel-listeners.d.ts +7 -1
  32. package/lib/sync/tunnel-listeners.js +18 -0
  33. package/lib/types.d.ts +15 -1
  34. package/lib/types.js +4 -0
  35. 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`, taking the
10
- * per-group mutex and observing the canonical handle, framed as in
11
- * `mls-codec.ts`. `exportSecret` passes the caller's label through untouched:
12
- * the labels belong to the package deriving topics from the result, so
13
- * substituting one moves every topic ID away from what the caller addressed.
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 is not a decode error but a group whose
20
- * members silently stop reading each other's commits.
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. Construction is synchronous
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 bytes feeds an HKDF-based topic
14
- // derivation; the length is otherwise unconstrained. It is also XChaCha20-
15
- // Poly1305's key size, and the same 32 the ledger-entry seal exports at — RFC
16
- // 9420 §8.5 binds the length into the exporter's KDFLabel, so deriving this from
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 — it is read to decide how to open, so it cannot
24
- // sit under the seal. It buys diagnosis, not compatibility: no v1 reader can open
25
- // a v2 blob either way, but the failure reads as "unsupported version" rather
26
- // than an AEAD refusal indistinguishable from a wrong epoch or a tampered frame.
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 would not be an independent export: it is
38
- // the exact exporter call `sealEntries`/`openEntries` make (same context,
39
- // same length), so it would hand back the ledger-entry seal key under
40
- // another name and make every holder of a topic secret a reader of the
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 (the
58
- // anycast `BroadcastClient` and the `busServer`) and the hub mux fans every
59
- // frame to both, so `unwrap` runs twice in one synchronous burst. MLS
60
- // decryption is single-use: `handle.decrypt` consumes a forward-secret ratchet
61
- // generation, so the second call fails with "Desired gen in the past" and the
62
- // frame never reaches its handler. Caching the in-flight promise per ciphertext
63
- // makes `unwrap` idempotent the first call advances the ratchet, the sibling
64
- // (calling before it resolves) replays the plaintext. Entries drop once
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. That is sound only
70
- // because the key is the ciphertext: one ciphertext at one epoch has exactly
71
- // one MLS-authenticated sender, so a replay cannot attribute a frame to
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 key = toB64(bytes);
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 (success or failure): the sibling transport
88
- // has already taken the in-flight promise, and a genuine failure must not be
89
- // cached so a later valid frame with the same bytes (e.g. after a resync) can
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 a handler of its own. The caller handles the
94
- // promise it was handed; without the catch here the derived one escapes as an
95
- // unhandled rejection. Not an edge case: a frame this handle cannot open
96
- // another epoch's, another group's is ordinary on a shared log, so the
97
- // rejecting path is the common one. Only this branch is absorbed; the promise
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 and no key, and must never throw: it is asked about every frame a log
108
- // holds, most of which are not this member's to open. `null` for anything
109
- // ts-mls will not decode, which is how a caller tells a frame sealed AHEAD
110
- // (openable once it catches up) from one sealed BELOW (gone forever, since MLS
111
- // ratchets forward) — the distinction the app lane's durable cursor rests on.
112
- // Untrusted: it is the publisher's word relayed by an untrusted hub, so it may
113
- // only decide what to TRY, never that bytes are authentic.
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 is what makes the open PURE, so it can run from inside
121
- // the apply of the very commit whose blob it opens — the only place it runs,
122
- // and one the ratchet-backed pair cannot serve. Byte format is fixed by
123
- // `@kumiai/mls-rpc`; a one-byte drift is a group that silently partitions.
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
- * The app-lane handler maps a per-group peer wires into `createGroupPeer`. Keyed
6
- * to {@link groupProtocols} so the peer dispatches each decoded procedure payload
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 procedure payload
18
- * back to the {@link GroupBroadcastMessage} the pure {@link processBroadcast}
19
- * apply logic already understands, re-adding the `groupID` the peer is bound to
20
- * (group-rpc strips it from the wire because each topic already encodes the
21
- * group). The apply logic owns verification, conflict resolution, and storage;
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` already drops an
25
- * unverifiable or malformed payload and returns `{ applied: false }` rather than
26
- * throwing, so the result is intentionally ignored. A programmer-error rethrow
27
- * (or an unexpected store fault) is caught and logged here so a single bad frame
28
- * never escapes as an unhandled rejection out of the receive loop.
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
- * broadcast responder writes a reply for whatever a handler returns, and the
8
- * requester's gather drops replies that carried an error and keeps the rest. So a
9
- * decline bounds the RESULT, not the traffic — every member still publishes one
10
- * reply frame — and "only matching devices reply" is true of what a gather
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, which group-rpc surfaces at
20
- * `message.payload.iss`. It builds that context message itself from the sender
21
- * `unwrap` recovered, so `iss` is an authenticated DID and not a transport-claimed
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. That is
25
- * `undefined`, never a sentinel: a placeholder DID would be an identity claim
26
- * nothing vouched for, and a guard comparing against it would be comparing
27
- * against a value chosen for never matching rather than one meaning "unknown".
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 procedure payload
37
- * back to the {@link GroupBroadcastMessage} the pure {@link processBroadcast}
38
- * apply logic already understands, re-adding the `groupID` the peer is bound to
39
- * (group-rpc strips it from the wire because each topic already encodes the
40
- * group). The apply logic owns verification, conflict resolution, and storage;
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` already drops an
44
- * unverifiable or malformed payload and returns `{ applied: false }` rather than
45
- * throwing, so the result is intentionally ignored. A programmer-error rethrow
46
- * (or an unexpected store fault) is caught and logged here so a single bad frame
47
- * never escapes as an unhandled rejection out of the receive loop.
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
+ }