From d251cd6304723240809cc9f443eb72dccbf15a41 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 31 Mar 2026 14:48:28 -0700 Subject: [PATCH] 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. --- .../fix-hook-resume-encryption-compat.md | 5 ++ packages/core/package.json | 2 + packages/core/src/capabilities.test.ts | 42 ++++++++++ packages/core/src/capabilities.ts | 84 +++++++++++++++++++ packages/core/src/runtime/resume-hook.ts | 13 +++ packages/next/package.json | 2 +- pnpm-lock.yaml | 20 +++-- pnpm-workspace.yaml | 1 + 8 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-hook-resume-encryption-compat.md create mode 100644 packages/core/src/capabilities.test.ts create mode 100644 packages/core/src/capabilities.ts diff --git a/.changeset/fix-hook-resume-encryption-compat.md b/.changeset/fix-hook-resume-encryption-compat.md new file mode 100644 index 0000000000..87d3ee099d --- /dev/null +++ b/.changeset/fix-hook-resume-encryption-compat.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Fix `resumeHook()`/`resumeWebhook()` failing on workflow runs from pre-encryption deployments by checking the target run's `workflowCoreVersion` capabilities before encoding the payload diff --git a/packages/core/package.json b/packages/core/package.json index 1a1f215713..e125080cc0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -93,12 +93,14 @@ "ms": "2.1.3", "nanoid": "5.1.6", "seedrandom": "3.0.5", + "semver": "catalog:", "ulid": "catalog:", "zod": "catalog:" }, "devDependencies": { "@opentelemetry/api": "1.9.0", "@types/debug": "4.1.12", + "@types/semver": "7.7.1", "@types/node": "catalog:", "@types/seedrandom": "3.0.8", "@workflow/tsconfig": "workspace:*", diff --git a/packages/core/src/capabilities.test.ts b/packages/core/src/capabilities.test.ts new file mode 100644 index 0000000000..50b48bebc4 --- /dev/null +++ b/packages/core/src/capabilities.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { getRunCapabilities } from './capabilities.js'; +import { SerializationFormat } from './serialization.js'; + +describe('getRunCapabilities', () => { + describe('undefined version (very old runs)', () => { + it('only supports baseline formats', () => { + const { supportedFormats } = getRunCapabilities(undefined); + expect(supportedFormats.has(SerializationFormat.DEVALUE_V1)).toBe(true); + expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(false); + }); + }); + + describe('pre-encryption versions', () => { + it.each([ + '4.1.0-beta.63', + '4.0.1-beta.27', + '3.0.0', + ])('%s does not support encryption', (version) => { + const { supportedFormats } = getRunCapabilities(version); + expect(supportedFormats.has(SerializationFormat.DEVALUE_V1)).toBe(true); + expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(false); + }); + }); + + describe('encryption-capable versions', () => { + it('supports encryption at the exact cutoff version', () => { + const { supportedFormats } = getRunCapabilities('4.2.0-beta.64'); + expect(supportedFormats.has(SerializationFormat.DEVALUE_V1)).toBe(true); + expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(true); + }); + + it.each([ + '4.2.0-beta.74', + '4.2.0', + '5.0.0', + ])('%s supports encryption', (version) => { + const { supportedFormats } = getRunCapabilities(version); + expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(true); + }); + }); +}); diff --git a/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts new file mode 100644 index 0000000000..3b9fc5e724 --- /dev/null +++ b/packages/core/src/capabilities.ts @@ -0,0 +1,84 @@ +/** + * Capabilities table for workflow runs based on their `@workflow/core` version. + * + * When resuming a hook or webhook, the payload must be encoded in a format + * that the *target* workflow run's deployment can decode. This module provides + * a way to look up what serialization formats a given `@workflow/core` version + * supports, so that newer deployments can avoid encoding payloads in formats + * that older deployments don't understand (e.g., the `encr` encryption format). + * + * ## Adding a new format + * + * When a new serialization format is introduced: + * 1. Add the format constant to `SerializationFormat` in `serialization.ts` + * 2. Add an entry to `FORMAT_VERSION_TABLE` below with the minimum + * `@workflow/core` version that supports it + * 3. The `getRunCapabilities()` function will automatically include it + */ + +import semver from 'semver'; +import { + SerializationFormat, + type SerializationFormatType, +} from './serialization.js'; + +/** + * Capabilities of a workflow run based on its `@workflow/core` version. + */ +export interface RunCapabilities { + /** + * The set of serialization format prefixes that the target run can decode. + * Use `supportedFormats.has(SerializationFormat.ENCRYPTED)` to check + * if encryption is supported, etc. + */ + supportedFormats: ReadonlySet; +} + +/** + * Maps serialization format identifiers to the minimum `@workflow/core` + * version that introduced support for them. Formats not listed here are + * assumed to be supported by all specVersion 2 runs (e.g., `devl`). + */ +const FORMAT_VERSION_TABLE: ReadonlyArray<{ + format: SerializationFormatType; + minVersion: string; +}> = [ + { format: SerializationFormat.ENCRYPTED, minVersion: '4.2.0-beta.64' }, + // Future entries: + // { format: SerializationFormat.CBOR, minVersion: '5.x.y' }, + // { format: SerializationFormat.ENCRYPTED_V2, minVersion: '5.x.y' }, +]; + +/** + * The set of formats supported by all specVersion 2 runs, regardless of + * `@workflow/core` version. These are the baseline formats that were present + * from the start of the specVersion 2 protocol. + */ +const BASELINE_FORMATS: ReadonlySet = new Set([ + SerializationFormat.DEVALUE_V1, +]); + +/** + * Look up what serialization capabilities a workflow run supports based on + * its `@workflow/core` version string (from `executionContext.workflowCoreVersion`). + * + * When the version is `undefined` (e.g. very old runs that predate the field), + * we assume the most conservative capabilities (baseline formats only). + */ +export function getRunCapabilities( + workflowCoreVersion: string | undefined +): RunCapabilities { + if (!workflowCoreVersion) { + return { supportedFormats: BASELINE_FORMATS }; + } + + const formats = new Set(BASELINE_FORMATS); + + for (const { format, minVersion } of FORMAT_VERSION_TABLE) { + if (semver.gte(workflowCoreVersion, minVersion)) { + formats.add(format); + } + } + + return { supportedFormats: formats }; +} diff --git a/packages/core/src/runtime/resume-hook.ts b/packages/core/src/runtime/resume-hook.ts index fedc1de3da..6c51ba1fb2 100644 --- a/packages/core/src/runtime/resume-hook.ts +++ b/packages/core/src/runtime/resume-hook.ts @@ -11,10 +11,12 @@ import { type WorkflowInvokePayload, type WorkflowRun, } from '@workflow/world'; +import { getRunCapabilities } from '../capabilities.js'; import { type CryptoKey, importKey } from '../encryption.js'; import { dehydrateStepReturnValue, hydrateStepArguments, + SerializationFormat, } from '../serialization.js'; import { WEBHOOK_RESPONSE_WRITABLE } from '../symbols.js'; import * as Attribute from '../telemetry/semantic-conventions.js'; @@ -123,6 +125,17 @@ export async function resumeHook( ...Attribute.WorkflowRunId(hook.runId), }); + // Check the target run's capabilities to ensure we encode the + // payload in a format the run's deployment can decode. For example, + // runs created before encryption support was added cannot decode + // the 'encr' serialization format. + const { supportedFormats } = getRunCapabilities( + workflowRun.executionContext?.workflowCoreVersion + ); + if (!supportedFormats.has(SerializationFormat.ENCRYPTED)) { + encryptionKey = undefined; + } + // Dehydrate the payload for storage const ops: Promise[] = []; const v1Compat = isLegacySpecVersion(hook.specVersion); diff --git a/packages/next/package.json b/packages/next/package.json index 2b0e3490cf..c2e431c472 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -37,7 +37,7 @@ "@workflow/builders": "workspace:*", "@workflow/core": "workspace:*", "@workflow/swc-plugin": "workspace:*", - "semver": "7.7.4", + "semver": "catalog:", "watchpack": "2.5.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70311f2e81..47678829b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ catalogs: nitro: specifier: 3.0.1-alpha.1 version: 3.0.1-alpha.1 + semver: + specifier: 7.7.4 + version: 7.7.4 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -588,6 +591,9 @@ importers: seedrandom: specifier: 3.0.5 version: 3.0.5 + semver: + specifier: 'catalog:' + version: 7.7.4 ulid: specifier: 'catalog:' version: 3.0.1 @@ -607,6 +613,9 @@ importers: '@types/seedrandom': specifier: 3.0.8 version: 3.0.8 + '@types/semver': + specifier: 7.7.1 + version: 7.7.1 '@workflow/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -737,7 +746,7 @@ importers: specifier: workspace:* version: link:../swc-plugin-workflow semver: - specifier: 7.7.4 + specifier: 'catalog:' version: 7.7.4 watchpack: specifier: 2.5.1 @@ -13619,11 +13628,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -21881,7 +21885,7 @@ snapshots: fast-glob: 3.3.3 minimatch: 9.0.5 piscina: 4.9.2 - semver: 7.7.3 + semver: 7.7.4 slash: 3.0.0 source-map: 0.7.6 optionalDependencies: @@ -30219,8 +30223,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} - semver@7.7.4: {} send@0.19.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 80605c1166..667f4878f3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ catalog: ai: 6.0.116 esbuild: ^0.27.3 nitro: 3.0.1-alpha.1 + semver: 7.7.4 typescript: ^5.9.3 ulid: ~3.0.1 undici: 7.22.0