Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e2dff50
Add browser-compatible AES-GCM to core and HKDF key derivation to wor…
TooTallNate Feb 6, 2026
f0d98ab
update changeset
TooTallNate Feb 14, 2026
6115b72
Move HKDF key derivation server-side: API returns per-run derived key
TooTallNate Feb 15, 2026
b4b52e2
Refactor encrypt/decrypt to accept CryptoKey, export importKey for ca…
TooTallNate Feb 18, 2026
3760ea9
Overload getEncryptionKeyForRun: accept context for start(), fetch Wo…
TooTallNate Feb 19, 2026
3ac5e04
Split changeset into per-package descriptions for world, world-vercel…
TooTallNate Feb 19, 2026
396cda1
Remove unnecessary Uint8Array.from() wrapper around Buffer.from()
TooTallNate Feb 19, 2026
d69925d
Use zod to parse Vercel API response
TooTallNate Feb 19, 2026
616b992
fix: restore world-vercel files to main versions
TooTallNate Mar 3, 2026
7bdeac2
fix: add type cast for hydrateStepReturnValue return in hook.ts
TooTallNate Mar 3, 2026
f0f4aaf
Make decryption an explicit opt-in for o11y tooling
TooTallNate Feb 11, 2026
f47275e
Restore encrypted data handling in o11y hydration layer
TooTallNate Feb 13, 2026
b36b06c
Use EncryptedDataRef with util.inspect.custom for CLI encrypted data …
TooTallNate Feb 13, 2026
4ae69db
Fix Decrypt button crash: use correct 'refresh' callback from useWork…
TooTallNate Feb 13, 2026
17ee566
Implement client-side decryption for web o11y with getEncryptionKeyFo…
TooTallNate Feb 13, 2026
dd6d3cb
Fix CLI decrypt: fetch WorkflowRun for key resolution, cache per runId
TooTallNate Feb 14, 2026
eead1ba
Use named constructor pattern for encrypted data display in web o11y
TooTallNate Feb 14, 2026
0bd0c00
Decrypt event data when encryption key is available after Decrypt but…
TooTallNate Feb 14, 2026
0a82592
Lift encryption key to run-level state, auto-decrypt on fetch, fix fi…
TooTallNate Feb 14, 2026
b885559
Re-load expanded event data when encryption key becomes available
TooTallNate Feb 14, 2026
0d5d816
Consolidate Decrypt to title bar Button, remove sidebar decrypt card
TooTallNate Feb 14, 2026
6c06d79
Add hover tooltip to Decrypt button explaining scope and state
TooTallNate Feb 14, 2026
5493502
Show flat Encrypted label for encrypted fields, use Lucide Lock icon …
TooTallNate Feb 14, 2026
6f6a11d
Render eventData subfields individually to avoid encrypted markers in…
TooTallNate Feb 14, 2026
aa7c7f6
Revert: render eventData subfields individually
TooTallNate Feb 14, 2026
4fd4247
Fix Lock icon vertical alignment in DataInspector encrypted label
TooTallNate Feb 14, 2026
1f40932
update changeset
TooTallNate Feb 14, 2026
956cb49
Update CLI, web, and stream callers for CryptoKey: importKey at resol…
TooTallNate Feb 18, 2026
d75944d
Pass teamId to the get-key endpoint
TooTallNate Feb 19, 2026
39e0e10
fix: remove unused DataInspector import in events-list.tsx
TooTallNate Mar 4, 2026
f43b695
fix: restore world-vercel files to base branch versions
TooTallNate Mar 4, 2026
a5e7422
fix: address PR review feedback
TooTallNate Mar 4, 2026
301c1a6
feat: thread encryptionKey through useWorkflowResourceData hook
TooTallNate Mar 4, 2026
f3e3ded
fix: address comprehensive review feedback on PR #1256
TooTallNate Mar 4, 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
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
---
Comment thread
TooTallNate marked this conversation as resolved.

Add encryption-aware o11y for CLI and web UI
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 @@ -252,6 +260,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;
};
169 changes: 159 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,126 @@ 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;
if (!runId) return resource;

const result = { ...resource };

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

// Decrypt input/output/error fields (WorkflowRun, Step)
result.input = await maybeDecrypt(result.input, k);
result.output = await maybeDecrypt(result.output, k);
(result as any).error = await maybeDecrypt((result as any).error, 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 };
for (const field of [
'result',
'input',
'output',
'metadata',
'payload',
]) {
eventData[field] = await maybeDecrypt(eventData[field], k);
}
result.eventData = eventData;
}
} catch (err) {
// Decryption failed (bad key, corrupted ciphertext, etc.) — fall back
// to showing encrypted placeholders instead of crashing the CLI.
const { logger } = await import('../config/log.js');
logger.warn(
`Decryption failed for resource ${runId}: ${err instanceof Error ? err.message : String(err)}`
);
}

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', 'error']) {
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', 'output', 'metadata', 'payload']) {
if (isEncryptedData(ed[key])) {
ed[key] = ENCRYPTED_REF;
}
}
result.eventData = ed;
}

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);
}
Loading
Loading