@openwop/openwop-conformance 1.1.0 → 1.2.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 (97) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +2 -2
  3. package/coverage.md +29 -17
  4. package/fixtures/conformance-agent-low-confidence.json +7 -4
  5. package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
  6. package/fixtures/conformance-agent-reasoning.json +23 -4
  7. package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
  8. package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
  9. package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
  10. package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
  11. package/fixtures/conformance-dispatch-input-mapping.json +49 -0
  12. package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
  13. package/fixtures/conformance-dispatch-output-mapping.json +49 -0
  14. package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
  15. package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
  16. package/fixtures.md +12 -2
  17. package/package.json +1 -1
  18. package/schemas/README.md +7 -0
  19. package/schemas/agent-ref.schema.json +1 -1
  20. package/schemas/ai-envelope.schema.json +106 -0
  21. package/schemas/capabilities.schema.json +300 -3
  22. package/schemas/core-conformance-mock-agent-config.schema.json +147 -0
  23. package/schemas/dispatch-config.schema.json +26 -0
  24. package/schemas/envelopes/clarification.request.schema.json +43 -0
  25. package/schemas/envelopes/error.schema.json +26 -0
  26. package/schemas/envelopes/schema.request.schema.json +22 -0
  27. package/schemas/envelopes/schema.response.schema.json +22 -0
  28. package/schemas/node-pack-manifest.schema.json +5 -0
  29. package/schemas/pack-lockfile.schema.json +16 -0
  30. package/schemas/run-event-payloads.schema.json +18 -2
  31. package/schemas/run-event.schema.json +2 -1
  32. package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
  33. package/src/lib/behavior-gate.ts +44 -5
  34. package/src/lib/env.ts +27 -0
  35. package/src/lib/webhook-receiver.ts +137 -0
  36. package/src/lib/workflow-chain-expansion.ts +213 -0
  37. package/src/scenarios/agentPackCatalog.test.ts +216 -0
  38. package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
  39. package/src/scenarios/agentReasoningEvents.test.ts +58 -7
  40. package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
  41. package/src/scenarios/ai-envelope-shape.test.ts +362 -0
  42. package/src/scenarios/aiEnvelope.capBreached.test.ts +173 -0
  43. package/src/scenarios/aiEnvelope.contractRefusal.test.ts +150 -0
  44. package/src/scenarios/aiEnvelope.correlationReplay.test.ts +69 -0
  45. package/src/scenarios/aiEnvelope.redaction.test.ts +73 -0
  46. package/src/scenarios/aiEnvelope.schemaDrift.test.ts +87 -0
  47. package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +143 -0
  48. package/src/scenarios/aiEnvelope.universalKinds.test.ts +176 -0
  49. package/src/scenarios/append-ordering.test.ts +44 -0
  50. package/src/scenarios/artifact-auth.test.ts +58 -0
  51. package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
  52. package/src/scenarios/blob-presign-expiry.test.ts +66 -0
  53. package/src/scenarios/blob-roundtrip.test.ts +48 -0
  54. package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
  55. package/src/scenarios/cache-ttl-expiry.test.ts +47 -0
  56. package/src/scenarios/dispatch-cross-worker-handoff.test.ts +98 -0
  57. package/src/scenarios/dispatch-input-mapping.test.ts +94 -0
  58. package/src/scenarios/dispatch-output-mapping.test.ts +65 -0
  59. package/src/scenarios/fs-path-traversal.test.ts +124 -0
  60. package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
  61. package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
  62. package/src/scenarios/kv-atomic-increment.test.ts +74 -0
  63. package/src/scenarios/kv-cas.test.ts +75 -0
  64. package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
  65. package/src/scenarios/kv-ttl-expiry.test.ts +47 -0
  66. package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
  67. package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
  68. package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
  69. package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
  70. package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
  71. package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
  72. package/src/scenarios/mcp-tool-roundtrip.test.ts +13 -6
  73. package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
  74. package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
  75. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
  76. package/src/scenarios/multi-region-idempotency.test.ts +39 -4
  77. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
  78. package/src/scenarios/pause-resume.test.ts +43 -0
  79. package/src/scenarios/queue-ack-nack-dlq.test.ts +67 -0
  80. package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
  81. package/src/scenarios/queue-publish-consume-roundtrip.test.ts +48 -0
  82. package/src/scenarios/registry-public.test.ts +91 -0
  83. package/src/scenarios/search-bm25-roundtrip.test.ts +47 -0
  84. package/src/scenarios/spec-corpus-validity.test.ts +28 -7
  85. package/src/scenarios/sql-injection-rejection.test.ts +84 -0
  86. package/src/scenarios/sql-transaction-atomicity.test.ts +66 -0
  87. package/src/scenarios/stream-subscribe-from-beginning.test.ts +66 -0
  88. package/src/scenarios/subworkflow-input-mapping.test.ts +100 -0
  89. package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
  90. package/src/scenarios/table-cursor-pagination.test.ts +47 -0
  91. package/src/scenarios/table-schema-enforcement.test.ts +47 -0
  92. package/src/scenarios/vector-knn-roundtrip.test.ts +48 -0
  93. package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
  94. package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
  95. package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
  96. package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
  97. package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
@@ -18,6 +18,18 @@
18
18
  * scenario exercises real behavior — useful for hosts that want to
19
19
  * claim full coverage in `INTEROP-MATRIX.md`.
20
20
  *
21
+ * - **Strict-mode opt-out (skip in strict mode too):** set
22
+ * `OPENWOP_OPTED_OUT_PROFILES=name1,name2,...` to declare that the
23
+ * host operator has deliberately chosen NOT to implement those
24
+ * profiles. In strict mode the gate skips them with a "honest
25
+ * opt-out" log line instead of failing — minimal hosts that
26
+ * advertise only what they implement can still go strict-mode
27
+ * green without falsifying capability claims. Conflict check:
28
+ * if a profile appears in BOTH `OPENWOP_OPTED_OUT_PROFILES` AND
29
+ * the host's discovery `capabilities.auth.profiles[]` (or
30
+ * equivalent), the gate logs a loud warning — opt-outs and
31
+ * advertisements are mutually exclusive.
32
+ *
21
33
  * Usage:
22
34
  *
23
35
  * ```ts
@@ -42,18 +54,45 @@ import { loadEnv } from './env.js';
42
54
 
43
55
  /**
44
56
  * Returns true if the scenario should proceed with assertions (advertised),
45
- * false if the scenario should `return` early (default-mode skip). In strict
46
- * mode (`OPENWOP_REQUIRE_BEHAVIOR=true`), throws if not advertised — so the
47
- * caller never actually receives `false` in that mode.
57
+ * false if the scenario should `return` early (default-mode skip OR
58
+ * strict-mode honest opt-out). In strict mode (`OPENWOP_REQUIRE_BEHAVIOR=true`)
59
+ * with `profileName` NOT in `OPENWOP_OPTED_OUT_PROFILES`, throws so the
60
+ * caller never receives `false` in that combination.
61
+ *
62
+ * If the host BOTH advertises the profile AND the operator listed it in
63
+ * the opt-out env var, surface a warning (likely typo) and treat as
64
+ * advertised (proceed). Advertisement always wins over opt-out: opting
65
+ * out of a profile you actually implement is meaningless.
48
66
  */
49
67
  export function behaviorGate(profileName: string, advertised: boolean): boolean {
68
+ const env = loadEnv();
69
+ const optedOut = env.optedOutProfiles.has(profileName);
70
+
71
+ if (advertised && optedOut) {
72
+ // eslint-disable-next-line no-console
73
+ console.warn(
74
+ `[${profileName}] both ADVERTISED by the host AND listed in OPENWOP_OPTED_OUT_PROFILES — ` +
75
+ `opt-out is ignored. Remove from the env var to clear this warning.`,
76
+ );
77
+ }
78
+
50
79
  if (advertised) return true;
51
80
 
52
- const env = loadEnv();
81
+ if (optedOut) {
82
+ // Honest opt-out: the operator declared the host does not implement
83
+ // this profile. Skip in BOTH default and strict mode.
84
+ // eslint-disable-next-line no-console
85
+ console.warn(
86
+ `[${profileName}] honest opt-out (OPENWOP_OPTED_OUT_PROFILES); skipping`,
87
+ );
88
+ return false;
89
+ }
90
+
53
91
  if (env.requireBehavior) {
54
92
  expect(
55
93
  advertised,
56
- `OPENWOP_REQUIRE_BEHAVIOR=true: host MUST advertise the ${profileName} profile for this scenario to run. ` +
94
+ `OPENWOP_REQUIRE_BEHAVIOR=true: host MUST advertise the ${profileName} profile for this scenario to run, ` +
95
+ `or declare opt-out via OPENWOP_OPTED_OUT_PROFILES=${profileName}. ` +
57
96
  `See conformance/coverage.md §"Capability-gated scenarios".`,
58
97
  ).toBe(true);
59
98
  // expect.toBe(true) throws; we won't reach here.
package/src/lib/env.ts CHANGED
@@ -16,6 +16,15 @@
16
16
  * Default is false — scenarios skip with a warning so default conformance
17
17
  * runs cover what the host has implemented. See `lib/behavior-gate.ts`
18
18
  * and `conformance/coverage.md` §"Capability-gated scenarios".
19
+ *
20
+ * OPENWOP_OPTED_OUT_PROFILES — comma-separated profile names the host
21
+ * operator has DELIBERATELY chosen not to implement. In strict mode
22
+ * these scenarios skip (logged as "honest opt-out") rather than
23
+ * failing — distinguishes "host doesn't claim this surface" (good)
24
+ * from "host claims but doesn't deliver" (bug). Lets honest minimal
25
+ * hosts go strict-mode green without falsifying capability claims.
26
+ * Example for SQLite:
27
+ * OPENWOP_OPTED_OUT_PROFILES=openwop-production,openwop-auth-mtls
19
28
  */
20
29
 
21
30
  export interface ConformanceEnv {
@@ -24,6 +33,15 @@ export interface ConformanceEnv {
24
33
  readonly implementationName: string;
25
34
  readonly implementationVersion: string;
26
35
  readonly requireBehavior: boolean;
36
+ /**
37
+ * Profiles the host operator has declared the host does NOT claim. Set
38
+ * via `OPENWOP_OPTED_OUT_PROFILES=name1,name2`. In strict mode, the
39
+ * behavior-gate honors this set as PASS-by-opt-out rather than failing
40
+ * the scenario. Never include a profile the host actually advertises —
41
+ * that's a typo, not an opt-out, and `behaviorGate` will surface a
42
+ * warning if it detects the conflict.
43
+ */
44
+ readonly optedOutProfiles: ReadonlySet<string>;
27
45
  }
28
46
 
29
47
  let cached: ConformanceEnv | null = null;
@@ -48,12 +66,21 @@ export function loadEnv(): ConformanceEnv {
48
66
  // Strip trailing slash so URL composition is consistent.
49
67
  const normalizedBase = baseUrl.replace(/\/$/, '');
50
68
 
69
+ const optedOutRaw = process.env.OPENWOP_OPTED_OUT_PROFILES?.trim() ?? '';
70
+ const optedOutProfiles = new Set(
71
+ optedOutRaw
72
+ .split(',')
73
+ .map((s) => s.trim())
74
+ .filter((s) => s.length > 0),
75
+ );
76
+
51
77
  cached = {
52
78
  baseUrl: normalizedBase,
53
79
  apiKey,
54
80
  implementationName: process.env.OPENWOP_IMPLEMENTATION_NAME?.trim() ?? 'unknown',
55
81
  implementationVersion: process.env.OPENWOP_IMPLEMENTATION_VERSION?.trim() ?? 'unknown',
56
82
  requireBehavior: process.env.OPENWOP_REQUIRE_BEHAVIOR === 'true',
83
+ optedOutProfiles,
57
84
  };
58
85
  return cached;
59
86
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Reference webhook receiver for the conformance suite — implements
3
+ * the verification contract per `spec/v1/webhooks.md` §"Signature
4
+ * recipe" + §"Replay-attack resistance" so adversarial-input scenarios
5
+ * can verify that a properly-implemented receiver rejects the
6
+ * documented failure modes.
7
+ *
8
+ * Mirrors the SDK's verifyWebhookSignature helper (sdk/typescript/src/
9
+ * webhook-helpers.ts) but inlined here so the conformance suite stays
10
+ * dependency-free vs. the SDK. The two MUST produce identical
11
+ * outcomes for the same inputs.
12
+ *
13
+ * @see spec/v1/webhooks.md §"Signature recipe"
14
+ * @see sdk/typescript/src/webhook-helpers.ts (canonical SDK
15
+ * implementation; this file is a conformance-suite mirror)
16
+ */
17
+
18
+ import { createHmac, timingSafeEqual } from 'node:crypto';
19
+
20
+ export const DEFAULT_FRESHNESS_WINDOW_SECONDS = 300;
21
+
22
+ export type WebhookRejectionReason =
23
+ | 'signature_mismatch'
24
+ | 'timestamp_expired'
25
+ | 'timestamp_too_far_in_future'
26
+ | 'malformed_signature_header'
27
+ | 'malformed_timestamp_header'
28
+ | 'wrong_algorithm'
29
+ | 'duplicate_signature';
30
+
31
+ export type WebhookVerifyResult =
32
+ | { accepted: true }
33
+ | { accepted: false; reason: WebhookRejectionReason };
34
+
35
+ export interface WebhookReceiverState {
36
+ /** Set of signature values the receiver has already accepted (anti-replay). */
37
+ acceptedSignatures: Set<string>;
38
+ }
39
+
40
+ export function createReceiverState(): WebhookReceiverState {
41
+ return { acceptedSignatures: new Set() };
42
+ }
43
+
44
+ export interface VerifyOptions {
45
+ /** Default 5 minutes per spec. Set 0 to disable freshness check. */
46
+ freshnessWindowSeconds?: number;
47
+ /** Override `now` (unix seconds) for deterministic tests. */
48
+ nowSeconds?: number;
49
+ }
50
+
51
+ /**
52
+ * Verify a single webhook delivery against the canonical recipe.
53
+ * Returns `{ accepted: true }` on success; `{ accepted: false, reason }`
54
+ * otherwise. Updates `state.acceptedSignatures` on acceptance for
55
+ * replay-attack detection on subsequent calls.
56
+ *
57
+ * Receivers MUST pass the **exact** request body bytes — parsed-and-
58
+ * reserialized JSON will fail verification.
59
+ */
60
+ export function verifyWebhookDelivery(
61
+ secret: string,
62
+ signatureHeader: string,
63
+ algorithmHeader: string | undefined,
64
+ timestampHeader: string,
65
+ rawBody: string | Buffer,
66
+ state: WebhookReceiverState,
67
+ options: VerifyOptions = {},
68
+ ): WebhookVerifyResult {
69
+ // 1. Algorithm gating. Hosts MAY include an explicit
70
+ // X-openwop-Signature-Algorithm header; receivers MUST refuse
71
+ // anything other than `v1` per webhooks.md §"Signature algorithm
72
+ // versioning". Absence is treated as the v1 default.
73
+ if (algorithmHeader !== undefined && algorithmHeader !== 'v1') {
74
+ return { accepted: false, reason: 'wrong_algorithm' };
75
+ }
76
+
77
+ // 2. Signature header parse.
78
+ if (!signatureHeader.startsWith('v1=')) {
79
+ return { accepted: false, reason: 'malformed_signature_header' };
80
+ }
81
+ const providedHex = signatureHeader.slice(3);
82
+ if (!/^[0-9a-f]+$/i.test(providedHex)) {
83
+ return { accepted: false, reason: 'malformed_signature_header' };
84
+ }
85
+
86
+ // 3. Anti-replay: receivers MUST refuse a signature value seen
87
+ // before, even if the timestamp would otherwise be fresh
88
+ // (defense-in-depth against an attacker resending a captured
89
+ // delivery before the original's timestamp window expires).
90
+ if (state.acceptedSignatures.has(signatureHeader)) {
91
+ return { accepted: false, reason: 'duplicate_signature' };
92
+ }
93
+
94
+ // 4. Timestamp parse + freshness window.
95
+ const timestamp = Number(timestampHeader);
96
+ if (!Number.isInteger(timestamp) || timestamp <= 0) {
97
+ return { accepted: false, reason: 'malformed_timestamp_header' };
98
+ }
99
+ const window = options.freshnessWindowSeconds ?? DEFAULT_FRESHNESS_WINDOW_SECONDS;
100
+ if (window > 0) {
101
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
102
+ const delta = now - timestamp;
103
+ if (delta > window) return { accepted: false, reason: 'timestamp_expired' };
104
+ if (delta < -window) return { accepted: false, reason: 'timestamp_too_far_in_future' };
105
+ }
106
+
107
+ // 5. HMAC recompute + constant-time compare.
108
+ const bodyStr = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
109
+ const expectedHex = createHmac('sha256', secret).update(`${timestamp}.${bodyStr}`, 'utf8').digest('hex');
110
+ const providedBuf = Buffer.from(providedHex, 'hex');
111
+ const expectedBuf = Buffer.from(expectedHex, 'hex');
112
+ if (providedBuf.length !== expectedBuf.length || !timingSafeEqual(providedBuf, expectedBuf)) {
113
+ return { accepted: false, reason: 'signature_mismatch' };
114
+ }
115
+
116
+ // 6. Accept + record for replay detection.
117
+ state.acceptedSignatures.add(signatureHeader);
118
+ return { accepted: true };
119
+ }
120
+
121
+ /**
122
+ * Sign a payload the way the host would — useful for building
123
+ * adversarial-input fixtures in scenarios.
124
+ */
125
+ export function signPayload(
126
+ secret: string,
127
+ timestamp: number,
128
+ rawBody: string | Buffer,
129
+ ): { signatureHeader: string; timestampHeader: string; algorithmHeader: 'v1' } {
130
+ const bodyStr = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
131
+ const hex = createHmac('sha256', secret).update(`${timestamp}.${bodyStr}`, 'utf8').digest('hex');
132
+ return {
133
+ signatureHeader: `v1=${hex}`,
134
+ timestampHeader: String(timestamp),
135
+ algorithmHeader: 'v1',
136
+ };
137
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Workflow-chain pack expansion — reference implementation of the
3
+ * 9-step host-editor expansion semantics from
4
+ * `spec/v1/workflow-chain-packs.md` §"Expansion semantics (normative)".
5
+ *
6
+ * Pure function. Zero I/O, zero crypto. Hosts implementing chain
7
+ * expansion in their workflow editors MAY import this directly OR
8
+ * adapt the algorithm into their language of choice — the contract
9
+ * this code encodes is the spec, not the code itself.
10
+ *
11
+ * What this implements:
12
+ * - Step 3: validate referenced typeIds resolve (delegated to caller via
13
+ * `isTypeIdResolvable` predicate)
14
+ * - Step 5: `{{params.<name>}}` literal substitution (recursive into
15
+ * nested string fields inside `config` / `inputs`)
16
+ * - Step 6: per-expansion node-id rewrite with a chainId-derived prefix
17
+ * for collision-free splice into the parent workflow
18
+ * - Step 8: capability propagation (chain.capabilities[] → every
19
+ * expanded WorkflowNode.capabilities[])
20
+ * - Edge endpoint rewriting (`from`/`to` ids that reference fragment
21
+ * nodes get the same prefix)
22
+ *
23
+ * What this deliberately DOESN'T implement (host-specific concerns):
24
+ * - Step 1: registry resolution (network/storage path is host-specific)
25
+ * - Step 2: signature verification (use `node:crypto`'s Ed25519 path —
26
+ * see workflow-chain-pack-signature-verification.test.ts)
27
+ * - Step 4: parameter-form prompting (host-UI concern)
28
+ * - Step 7: splice into parent workflow (host-editor concern; this
29
+ * function returns the rewritten fragment ready to be appended)
30
+ * - Step 9: persistence (host-storage concern)
31
+ *
32
+ * @see spec/v1/workflow-chain-packs.md §"Expansion semantics (normative)"
33
+ * @see RFCS/0013-workflow-chain-packs.md
34
+ */
35
+
36
+ /** A workflow-chain entry as it appears in a pack manifest. */
37
+ export interface WorkflowChain {
38
+ chainId: string;
39
+ version: string;
40
+ label: string;
41
+ description: string;
42
+ parameters: object;
43
+ dag: { nodes: ReadonlyArray<FragmentNode>; edges?: ReadonlyArray<FragmentEdge> };
44
+ outputs?: Record<string, { type: string; description: string }>;
45
+ capabilities?: ReadonlyArray<'streamable' | 'cacheable' | 'side-effectful' | 'mcp-exportable'>;
46
+ }
47
+
48
+ export interface FragmentNode {
49
+ id: string;
50
+ typeId: string;
51
+ name?: string;
52
+ position?: { x: number; y: number };
53
+ config?: Record<string, unknown>;
54
+ inputs?: Record<string, unknown>;
55
+ }
56
+
57
+ export interface FragmentEdge {
58
+ from: string;
59
+ to: string;
60
+ condition?: string;
61
+ }
62
+
63
+ /** Per-expansion context the caller supplies. */
64
+ export interface ExpansionContext {
65
+ /** Caller-supplied unique tag for this expansion (e.g., 4-hex random).
66
+ * Combined with the chainId slug to namespace expanded node ids so
67
+ * the same chain can be expanded multiple times within one parent
68
+ * workflow without id collisions. */
69
+ expansionId: string;
70
+ /** Author-supplied parameter values, ALREADY VALIDATED against the
71
+ * chain's `parameters` JSON Schema. This function does NOT re-validate
72
+ * — the caller MUST ajv-compile `chain.parameters` and reject invalid
73
+ * input with `chain_parameter_invalid` BEFORE calling. */
74
+ params: Record<string, unknown>;
75
+ /** Predicate the caller supplies for typeId resolution (step 3). Should
76
+ * return `true` if the typeId is registered with the destination host
77
+ * (either reserved `core.*` or published via a known node pack). */
78
+ isTypeIdResolvable: (typeId: string) => boolean;
79
+ }
80
+
81
+ /** Result of expansion — ready to be spliced into a parent workflow's
82
+ * `nodes[]` / `edges[]`. */
83
+ export interface ExpandedFragment {
84
+ nodes: ReadonlyArray<{
85
+ id: string;
86
+ typeId: string;
87
+ name?: string;
88
+ position?: { x: number; y: number };
89
+ config?: Record<string, unknown>;
90
+ inputs?: Record<string, unknown>;
91
+ capabilities?: ReadonlyArray<string>;
92
+ }>;
93
+ edges: ReadonlyArray<{ from: string; to: string; condition?: string }>;
94
+ /** Map of original-fragment-id → rewritten-id, so the caller can
95
+ * wire the parent workflow's adjacent edges into the expansion. */
96
+ idMap: ReadonlyMap<string, string>;
97
+ }
98
+
99
+ /** Thrown when expansion encounters a chain that references a typeId the
100
+ * destination host can't resolve. Carries both the offending `typeId`
101
+ * and the `chainId` for diagnostic reporting. The error message uses
102
+ * the wire-level error code `chain_unresolvable_typeid` per
103
+ * `workflow-chain-packs.md` §"Error codes". */
104
+ export class ChainUnresolvableTypeIdError extends Error {
105
+ readonly code = 'chain_unresolvable_typeid';
106
+ constructor(readonly typeId: string, readonly chainId: string) {
107
+ super(`chain_unresolvable_typeid: '${typeId}' in chain '${chainId}'`);
108
+ this.name = 'ChainUnresolvableTypeIdError';
109
+ }
110
+ }
111
+
112
+ const PARAM_PATTERN = /\{\{params\.([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
113
+
114
+ /** Recursive literal substitution of `{{params.<name>}}` placeholders in
115
+ * any string field. Non-string values pass through unchanged; nested
116
+ * arrays/objects are walked. */
117
+ function substitute(value: unknown, params: Record<string, unknown>): unknown {
118
+ if (typeof value === 'string') {
119
+ return value.replace(PARAM_PATTERN, (_match, name: string) => {
120
+ const v = params[name];
121
+ // Per the spec, parameter values are validated against the chain's
122
+ // parameters schema BEFORE expansion, so `v === undefined` here
123
+ // means the chain author referenced an undeclared parameter — the
124
+ // safest substitution is the empty string (matching the standard
125
+ // {{...}} convention in n8n/Handlebars).
126
+ return v === undefined ? '' : String(v);
127
+ });
128
+ }
129
+ if (Array.isArray(value)) return value.map((v) => substitute(v, params));
130
+ if (value !== null && typeof value === 'object') {
131
+ const out: Record<string, unknown> = {};
132
+ for (const [k, v] of Object.entries(value)) out[k] = substitute(v, params);
133
+ return out;
134
+ }
135
+ return value;
136
+ }
137
+
138
+ /** Rewrite an edge endpoint ref. `ref` is either `<nodeId>` or
139
+ * `<nodeId>.<portName>`. Only the nodeId portion is rewritten; the
140
+ * portName (if present) is preserved verbatim. Refs that don't match
141
+ * a fragment node id pass through unchanged (lets edges to/from
142
+ * parent-workflow nodes work via post-splice wiring). */
143
+ function rewriteEdgeRef(
144
+ ref: string,
145
+ fragmentNodeIds: ReadonlySet<string>,
146
+ prefix: string,
147
+ ): string {
148
+ const dotIdx = ref.indexOf('.');
149
+ const nodeId = dotIdx === -1 ? ref : ref.slice(0, dotIdx);
150
+ const portPart = dotIdx === -1 ? '' : ref.slice(dotIdx);
151
+ return fragmentNodeIds.has(nodeId) ? `${prefix}${nodeId}${portPart}` : ref;
152
+ }
153
+
154
+ /** Compute the per-expansion node-id prefix from the chainId + expansionId.
155
+ * The chainId's dots are replaced with underscores so the resulting ids
156
+ * remain valid in storage backends that reserve `.` for hierarchical
157
+ * keys. */
158
+ function computePrefix(chainId: string, expansionId: string): string {
159
+ return `${chainId.replace(/\./g, '_')}_${expansionId}_`;
160
+ }
161
+
162
+ /**
163
+ * Expand a workflow-chain into a concrete fragment ready to splice into a
164
+ * parent workflow. Implements steps 3 + 5 + 6 + 8 of the normative
165
+ * `workflow-chain-packs.md` §"Expansion semantics" flow.
166
+ *
167
+ * @throws ChainUnresolvableTypeIdError when any `dag.nodes[].typeId`
168
+ * fails the caller's `isTypeIdResolvable` predicate.
169
+ */
170
+ export function expandChain(chain: WorkflowChain, ctx: ExpansionContext): ExpandedFragment {
171
+ // Step 3: validate every typeId resolves.
172
+ for (const node of chain.dag.nodes) {
173
+ if (!ctx.isTypeIdResolvable(node.typeId)) {
174
+ throw new ChainUnresolvableTypeIdError(node.typeId, chain.chainId);
175
+ }
176
+ }
177
+
178
+ const prefix = computePrefix(chain.chainId, ctx.expansionId);
179
+ const fragmentNodeIds = new Set(chain.dag.nodes.map((n) => n.id));
180
+ const idMap = new Map<string, string>();
181
+ for (const id of fragmentNodeIds) idMap.set(id, `${prefix}${id}`);
182
+
183
+ // Steps 5 + 6 + 8: substitute placeholders, rewrite ids, propagate capabilities.
184
+ const expandedNodes = chain.dag.nodes.map((n) => {
185
+ const out: ExpandedFragment['nodes'][number] = {
186
+ id: `${prefix}${n.id}`,
187
+ typeId: n.typeId,
188
+ };
189
+ if (n.name !== undefined) out.name = n.name;
190
+ if (n.position !== undefined) out.position = n.position;
191
+ if (n.config !== undefined) {
192
+ out.config = substitute(n.config, ctx.params) as Record<string, unknown>;
193
+ }
194
+ if (n.inputs !== undefined) {
195
+ out.inputs = substitute(n.inputs, ctx.params) as Record<string, unknown>;
196
+ }
197
+ if (chain.capabilities && chain.capabilities.length > 0) {
198
+ out.capabilities = [...chain.capabilities];
199
+ }
200
+ return out;
201
+ });
202
+
203
+ const expandedEdges = (chain.dag.edges ?? []).map((e) => {
204
+ const out: ExpandedFragment['edges'][number] = {
205
+ from: rewriteEdgeRef(e.from, fragmentNodeIds, prefix),
206
+ to: rewriteEdgeRef(e.to, fragmentNodeIds, prefix),
207
+ };
208
+ if (e.condition !== undefined) out.condition = e.condition;
209
+ return out;
210
+ });
211
+
212
+ return { nodes: expandedNodes, edges: expandedEdges, idMap };
213
+ }