Skip to content

Commit 6e72b29

Browse files
authored
Add World.getEncryptionKeyForRun and thread encryption key through serialization (vercel#979)
## Summary - Adds `World.getEncryptionKeyForRun(run)` returning `Uint8Array | undefined` as the interface for retrieving per-run encryption keys - Updates all 8 dehydrate/hydrate serialization functions to accept `key: Uint8Array | undefined` - Updates runtime callers, CLI, and tests to thread the key parameter through
1 parent 0d5323c commit 6e72b29

25 files changed

Lines changed: 1558 additions & 409 deletions

.changeset/encryptor-interface.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@workflow/core": patch
3+
"@workflow/world": patch
4+
"@workflow/cli": patch
5+
"@workflow/world-testing": patch
6+
---
7+
8+
Add `World.getEncryptionKeyForRun()` and thread encryption key through serialization layer

packages/cli/src/lib/inspect/hydration.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,20 @@ function getRevivers(): Revivers {
128128
// Public API
129129
// ---------------------------------------------------------------------------
130130

131+
/** Resolver function that retrieves the encryption key for a given run ID. */
132+
export type EncryptionKeyResolver =
133+
| ((runId: string) => Promise<Uint8Array | undefined>)
134+
| null;
135+
131136
/**
132137
* Hydrate the serialized data fields of a resource for CLI display.
138+
*
139+
* The optional `_encryptionKeyResolver` parameter is accepted for forward
140+
* compatibility with encryption support but is not yet used.
133141
*/
134-
export function hydrateResourceIO<T>(resource: T): T {
142+
export function hydrateResourceIO<T>(
143+
resource: T,
144+
_encryptionKeyResolver?: EncryptionKeyResolver
145+
): T {
135146
return hydrateResourceIOGeneric(resource as any, getRevivers()) as T;
136147
}

packages/cli/src/lib/inspect/output.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ import type {
1616
World,
1717
} from '@workflow/world';
1818
import chalk from 'chalk';
19+
20+
/** A function that resolves an encryption key for a given runId. */
21+
export type EncryptionKeyResolver =
22+
| ((runId: string) => Promise<Uint8Array | undefined>)
23+
| null;
24+
25+
/** Create an EncryptionKeyResolver from a World instance */
26+
function createResolver(world: World): EncryptionKeyResolver {
27+
if (!world.getEncryptionKeyForRun) return null;
28+
return (runId: string) => world.getEncryptionKeyForRun!(runId);
29+
}
30+
1931
import { formatDistance } from 'date-fns';
2032
import Table from 'easy-table';
2133
import { logger } from '../config/log.js';
@@ -507,6 +519,7 @@ const inlineFormatIO = <T>(io: T, topLevel: boolean = true): string => {
507519
};
508520

509521
export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
522+
const resolveKey = createResolver(world);
510523
if (opts.stepId || opts.runId) {
511524
logger.warn(
512525
'Filtering by step-id or run-id is not supported in list calls, ignoring filter.'
@@ -535,7 +548,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
535548
resolveData,
536549
});
537550
const runsWithHydratedIO = await Promise.all(
538-
runs.data.map(hydrateResourceIO)
551+
runs.data.map((r) => hydrateResourceIO(r, resolveKey))
539552
);
540553
showJson({ ...runs, data: runsWithHydratedIO });
541554
return;
@@ -574,7 +587,9 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
574587
}
575588
},
576589
displayPage: async (runs) => {
577-
const runsWithHydratedIO = await Promise.all(runs.map(hydrateResourceIO));
590+
const runsWithHydratedIO = await Promise.all(
591+
runs.map((r) => hydrateResourceIO(r, resolveKey))
592+
);
578593
logger.log(showTable(runsWithHydratedIO, props, opts));
579594
},
580595
});
@@ -584,13 +599,16 @@ export const getRecentRun = async (
584599
world: World,
585600
opts: InspectCLIOptions = {}
586601
) => {
602+
const resolveKey = createResolver(world);
587603
logger.warn(`No runId provided, fetching data for latest run instead.`);
588604
try {
589605
const runs = await world.runs.list({
590606
pagination: { limit: 1, sortOrder: opts.sort || 'desc' },
591607
resolveData: 'none', // Don't need data for just getting the ID
592608
});
593-
runs.data = await Promise.all(runs.data.map(hydrateResourceIO));
609+
runs.data = await Promise.all(
610+
runs.data.map((r) => hydrateResourceIO(r, resolveKey))
611+
);
594612
return runs.data[0];
595613
} catch (error) {
596614
if (handleApiError(error, opts.backend)) {
@@ -605,12 +623,13 @@ export const showRun = async (
605623
runId: string,
606624
opts: InspectCLIOptions = {}
607625
) => {
626+
const resolveKey = createResolver(world);
608627
if (opts.withData) {
609628
logger.warn('`withData` flag is ignored when showing individual resources');
610629
}
611630
try {
612631
const run = await world.runs.get(runId, { resolveData: 'all' });
613-
const runWithHydratedIO = await hydrateResourceIO(run);
632+
const runWithHydratedIO = await hydrateResourceIO(run, resolveKey);
614633
if (opts.json) {
615634
showJson(runWithHydratedIO);
616635
return;
@@ -636,6 +655,7 @@ export const listSteps = async (
636655
runId: undefined,
637656
}
638657
) => {
658+
const resolveKey = createResolver(world);
639659
if (opts.stepId) {
640660
logger.warn(
641661
'Filtering by step-id is not supported in list calls, ignoring filter.'
@@ -714,7 +734,7 @@ export const listSteps = async (
714734
},
715735
displayPage: async (steps) => {
716736
const stepsWithHydratedIO = await Promise.all(
717-
steps.map(hydrateResourceIO)
737+
steps.map((s) => hydrateResourceIO(s, resolveKey))
718738
);
719739
logger.log(showTable(stepsWithHydratedIO, props, opts));
720740
showInspectInfoBox('step');
@@ -727,6 +747,7 @@ export const showStep = async (
727747
stepId: string,
728748
opts: InspectCLIOptions = {}
729749
) => {
750+
const resolveKey = createResolver(world);
730751
if (opts.withData) {
731752
logger.warn('`withData` flag is ignored when showing individual resources');
732753
}
@@ -739,7 +760,7 @@ export const showStep = async (
739760
const step = await world.steps.get(opts.runId, stepId, {
740761
resolveData: 'all',
741762
});
742-
const stepWithHydratedIO = await hydrateResourceIO(step);
763+
const stepWithHydratedIO = await hydrateResourceIO(step, resolveKey);
743764
if (opts.json) {
744765
showJson(stepWithHydratedIO);
745766
return;
@@ -923,6 +944,7 @@ export const listEvents = async (
923944
};
924945

925946
export const listHooks = async (world: World, opts: InspectCLIOptions = {}) => {
947+
const resolveKey = createResolver(world);
926948
if (opts.workflowName) {
927949
logger.warn(
928950
'Filtering by workflow-name is not supported for hooks, ignoring filter.'
@@ -955,7 +977,7 @@ export const listHooks = async (world: World, opts: InspectCLIOptions = {}) => {
955977
resolveData,
956978
});
957979
const hydratedHooks = await Promise.all(
958-
hooks.data.map(hydrateResourceIO)
980+
hooks.data.map((h) => hydrateResourceIO(h, resolveKey))
959981
);
960982
showJson({ ...hooks, data: hydratedHooks });
961983
return;
@@ -1000,7 +1022,9 @@ export const listHooks = async (world: World, opts: InspectCLIOptions = {}) => {
10001022
}
10011023
},
10021024
displayPage: async (hooks) => {
1003-
const hydratedHooks = await Promise.all(hooks.map(hydrateResourceIO));
1025+
const hydratedHooks = await Promise.all(
1026+
hooks.map((h) => hydrateResourceIO(h, resolveKey))
1027+
);
10041028
logger.log(showTable(hydratedHooks, HOOK_LISTED_PROPS, opts));
10051029
showInspectInfoBox('hook');
10061030
},
@@ -1012,14 +1036,15 @@ export const showHook = async (
10121036
hookId: string,
10131037
opts: InspectCLIOptions = {}
10141038
) => {
1039+
const resolveKey = createResolver(world);
10151040
if (opts.withData) {
10161041
logger.warn('`withData` flag is ignored when showing individual resources');
10171042
}
10181043
try {
10191044
const hook = await world.hooks.get(hookId, {
10201045
resolveData: 'all',
10211046
});
1022-
const hydratedHook = await hydrateResourceIO(hook);
1047+
const hydratedHook = await hydrateResourceIO(hook, resolveKey);
10231048
if (opts.json) {
10241049
showJson(hydratedHook);
10251050
return;

packages/core/src/private.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export function getStepFunction(stepId: string): StepFunction | undefined {
8888
export { __private_getClosureVars } from './step/get-closure-vars.js';
8989

9090
export interface WorkflowOrchestratorContext {
91+
runId: string;
92+
encryptionKey: Uint8Array | undefined;
9193
globalThis: typeof globalThis;
9294
eventsConsumer: EventsConsumer;
9395
/**

packages/core/src/runtime.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,14 @@ export function workflowEntrypoint(
249249
replaySpan?.setAttributes({
250250
...Attribute.WorkflowEventsCount(events.length),
251251
});
252+
// Resolve the encryption key for this run's deployment
253+
const encryptionKey =
254+
await world.getEncryptionKeyForRun?.(runId);
252255
return await runWorkflow(
253256
workflowCode,
254257
workflowRun,
255-
events
258+
events,
259+
encryptionKey
256260
);
257261
}
258262
);

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

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,33 @@ import { getWorkflowQueueName } from './helpers.js';
1818
import { getWorld } from './world.js';
1919

2020
/**
21-
* Get the hook by token to find the associated workflow run,
22-
* and hydrate the `metadata` property if it was set from within
23-
* the workflow run.
24-
*
25-
* @param token - The unique token identifying the hook
21+
* Internal helper that returns both the hook and the resolved encryption key.
2622
*/
27-
export async function getHookByToken(token: string): Promise<Hook> {
23+
async function getHookByTokenWithKey(
24+
token: string
25+
): Promise<{ hook: Hook; encryptionKey: Uint8Array | undefined }> {
2826
const world = getWorld();
2927
const hook = await world.hooks.getByToken(token);
28+
const encryptionKey = await world.getEncryptionKeyForRun?.(hook.runId);
3029
if (typeof hook.metadata !== 'undefined') {
3130
hook.metadata = await hydrateStepArguments(
3231
hook.metadata as any,
33-
[],
34-
hook.runId
32+
hook.runId,
33+
encryptionKey
3534
);
3635
}
36+
return { hook, encryptionKey };
37+
}
38+
39+
/**
40+
* Get the hook by token to find the associated workflow run,
41+
* and hydrate the `metadata` property if it was set from within
42+
* the workflow run.
43+
*
44+
* @param token - The unique token identifying the hook
45+
*/
46+
export async function getHookByToken(token: string): Promise<Hook> {
47+
const { hook } = await getHookByTokenWithKey(token);
3748
return hook;
3849
}
3950

@@ -68,17 +79,26 @@ export async function getHookByToken(token: string): Promise<Hook> {
6879
*/
6980
export async function resumeHook<T = any>(
7081
tokenOrHook: string | Hook,
71-
payload: T
82+
payload: T,
83+
encryptionKeyOverride?: Uint8Array | undefined
7284
): Promise<Hook> {
7385
return await waitedUntil(() => {
7486
return trace('hook.resume', async (span) => {
7587
const world = getWorld();
7688

7789
try {
78-
const hook =
79-
typeof tokenOrHook === 'string'
80-
? await getHookByToken(tokenOrHook)
81-
: tokenOrHook;
90+
let hook: Hook;
91+
let encryptionKey: Uint8Array | undefined;
92+
if (typeof tokenOrHook === 'string') {
93+
const result = await getHookByTokenWithKey(tokenOrHook);
94+
hook = result.hook;
95+
encryptionKey = encryptionKeyOverride ?? result.encryptionKey;
96+
} else {
97+
hook = tokenOrHook;
98+
encryptionKey =
99+
encryptionKeyOverride ??
100+
(await world.getEncryptionKeyForRun?.(hook.runId));
101+
}
82102

83103
span?.setAttributes({
84104
...Attribute.HookToken(hook.token),
@@ -91,8 +111,9 @@ export async function resumeHook<T = any>(
91111
const v1Compat = isLegacySpecVersion(hook.specVersion);
92112
const dehydratedPayload = await dehydrateStepReturnValue(
93113
payload,
94-
ops,
95114
hook.runId,
115+
encryptionKey,
116+
ops,
96117
globalThis,
97118
v1Compat
98119
);
@@ -200,7 +221,7 @@ export async function resumeWebhook(
200221
token: string,
201222
request: Request
202223
): Promise<Response> {
203-
const hook = await getHookByToken(token);
224+
const { hook, encryptionKey } = await getHookByTokenWithKey(token);
204225

205226
let response: Response | undefined;
206227
let responseReadable: ReadableStream<Response> | undefined;
@@ -229,7 +250,7 @@ export async function resumeWebhook(
229250
response = new Response(null, { status: 202 });
230251
}
231252

232-
await resumeHook(hook, request);
253+
await resumeHook(hook, request, encryptionKey);
233254

234255
if (responseReadable) {
235256
// Wait for the readable stream to emit one chunk,

packages/core/src/runtime/run.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,14 @@ export class Run<TResult> {
153153
const run = await this.world.runs.get(this.runId);
154154

155155
if (run.status === 'completed') {
156-
return await hydrateWorkflowReturnValue(run.output, [], this.runId);
156+
const encryptionKey = await this.world.getEncryptionKeyForRun?.(
157+
this.runId
158+
);
159+
return await hydrateWorkflowReturnValue(
160+
run.output,
161+
this.runId,
162+
encryptionKey
163+
);
157164
}
158165

159166
if (run.status === 'cancelled') {

packages/core/src/runtime/runs.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,14 @@ export async function recreateRunFromExisting(
4949
): Promise<string> {
5050
try {
5151
const run = await world.runs.get(runId, { resolveData: 'all' });
52+
const encryptionKey = await world.getEncryptionKeyForRun?.(runId);
5253
const workflowArgs = normalizeWorkflowArgs(
53-
await hydrateWorkflowArguments(run.input, globalThis)
54+
await hydrateWorkflowArguments(
55+
run.input,
56+
runId,
57+
encryptionKey,
58+
globalThis
59+
)
5460
);
5561
const specVersion =
5662
options.specVersion ?? run.specVersion ?? SPEC_VERSION_LEGACY;

packages/core/src/runtime/start.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,19 @@ export async function start<TArgs extends unknown[], TResult>(
117117
const specVersion = opts.specVersion ?? SPEC_VERSION_CURRENT;
118118
const v1Compat = isLegacySpecVersion(specVersion);
119119

120+
// Resolve encryption key for the new run. The runId has already been
121+
// generated above (client-generated ULID) and will be used for both
122+
// key derivation and the run_created event. The World implementation
123+
// uses the runId for per-run HKDF key derivation.
124+
const encryptionKey = await world.getEncryptionKeyForRun?.(runId);
125+
120126
// Create run via run_created event (event-sourced architecture)
121127
// Pass client-generated runId - server will accept and use it
122128
const workflowArguments = await dehydrateWorkflowArguments(
123129
args,
124-
ops,
125130
runId,
131+
encryptionKey,
132+
ops,
126133
globalThis,
127134
v1Compat
128135
);

0 commit comments

Comments
 (0)