@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.
- package/CHANGELOG.md +25 -0
- package/README.md +2 -2
- package/coverage.md +29 -17
- package/fixtures/conformance-agent-low-confidence.json +7 -4
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
- package/fixtures/conformance-agent-reasoning.json +23 -4
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
- package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
- package/fixtures/conformance-dispatch-input-mapping.json +49 -0
- package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
- package/fixtures/conformance-dispatch-output-mapping.json +49 -0
- package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
- package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
- package/fixtures.md +12 -2
- package/package.json +1 -1
- package/schemas/README.md +7 -0
- package/schemas/agent-ref.schema.json +1 -1
- package/schemas/ai-envelope.schema.json +106 -0
- package/schemas/capabilities.schema.json +300 -3
- package/schemas/core-conformance-mock-agent-config.schema.json +147 -0
- package/schemas/dispatch-config.schema.json +26 -0
- package/schemas/envelopes/clarification.request.schema.json +43 -0
- package/schemas/envelopes/error.schema.json +26 -0
- package/schemas/envelopes/schema.request.schema.json +22 -0
- package/schemas/envelopes/schema.response.schema.json +22 -0
- package/schemas/node-pack-manifest.schema.json +5 -0
- package/schemas/pack-lockfile.schema.json +16 -0
- package/schemas/run-event-payloads.schema.json +18 -2
- package/schemas/run-event.schema.json +2 -1
- package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
- package/src/lib/behavior-gate.ts +44 -5
- package/src/lib/env.ts +27 -0
- package/src/lib/webhook-receiver.ts +137 -0
- package/src/lib/workflow-chain-expansion.ts +213 -0
- package/src/scenarios/agentPackCatalog.test.ts +216 -0
- package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
- package/src/scenarios/agentReasoningEvents.test.ts +58 -7
- package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
- package/src/scenarios/ai-envelope-shape.test.ts +362 -0
- package/src/scenarios/aiEnvelope.capBreached.test.ts +173 -0
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +150 -0
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +69 -0
- package/src/scenarios/aiEnvelope.redaction.test.ts +73 -0
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +87 -0
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +143 -0
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +176 -0
- package/src/scenarios/append-ordering.test.ts +44 -0
- package/src/scenarios/artifact-auth.test.ts +58 -0
- package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/blob-presign-expiry.test.ts +66 -0
- package/src/scenarios/blob-roundtrip.test.ts +48 -0
- package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
- package/src/scenarios/cache-ttl-expiry.test.ts +47 -0
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +98 -0
- package/src/scenarios/dispatch-input-mapping.test.ts +94 -0
- package/src/scenarios/dispatch-output-mapping.test.ts +65 -0
- package/src/scenarios/fs-path-traversal.test.ts +124 -0
- package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
- package/src/scenarios/kv-atomic-increment.test.ts +74 -0
- package/src/scenarios/kv-cas.test.ts +75 -0
- package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
- package/src/scenarios/kv-ttl-expiry.test.ts +47 -0
- package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
- package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
- package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
- package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
- package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
- package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +13 -6
- package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
- package/src/scenarios/multi-region-idempotency.test.ts +39 -4
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
- package/src/scenarios/pause-resume.test.ts +43 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +67 -0
- package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +48 -0
- package/src/scenarios/registry-public.test.ts +91 -0
- package/src/scenarios/search-bm25-roundtrip.test.ts +47 -0
- package/src/scenarios/spec-corpus-validity.test.ts +28 -7
- package/src/scenarios/sql-injection-rejection.test.ts +84 -0
- package/src/scenarios/sql-transaction-atomicity.test.ts +66 -0
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +66 -0
- package/src/scenarios/subworkflow-input-mapping.test.ts +100 -0
- package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
- package/src/scenarios/table-cursor-pagination.test.ts +47 -0
- package/src/scenarios/table-schema-enforcement.test.ts +47 -0
- package/src/scenarios/vector-knn-roundtrip.test.ts +48 -0
- package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
- package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
- package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
package/src/lib/behavior-gate.ts
CHANGED
|
@@ -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
|
|
46
|
-
* mode (`OPENWOP_REQUIRE_BEHAVIOR=true`)
|
|
47
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|