Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/drop-nanoid-dependency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Security: remove the `nanoid` dependency (pinned at 5.1.6, affected by [GHSA-28wg-ghj8-5hjv](https://github.com/advisories/GHSA-28wg-ghj8-5hjv)). Default hook tokens now come from a built-in copy of the same generator, so tokens and seeded random values stay identical for runs recorded on earlier versions. Upgrading `nanoid` itself would have changed them, because `nanoid@5.1.16` draws a different number of random bytes per id.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

The changeset is used verbatim in the changelog, and the repo convention is one terse line with no implementation detail. Something like: Security: remove the nanoid dependency (GHSA-28wg-ghj8-5hjv). Hook tokens are unchanged for existing runs. The rationale for not bumping fits better in the PR description, where it already is.

1 change: 0 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@
"debug": "4.4.3",
"devalue": "6.0.3",
"ms": "2.1.3",
"nanoid": "5.1.6",
"quickjs-wasi": "3.4.0",
"seedrandom": "3.0.5",
"semver": "catalog:",
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/abort-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
*/

import type { Event, WorkflowRun } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import { EventsConsumer } from './events-consumer.js';
import type { WorkflowSuspension } from './global.js';
import type { WorkflowOrchestratorContext } from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import {
dehydrateWorkflowArguments,
hydrateWorkflowReturnValue,
Expand Down Expand Up @@ -47,9 +47,7 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: () => {},
promiseQueue: Promise.resolve(),
pendingDeliveries: 0,
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/abort-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
import { ReplayDivergenceError } from '@workflow/errors';
import { withResolvers } from '@workflow/utils';
import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import { DEFERRED_CHECK_DELAY_MS, EventsConsumer } from './events-consumer.js';
import type { WorkflowOrchestratorContext } from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import { dehydrateStepReturnValue } from './serialization.js';
import { createContext } from './vm/index.js';
import {
Expand Down Expand Up @@ -45,9 +45,7 @@ function setupWorkflowContext(
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: vi.fn(),
promiseQueue: Promise.resolve(),
};
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/abort-replay-ordering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
*/

import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import { EventsConsumer } from './events-consumer.js';
Expand All @@ -33,6 +32,7 @@ import {
type WorkflowOrchestratorContext,
} from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import { createContext } from './vm/index.js';
import { createCreateAbortController } from './workflow/abort-controller.js';

Expand Down Expand Up @@ -80,9 +80,7 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: vi.fn(),
promiseQueue: Promise.resolve(),
pendingDeliveries: 0,
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/async-deserialization-ordering.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { FatalError } from '@workflow/errors';
import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { registerSerializationClass } from './class-serialization.js';
import { EventsConsumer } from './events-consumer.js';
import { isDeliveryIdle, type WorkflowOrchestratorContext } from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import {
dehydrateStepError,
dehydrateStepReturnValue,
Expand Down Expand Up @@ -60,9 +60,7 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: vi.fn(),
promiseQueue: Promise.resolve(),
pendingDeliveries: 0,
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/delivery-barrier-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@
import { WorkflowRuntimeError } from '@workflow/errors';
import { withResolvers } from '@workflow/utils';
import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import { EventsConsumer } from './events-consumer.js';
Expand All @@ -64,6 +63,7 @@ import {
type WorkflowOrchestratorContext,
} from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import { dehydrateStepReturnValue } from './serialization.js';
import { createUseStep } from './step.js';
import { createContext } from './vm/index.js';
Expand Down Expand Up @@ -110,9 +110,7 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
get mintCount() {
return mintCount;
},
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: vi.fn(),
get promiseQueue() {
return promiseQueueHolder.current;
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/delivery-barrier-dispenser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import { WorkflowRuntimeError } from '@workflow/errors';
import { withResolvers } from '@workflow/utils';
import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it } from 'vitest';
import { EventsConsumer } from './events-consumer.js';
Expand All @@ -29,6 +28,7 @@ import {
type WorkflowOrchestratorContext,
} from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import { dehydrateStepReturnValue } from './serialization.js';
import { createUseStep } from './step.js';
import { createContext } from './vm/index.js';
Expand Down Expand Up @@ -82,9 +82,7 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
get mintCount() {
return mintCount;
},
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: () => {},
get promiseQueue() {
return promiseQueueHolder.current;
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/race-padded-draw-ordering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@
import { WorkflowRuntimeError } from '@workflow/errors';
import { withResolvers } from '@workflow/utils';
import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EventsConsumer } from './events-consumer.js';
import { WorkflowSuspension } from './global.js';
import { isDeliveryIdle, type WorkflowOrchestratorContext } from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import { dehydrateStepReturnValue } from './serialization.js';
import { createUseStep } from './step.js';
import { createContext } from './vm/index.js';
Expand Down Expand Up @@ -72,9 +72,7 @@ function setupWorkflowContext(
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: vi.fn(),
get promiseQueue() {
return promiseQueueHolder.current;
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/runtime/quickjs-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import {
AttributeValidationError,
validateAttributeEventDataSize,
} from '@workflow/world/attributes-validation';
import * as nanoid from 'nanoid';
import {
type ExtensionDescriptor,
JSException,
Expand All @@ -55,6 +54,7 @@ import {
import seedrandom from 'seedrandom';
import { monotonicFactory } from 'ulid';
import { runtimeLogger } from '../logger.js';
import { createSeededNanoid } from '../seeded-nanoid.js';
import { decompress } from '../serialization/compression.js';
import type { DecryptionKey } from '../serialization/encryption.js';
import { decrypt } from '../serialization/encryption.js';
Expand Down Expand Up @@ -1589,11 +1589,9 @@ export async function startQuickJSWorkflow(
return baseRng();
};

// Seeded nanoid generator: uses the same nanoid package and seeded PRNG
// Seeded nanoid generator: uses the same generator and seeded PRNG
// as the node:vm engine for consistent token generation.
const generateNanoid = nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * rng())
);
const generateNanoid = createSeededNanoid(rng);

// Deterministic replay clock, mirroring the node:vm engine (see
// workflow.ts): the initial value is the run's creation time recovered
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/seeded-nanoid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import seedrandom from 'seedrandom';
import { describe, expect, it } from 'vitest';
import { createSeededNanoid } from './seeded-nanoid.js';

describe('createSeededNanoid', () => {
// Golden values recorded with nanoid@5.1.6's
// customRandom(urlAlphabet, 21, ...), the generator that earlier versions
// used for default hook tokens.
it('matches the ids and PRNG consumption of nanoid@5.1.6', () => {
const rng = seedrandom('wrun_test:seed');
const generate = createSeededNanoid(rng);

expect(generate()).toBe('pVjvQyygUGbIid9mUBIhp');
expect(generate()).toBe('mgNKWnMBr9yywfW_syBGT');
// The next value workflow code would see from Math.random().
expect(rng()).toBe(0.3399971329395688);
});

it('draws 34 values per id', () => {
let draws = 0;
const generate = createSeededNanoid(() => {
draws++;
return 0.5;
});

expect(generate()).toHaveLength(21);
expect(draws).toBe(34);
});
});
32 changes: 32 additions & 0 deletions packages/core/src/seeded-nanoid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Deterministic generator for default hook tokens inside workflow code.
*
* The output, and the number of values drawn from `random`, are part of the
* replay contract. Every draw advances the run-seeded PRNG that also feeds
* `Math.random()` and correlation IDs in workflow code, so a different
* consumption pattern changes every ID minted after the first `createHook()`,
* and a run recorded on one version diverges when it is replayed on another.
*
* This reproduces `nanoid@5.1.6`'s `customRandom(urlAlphabet, 21, ...)`
* exactly, instead of depending on nanoid, whose `customRandom` changed how
* many bytes it requests in 5.1.16. Do not change it.
*/
const URL_ALPHABET =
'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
const ID_SIZE = 21;
// nanoid 5.1.6 requests ceil(1.6 * mask * size / alphabet.length) bytes per
// batch: ceil(1.6 * 63 * 21 / 64) = 34. With a 64-character alphabet every
// byte maps to a character, so one batch always yields a full id, read from
// the end of the batch.
const BYTES_PER_ID = 34;

export function createSeededNanoid(random: () => number): () => string {
return () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

Checked this against the real nanoid@5.1.6 locally: 5,000 random seeds, 5 tokens each, with 0-3 extra PRNG draws between tokens to mimic Math.random() calls in workflow code. All 25,000 tokens matched and the PRNG ended at the same position every time. The returned function also takes no size argument, which is fine: all callers (hook.ts and both QuickJS __generateNanoid bindings) call it with no arguments.

const bytes = new Uint8Array(BYTES_PER_ID).map(() => 256 * random());
let id = '';
for (let i = BYTES_PER_ID - 1; id.length < ID_SIZE; i--) {
id += URL_ALPHABET[bytes[i] & 63];
}
return id;
};
}
6 changes: 2 additions & 4 deletions packages/core/src/step-delivery-hop-count.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@
import { WorkflowRuntimeError } from '@workflow/errors';
import { withResolvers } from '@workflow/utils';
import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import { EventsConsumer } from './events-consumer.js';
import { WorkflowSuspension } from './global.js';
import type { WorkflowOrchestratorContext } from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import {
dehydrateStepError,
dehydrateStepReturnValue,
Expand Down Expand Up @@ -72,9 +72,7 @@ function setupWorkflowContext(
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: vi.fn(),
get promiseQueue() {
return promiseQueueHolder.current;
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/step-delivery-ordering.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { WorkflowRuntimeError } from '@workflow/errors';
import { withResolvers } from '@workflow/utils';
import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EventsConsumer } from './events-consumer.js';
import { WorkflowSuspension } from './global.js';
import type { WorkflowOrchestratorContext } from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import { dehydrateStepReturnValue } from './serialization.js';
import { createUseStep } from './step.js';
import { createContext } from './vm/index.js';
Expand Down Expand Up @@ -121,9 +121,7 @@ function setupWorkflowContext(
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: vi.fn(),
get promiseQueue() {
return promiseQueueHolder.current;
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/step-hydration-memoization.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EventsConsumer } from './events-consumer.js';
import type { WorkflowOrchestratorContext } from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import { dehydrateStepReturnValue } from './serialization.js';
import { createUseStep } from './step.js';
import { createContext } from './vm/index.js';
Expand Down Expand Up @@ -45,9 +45,7 @@ function setupWorkflowContext(
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: vi.fn(),
promiseQueue: Promise.resolve(),
pendingDeliveries: 0,
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/step.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { FatalError, ReplayDivergenceError } from '@workflow/errors';
import { withResolvers } from '@workflow/utils';
import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { registerSerializationClass } from './class-serialization.js';
import { EventsConsumer } from './events-consumer.js';
import { WorkflowSuspension } from './global.js';
import type { WorkflowOrchestratorContext } from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
import { createSeededNanoid } from './seeded-nanoid.js';
import {
dehydrateStepError,
dehydrateStepReturnValue,
Expand Down Expand Up @@ -60,9 +60,7 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
generateNanoid: createSeededNanoid(() => context.globalThis.Math.random()),
onWorkflowError: vi.fn(),
promiseQueue: Promise.resolve(),
pendingDeliveries: 0,
Expand Down
Loading
Loading