Skip to content

Commit d251cd6

Browse files
committed
fix: check target run capabilities before encrypting hook payloads
When resumeHook()/resumeWebhook() is called on a newer deployment that supports encryption, it would encode the payload with the 'encr' format. If the target workflow run was created by an older deployment that predates encryption support, the run would fail with: Error: Unknown serialization format: "encr". Known formats: devl Add a capabilities table that maps @workflow/core versions to supported serialization formats. Before encoding, resumeHook() now checks the target run's workflowCoreVersion and suppresses encryption when the run's deployment doesn't support it.
1 parent 329cdb3 commit d251cd6

8 files changed

Lines changed: 159 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/core": patch
3+
---
4+
5+
Fix `resumeHook()`/`resumeWebhook()` failing on workflow runs from pre-encryption deployments by checking the target run's `workflowCoreVersion` capabilities before encoding the payload

packages/core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,14 @@
9393
"ms": "2.1.3",
9494
"nanoid": "5.1.6",
9595
"seedrandom": "3.0.5",
96+
"semver": "catalog:",
9697
"ulid": "catalog:",
9798
"zod": "catalog:"
9899
},
99100
"devDependencies": {
100101
"@opentelemetry/api": "1.9.0",
101102
"@types/debug": "4.1.12",
103+
"@types/semver": "7.7.1",
102104
"@types/node": "catalog:",
103105
"@types/seedrandom": "3.0.8",
104106
"@workflow/tsconfig": "workspace:*",
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getRunCapabilities } from './capabilities.js';
3+
import { SerializationFormat } from './serialization.js';
4+
5+
describe('getRunCapabilities', () => {
6+
describe('undefined version (very old runs)', () => {
7+
it('only supports baseline formats', () => {
8+
const { supportedFormats } = getRunCapabilities(undefined);
9+
expect(supportedFormats.has(SerializationFormat.DEVALUE_V1)).toBe(true);
10+
expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(false);
11+
});
12+
});
13+
14+
describe('pre-encryption versions', () => {
15+
it.each([
16+
'4.1.0-beta.63',
17+
'4.0.1-beta.27',
18+
'3.0.0',
19+
])('%s does not support encryption', (version) => {
20+
const { supportedFormats } = getRunCapabilities(version);
21+
expect(supportedFormats.has(SerializationFormat.DEVALUE_V1)).toBe(true);
22+
expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(false);
23+
});
24+
});
25+
26+
describe('encryption-capable versions', () => {
27+
it('supports encryption at the exact cutoff version', () => {
28+
const { supportedFormats } = getRunCapabilities('4.2.0-beta.64');
29+
expect(supportedFormats.has(SerializationFormat.DEVALUE_V1)).toBe(true);
30+
expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(true);
31+
});
32+
33+
it.each([
34+
'4.2.0-beta.74',
35+
'4.2.0',
36+
'5.0.0',
37+
])('%s supports encryption', (version) => {
38+
const { supportedFormats } = getRunCapabilities(version);
39+
expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(true);
40+
});
41+
});
42+
});

packages/core/src/capabilities.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Capabilities table for workflow runs based on their `@workflow/core` version.
3+
*
4+
* When resuming a hook or webhook, the payload must be encoded in a format
5+
* that the *target* workflow run's deployment can decode. This module provides
6+
* a way to look up what serialization formats a given `@workflow/core` version
7+
* supports, so that newer deployments can avoid encoding payloads in formats
8+
* that older deployments don't understand (e.g., the `encr` encryption format).
9+
*
10+
* ## Adding a new format
11+
*
12+
* When a new serialization format is introduced:
13+
* 1. Add the format constant to `SerializationFormat` in `serialization.ts`
14+
* 2. Add an entry to `FORMAT_VERSION_TABLE` below with the minimum
15+
* `@workflow/core` version that supports it
16+
* 3. The `getRunCapabilities()` function will automatically include it
17+
*/
18+
19+
import semver from 'semver';
20+
import {
21+
SerializationFormat,
22+
type SerializationFormatType,
23+
} from './serialization.js';
24+
25+
/**
26+
* Capabilities of a workflow run based on its `@workflow/core` version.
27+
*/
28+
export interface RunCapabilities {
29+
/**
30+
* The set of serialization format prefixes that the target run can decode.
31+
* Use `supportedFormats.has(SerializationFormat.ENCRYPTED)` to check
32+
* if encryption is supported, etc.
33+
*/
34+
supportedFormats: ReadonlySet<SerializationFormatType>;
35+
}
36+
37+
/**
38+
* Maps serialization format identifiers to the minimum `@workflow/core`
39+
* version that introduced support for them. Formats not listed here are
40+
* assumed to be supported by all specVersion 2 runs (e.g., `devl`).
41+
*/
42+
const FORMAT_VERSION_TABLE: ReadonlyArray<{
43+
format: SerializationFormatType;
44+
minVersion: string;
45+
}> = [
46+
{ format: SerializationFormat.ENCRYPTED, minVersion: '4.2.0-beta.64' },
47+
// Future entries:
48+
// { format: SerializationFormat.CBOR, minVersion: '5.x.y' },
49+
// { format: SerializationFormat.ENCRYPTED_V2, minVersion: '5.x.y' },
50+
];
51+
52+
/**
53+
* The set of formats supported by all specVersion 2 runs, regardless of
54+
* `@workflow/core` version. These are the baseline formats that were present
55+
* from the start of the specVersion 2 protocol.
56+
*/
57+
const BASELINE_FORMATS: ReadonlySet<SerializationFormatType> = new Set([
58+
SerializationFormat.DEVALUE_V1,
59+
]);
60+
61+
/**
62+
* Look up what serialization capabilities a workflow run supports based on
63+
* its `@workflow/core` version string (from `executionContext.workflowCoreVersion`).
64+
*
65+
* When the version is `undefined` (e.g. very old runs that predate the field),
66+
* we assume the most conservative capabilities (baseline formats only).
67+
*/
68+
export function getRunCapabilities(
69+
workflowCoreVersion: string | undefined
70+
): RunCapabilities {
71+
if (!workflowCoreVersion) {
72+
return { supportedFormats: BASELINE_FORMATS };
73+
}
74+
75+
const formats = new Set<SerializationFormatType>(BASELINE_FORMATS);
76+
77+
for (const { format, minVersion } of FORMAT_VERSION_TABLE) {
78+
if (semver.gte(workflowCoreVersion, minVersion)) {
79+
formats.add(format);
80+
}
81+
}
82+
83+
return { supportedFormats: formats };
84+
}

packages/core/src/runtime/resume-hook.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import {
1111
type WorkflowInvokePayload,
1212
type WorkflowRun,
1313
} from '@workflow/world';
14+
import { getRunCapabilities } from '../capabilities.js';
1415
import { type CryptoKey, importKey } from '../encryption.js';
1516
import {
1617
dehydrateStepReturnValue,
1718
hydrateStepArguments,
19+
SerializationFormat,
1820
} from '../serialization.js';
1921
import { WEBHOOK_RESPONSE_WRITABLE } from '../symbols.js';
2022
import * as Attribute from '../telemetry/semantic-conventions.js';
@@ -123,6 +125,17 @@ export async function resumeHook<T = any>(
123125
...Attribute.WorkflowRunId(hook.runId),
124126
});
125127

128+
// Check the target run's capabilities to ensure we encode the
129+
// payload in a format the run's deployment can decode. For example,
130+
// runs created before encryption support was added cannot decode
131+
// the 'encr' serialization format.
132+
const { supportedFormats } = getRunCapabilities(
133+
workflowRun.executionContext?.workflowCoreVersion
134+
);
135+
if (!supportedFormats.has(SerializationFormat.ENCRYPTED)) {
136+
encryptionKey = undefined;
137+
}
138+
126139
// Dehydrate the payload for storage
127140
const ops: Promise<any>[] = [];
128141
const v1Compat = isLegacySpecVersion(hook.specVersion);

packages/next/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"@workflow/builders": "workspace:*",
3838
"@workflow/core": "workspace:*",
3939
"@workflow/swc-plugin": "workspace:*",
40-
"semver": "7.7.4",
40+
"semver": "catalog:",
4141
"watchpack": "2.5.1"
4242
},
4343
"devDependencies": {

pnpm-lock.yaml

Lines changed: 11 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ catalog:
1616
ai: 6.0.116
1717
esbuild: ^0.27.3
1818
nitro: 3.0.1-alpha.1
19+
semver: 7.7.4
1920
typescript: ^5.9.3
2021
ulid: ~3.0.1
2122
undici: 7.22.0

0 commit comments

Comments
 (0)