Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
b9285f1
Add browser-compatible AES-GCM to core and HKDF key derivation to wor…
TooTallNate Feb 6, 2026
09b5354
update changeset
TooTallNate Feb 14, 2026
87dbb9c
Move HKDF key derivation server-side: API returns per-run derived key
TooTallNate Feb 15, 2026
e041e20
Refactor encrypt/decrypt to accept CryptoKey, export importKey for ca…
TooTallNate Feb 18, 2026
61d0c47
Pass WorkflowRun entity to getEncryptionKeyForRun where available (av…
TooTallNate Feb 18, 2026
5d733dd
.
TooTallNate Feb 18, 2026
31420f5
Refactor handleSuspension to accept WorkflowRun, pass run entity to g…
TooTallNate Feb 18, 2026
6de4489
Overload getEncryptionKeyForRun: accept context for start(), fetch Wo…
TooTallNate Feb 19, 2026
4bde3e7
Split changeset into per-package descriptions for world, world-vercel…
TooTallNate Feb 19, 2026
eb5d3a6
Remove unnecessary Uint8Array.from() wrapper around Buffer.from()
TooTallNate Feb 19, 2026
99b63da
Use zod to parse Vercel API response
TooTallNate Feb 19, 2026
eaeb70e
Wire encryption into serialization layer
TooTallNate Feb 6, 2026
9828e58
Wire AES-GCM encryption into serialization layer
TooTallNate Feb 10, 2026
217cafd
update changeset
TooTallNate Feb 14, 2026
56eceb8
Add encryption unit tests: primitives, maybeEncrypt/maybeDecrypt, isE…
TooTallNate Feb 18, 2026
6d18ad1
Accept CryptoKey in encrypt/decrypt, export importKey for callers to …
TooTallNate Feb 18, 2026
65b06ba
Fix review comments: cache stream encryption key, remove redundant ca…
TooTallNate Feb 18, 2026
7dfe3f5
Trying to clean up some type non-sense
TooTallNate Feb 18, 2026
1ec73ba
Make EventsConsumer async-aware: remove all setTimeout(0) hacks from …
TooTallNate Feb 19, 2026
1eff2d5
Revert "Make EventsConsumer async-aware: remove all setTimeout(0) hac…
TooTallNate Feb 19, 2026
0155949
Add failing test: encrypted Promise.all replay triggers unconsumed ev…
TooTallNate Feb 19, 2026
02bfd98
Fix failing test
TooTallNate Feb 19, 2026
b8afbd7
.
TooTallNate Feb 19, 2026
c68e1d9
Add full encryption test suite to workflow.test.ts and attempt fix fo…
TooTallNate Feb 19, 2026
d5e1039
.
TooTallNate Feb 19, 2026
9236bda
Redesign EventsConsumer: scan-forward consume, watchdog timer, post-c…
TooTallNate Feb 19, 2026
9ff06de
Make decryption an explicit opt-in for o11y tooling
TooTallNate Feb 11, 2026
18e35f8
Restore encrypted data handling in o11y hydration layer
TooTallNate Feb 13, 2026
9ad205c
Use EncryptedDataRef with util.inspect.custom for CLI encrypted data …
TooTallNate Feb 13, 2026
2b2d9b6
Fix Decrypt button crash: use correct 'refresh' callback from useWork…
TooTallNate Feb 13, 2026
4fb407d
Implement client-side decryption for web o11y with getEncryptionKeyFo…
TooTallNate Feb 13, 2026
3157a45
Fix CLI decrypt: fetch WorkflowRun for key resolution, cache per runId
TooTallNate Feb 14, 2026
e6a7388
Use named constructor pattern for encrypted data display in web o11y
TooTallNate Feb 14, 2026
6d8bc3e
Decrypt event data when encryption key is available after Decrypt but…
TooTallNate Feb 14, 2026
a07fc21
Lift encryption key to run-level state, auto-decrypt on fetch, fix fi…
TooTallNate Feb 14, 2026
f23c7cb
Re-load expanded event data when encryption key becomes available
TooTallNate Feb 14, 2026
7374cbd
Consolidate Decrypt to title bar Button, remove sidebar decrypt card
TooTallNate Feb 14, 2026
7120677
Add hover tooltip to Decrypt button explaining scope and state
TooTallNate Feb 14, 2026
3bfe8ec
Show flat Encrypted label for encrypted fields, use Lucide Lock icon …
TooTallNate Feb 14, 2026
3f10420
Render eventData subfields individually to avoid encrypted markers in…
TooTallNate Feb 14, 2026
4a0c137
Revert: render eventData subfields individually
TooTallNate Feb 14, 2026
fbf470e
Fix Lock icon vertical alignment in DataInspector encrypted label
TooTallNate Feb 14, 2026
288691d
update changeset
TooTallNate Feb 14, 2026
7018c6c
Fix loading state never clearing for hook/sleep resource types
TooTallNate Feb 15, 2026
6766332
Update CLI, web, and stream callers for CryptoKey: importKey at resol…
TooTallNate Feb 18, 2026
9ef7740
Pass teamId to the get-key endpoint
TooTallNate Feb 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/e2e-encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Wire AES-GCM encryption into serialization layer with stream support
8 changes: 8 additions & 0 deletions .changeset/opt-in-decrypt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@workflow/cli": patch
"@workflow/core": patch
"@workflow/web": patch
"@workflow/web-shared": patch
---

Add encryption-aware o11y for CLI and web UI
5 changes: 5 additions & 0 deletions .changeset/vercel-encryption-world-vercel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world-vercel": patch
---

Implement `getEncryptionKeyForRun` with HKDF-SHA256 per-run key derivation and cross-deployment key resolution via `fetchRunKey` API
5 changes: 5 additions & 0 deletions .changeset/vercel-encryption-world.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world": patch
---

Overload `getEncryptionKeyForRun` interface: accept `WorkflowRun` (preferred) or `runId` string with optional opaque world-specific context for `start()`
5 changes: 5 additions & 0 deletions .changeset/vercel-encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Add browser-compatible AES-256-GCM encryption module with `importKey`, `encrypt`, and `decrypt` functions; update all runtime callers to resolve `CryptoKey` once per run via `importKey()`
9 changes: 9 additions & 0 deletions packages/cli/src/commands/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ export default class Inspect extends BaseCommand {
helpGroup: 'Display',
helpLabel: '-d, --withData',
}),
decrypt: Flags.boolean({
description:
'decrypt encrypted values (triggers audit-logged key retrieval)',
required: false,
default: false,
helpGroup: 'Display',
helpLabel: '--decrypt',
}),
...cliFlags,
} as const;

Expand Down Expand Up @@ -247,6 +255,7 @@ function toInspectOptions(flags: any): InspectCLIOptions {
limit: flags.limit,
workflowName: flags.workflowName,
withData: flags.withData,
decrypt: flags.decrypt,
backend: flags.backend,
interactive: flags.interactive,
};
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/lib/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ export type InspectCLIOptions = {
backend?: string;
disableRelativeDates?: boolean;
interactive?: boolean;
/** When true, decrypt encrypted values (triggers audit-logged key retrieval) */
decrypt?: boolean;
};
151 changes: 141 additions & 10 deletions packages/cli/src/lib/inspect/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,35 @@
*/

import { inspect } from 'node:util';
import { maybeDecrypt } from '@workflow/core/serialization';
import {
ClassInstanceRef,
extractClassName,
hydrateResourceIO as hydrateResourceIOGeneric,
isEncryptedData,
observabilityRevivers,
type Revivers,
} from '@workflow/core/serialization-format';
import { parseClassName } from '@workflow/utils/parse-name';
import chalk from 'chalk';

/**
* A function that resolves an encryption key for a run, or null to skip
* decryption. Accepts a runId — the resolver is responsible for looking
* up the WorkflowRun internally (with caching) if the World needs it.
*/
export type EncryptionKeyResolver =
| ((runId: string) => Promise<Uint8Array | undefined>)
| null;

// Re-export types and utilities that consumers need
export {
CLASS_INSTANCE_REF_TYPE,
ClassInstanceRef,
ENCRYPTED_PLACEHOLDER,
extractStreamIds,
isClassInstanceRef,
isEncryptedData,
isStreamId,
isStreamRef,
type Revivers,
Expand Down Expand Up @@ -53,6 +67,39 @@ class CLIClassInstanceRef extends ClassInstanceRef {
}
}

// ---------------------------------------------------------------------------
// CLI encrypted data placeholder with custom inspect
// ---------------------------------------------------------------------------

/**
* Placeholder object for encrypted data fields in CLI output.
*
* Uses `util.inspect.custom` to render as a styled, unquoted string
* (e.g., dim yellow "🔒 Encrypted") instead of a plain quoted string.
* Also provides `toJSON()` for `--json` output.
*/
class EncryptedDataRef {
[inspect.custom](): string {
return chalk.dim.yellow('\u{1F512} Encrypted');
}

toJSON(): string {
return '\u{1F512} Encrypted';
}

toString(): string {
return '\u{1F512} Encrypted';
}
}

/** Singleton encrypted data placeholder for CLI display */
const ENCRYPTED_REF = new EncryptedDataRef();

/** Check if a value is an EncryptedDataRef (for custom table formatting in CLI) */
export function isEncryptedRef(value: unknown): value is EncryptedDataRef {
return value instanceof EncryptedDataRef;
}

// ---------------------------------------------------------------------------
// CLI revivers (Node.js, uses Buffer)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -124,24 +171,108 @@ function getRevivers(): Revivers {
return cachedRevivers;
}

// ---------------------------------------------------------------------------
// Decryption helpers
// ---------------------------------------------------------------------------

/**
* Pre-process a resource's data fields: if the resolver is provided and
* the field is encrypted, decrypt it before generic hydration.
*
* Uses core's `maybeDecrypt()` which handles the 'encr' prefix stripping
* and AES-GCM decryption transparently.
*
* When the resolver is null (no --decrypt flag), encrypted fields pass
* through as Uint8Array and are replaced with EncryptedDataRef in post-processing.
*/
async function maybeDecryptFields<
T extends {
runId?: string;
input?: any;
output?: any;
metadata?: any;
eventData?: any;
},
>(resource: T, resolver: EncryptionKeyResolver): Promise<T> {
if (!resolver) return resource;

const runId = (resource as any).runId as string | undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: (resource as any).runId is used here but the generic T is already constrained to { runId?: string; ... } — you can just use resource.runId directly without the cast.

if (!runId) return resource;

const result = { ...resource };
const rawKey = await resolver(runId);
const { importKey } = await import('@workflow/core/encryption');
const k = rawKey ? await importKey(rawKey) : undefined;

// Decrypt input/output fields (WorkflowRun, Step)
result.input = await maybeDecrypt(result.input, k);
result.output = await maybeDecrypt(result.output, k);

// Decrypt metadata field (Hook)
result.metadata = await maybeDecrypt(result.metadata, k);

// Decrypt eventData fields (Event)
if (result.eventData && typeof result.eventData === 'object') {
const eventData = { ...result.eventData };
eventData.result = await maybeDecrypt(eventData.result, k);
eventData.input = await maybeDecrypt(eventData.input, k);
result.eventData = eventData;
}
Comment on lines +214 to +220
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI decryption only handles eventData.result and eventData.input. Core hydration supports additional serialized eventData fields (output, metadata, payload), so those will remain encrypted even with --decrypt. Extend this to cover the full set of known serialized eventData subfields to match core/web behavior.

Copilot uses AI. Check for mistakes.

return result;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/** Resolver function that retrieves the encryption key for a given run ID. */
export type EncryptionKeyResolver =
| ((runId: string) => Promise<Uint8Array | undefined>)
| null;
/**
* Replace encrypted Uint8Array values with EncryptedDataRef objects
* in known data fields so they render with custom inspect styling.
*/
function replaceEncryptedWithRef<T>(resource: T): T {
if (!resource || typeof resource !== 'object') return resource;
const r = resource as Record<string, unknown>;
const result = { ...r };

for (const key of ['input', 'output', 'metadata']) {
if (isEncryptedData(result[key])) {
result[key] = ENCRYPTED_REF;
}
}

if (result.eventData && typeof result.eventData === 'object') {
const ed = { ...(result.eventData as Record<string, unknown>) };
for (const key of ['result', 'input']) {
if (isEncryptedData(ed[key])) {
ed[key] = ENCRYPTED_REF;
}
}
result.eventData = ed;
Comment on lines +244 to +251
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Encrypted placeholder replacement for events only checks eventData.result and eventData.input. If eventData.output / metadata / payload are encrypted, they'll fall through and print as raw Uint8Array in CLI output. Update this loop to replace all known encrypted eventData subfields so CLI output is consistent.

Copilot uses AI. Check for mistakes.
}

return result as T;
}

/**
* Hydrate the serialized data fields of a resource for CLI display.
*
* The optional `_encryptionKeyResolver` parameter is accepted for forward
* compatibility with encryption support but is not yet used.
* When `encryptorResolver` is null (default / no --decrypt flag), encrypted
* fields are shown as styled "🔒 Encrypted" placeholders via EncryptedDataRef.
*
* When `encryptorResolver` is provided (--decrypt flag), encrypted fields
* are decrypted before hydration so the actual user data is displayed.
*/
export function hydrateResourceIO<T>(
export async function hydrateResourceIO<T>(
resource: T,
_encryptionKeyResolver?: EncryptionKeyResolver
): T {
return hydrateResourceIOGeneric(resource as any, getRevivers()) as T;
keyResolver?: EncryptionKeyResolver
): Promise<T> {
// Pre-process: decrypt any encrypted fields when a resolver is provided
const preprocessed = await maybeDecryptFields(
resource as any,
keyResolver ?? null
);
const hydrated = hydrateResourceIOGeneric(preprocessed, getRevivers()) as T;
// Post-process: swap encrypted Uint8Arrays for CLI-styled objects
return replaceEncryptedWithRef(hydrated);
}
64 changes: 45 additions & 19 deletions packages/cli/src/lib/inspect/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,39 @@ import type {
} from '@workflow/world';
import chalk from 'chalk';

/** A function that resolves an encryption key for a given runId. */
export type EncryptionKeyResolver =
| ((runId: string) => Promise<Uint8Array | undefined>)
| null;

/** Create an EncryptionKeyResolver from a World instance */
function createResolver(world: World): EncryptionKeyResolver {
if (!world.getEncryptionKeyForRun) return null;
return (runId: string) => world.getEncryptionKeyForRun!(runId);
}

import { formatDistance } from 'date-fns';
import Table from 'easy-table';
import { logger } from '../config/log.js';
import type { InspectCLIOptions } from '../config/types.js';
import { hydrateResourceIO } from './hydration.js';
import {
type EncryptionKeyResolver,
hydrateResourceIO,
isEncryptedRef,
} from './hydration.js';

/**
* Create an EncryptionKeyResolver from a World instance.
* Returns null if decrypt is false — encrypted data will show as a placeholder.
*
* The resolver fetches the full WorkflowRun (cached per runId) so that the
* World can inspect deployment-specific fields for key resolution.
*/
function createResolver(world: World, decrypt: boolean): EncryptionKeyResolver {
if (!decrypt) return null;
if (!world.getEncryptionKeyForRun) return null;
const cache = new Map<string, Promise<Uint8Array | undefined>>();
return (runId: string) => {
let cached = cache.get(runId);
if (!cached) {
cached = world.runs
.get(runId)
.then((run) => world.getEncryptionKeyForRun!(run));
cache.set(runId, cached);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache never evicts on failure — if world.runs.get() or world.getEncryptionKeyForRun() rejects for a given runId, the rejected promise is cached permanently. All subsequent decrypt attempts for that run will immediately fail with the same stale error.

Consider evicting on rejection:

cached = world.runs
  .get(runId)
  .then((run) => world.getEncryptionKeyForRun!(run))
  .catch((err) => {
    cache.delete(runId);
    throw err;
  });

}
return cached;
};
}

import { setupListPagination } from './pagination.js';
import { streamToConsole } from './stream.js';
import {
Expand Down Expand Up @@ -485,6 +502,8 @@ const inlineFormatIO = <T>(io: T, topLevel: boolean = true): string => {
value = '<empty>';
} else if (io === null) {
value = '<null>';
} else if (isEncryptedRef(io)) {
value = chalk.dim.yellow('\u{1F512} Encrypted');
} else if (io && Array.isArray(io)) {
if (io.length === 0) {
value = '<empty>';
Expand Down Expand Up @@ -519,7 +538,8 @@ const inlineFormatIO = <T>(io: T, topLevel: boolean = true): string => {
};

export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const resolveKey = createResolver(world);
const resolveKey = createResolver(world, opts?.decrypt ?? false);

if (opts.stepId || opts.runId) {
logger.warn(
'Filtering by step-id or run-id is not supported in list calls, ignoring filter.'
Expand Down Expand Up @@ -599,7 +619,8 @@ export const getRecentRun = async (
world: World,
opts: InspectCLIOptions = {}
) => {
const resolveKey = createResolver(world);
const resolveKey = createResolver(world, opts?.decrypt ?? false);

logger.warn(`No runId provided, fetching data for latest run instead.`);
try {
const runs = await world.runs.list({
Expand All @@ -623,7 +644,8 @@ export const showRun = async (
runId: string,
opts: InspectCLIOptions = {}
) => {
const resolveKey = createResolver(world);
const resolveKey = createResolver(world, opts?.decrypt ?? false);

if (opts.withData) {
logger.warn('`withData` flag is ignored when showing individual resources');
}
Expand Down Expand Up @@ -655,7 +677,8 @@ export const listSteps = async (
runId: undefined,
}
) => {
const resolveKey = createResolver(world);
const resolveKey = createResolver(world, opts?.decrypt ?? false);

if (opts.stepId) {
logger.warn(
'Filtering by step-id is not supported in list calls, ignoring filter.'
Expand Down Expand Up @@ -747,7 +770,8 @@ export const showStep = async (
stepId: string,
opts: InspectCLIOptions = {}
) => {
const resolveKey = createResolver(world);
const resolveKey = createResolver(world, opts?.decrypt ?? false);

if (opts.withData) {
logger.warn('`withData` flag is ignored when showing individual resources');
}
Expand Down Expand Up @@ -944,7 +968,8 @@ export const listEvents = async (
};

export const listHooks = async (world: World, opts: InspectCLIOptions = {}) => {
const resolveKey = createResolver(world);
const resolveKey = createResolver(world, opts?.decrypt ?? false);

if (opts.workflowName) {
logger.warn(
'Filtering by workflow-name is not supported for hooks, ignoring filter.'
Expand Down Expand Up @@ -1036,7 +1061,8 @@ export const showHook = async (
hookId: string,
opts: InspectCLIOptions = {}
) => {
const resolveKey = createResolver(world);
const resolveKey = createResolver(world, opts?.decrypt ?? false);

if (opts.withData) {
logger.warn('`withData` flag is ignored when showing individual resources');
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"types": "./dist/serialization-format.d.ts",
"default": "./dist/serialization-format.js"
},
"./encryption": {
"types": "./dist/encryption.d.ts",
"default": "./dist/encryption.js"
},
"./_workflow": "./dist/workflow/index.js"
},
"scripts": {
Expand Down
Loading
Loading