diff --git a/.changeset/reject-empty-hook-token.md b/.changeset/reject-empty-hook-token.md new file mode 100644 index 0000000000..3e36382e2b --- /dev/null +++ b/.changeset/reject-empty-hook-token.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': patch +--- + +Reject an explicit empty-string `token` in `createHook()`. Omit the option (or pass `undefined`) to get a randomly generated token, or pass a non-empty string. diff --git a/packages/core/src/create-hook.ts b/packages/core/src/create-hook.ts index 6ee2c29236..108fc205ea 100644 --- a/packages/core/src/create-hook.ts +++ b/packages/core/src/create-hook.ts @@ -132,7 +132,9 @@ export interface HookOptions { * tokens are always randomly generated to prevent unauthorized access * to the public webhook endpoint. * - * If not provided, a randomly generated token will be assigned. + * If provided, the token must be a non-empty string; passing an empty + * string throws. If not provided (or `undefined`), a randomly generated + * token will be assigned. * * @example * diff --git a/packages/core/src/workflow/hook.test.ts b/packages/core/src/workflow/hook.test.ts index 0901d8bb6c..eedf3c8aee 100644 --- a/packages/core/src/workflow/hook.test.ts +++ b/packages/core/src/workflow/hook.test.ts @@ -1172,6 +1172,33 @@ describe('createCreateHook', () => { expect(queueItem.isWebhook).toBe(true); } }); + + it('should throw when an empty string token is provided', () => { + const ctx = setupWorkflowContext([]); + const createHook = createCreateHook(ctx); + + expect(() => createHook({ token: '' })).toThrow( + '`createHook()` was called with an empty string token. Pass a non-empty token, or omit the `token` option to use a randomly generated one.' + ); + + // The rejected hook must not be registered in the invocations queue. + expect(ctx.invocationsQueue.size).toBe(0); + }); + + it('should auto-generate a non-empty token when none is provided', () => { + const ctx = setupWorkflowContext([]); + const createHook = createCreateHook(ctx); + const hook = createHook(); + + expect(hook.token).toBeTruthy(); + expect(hook.token.length).toBeGreaterThan(0); + + const queueItem = ctx.invocationsQueue.values().next().value; + expect(queueItem?.type).toBe('hook'); + if (queueItem?.type === 'hook') { + expect(queueItem.token).toBe(hook.token); + } + }); }); describe('createWebhook', () => { diff --git a/packages/core/src/workflow/hook.ts b/packages/core/src/workflow/hook.ts index ce186f6920..329c97caf5 100644 --- a/packages/core/src/workflow/hook.ts +++ b/packages/core/src/workflow/hook.ts @@ -57,6 +57,18 @@ function createConflictingRun( export function createCreateHook(ctx: WorkflowOrchestratorContext) { return function createHookImpl(options: HookOptions = {}): Hook { + // Reject an explicit empty-string token. A token must either be omitted + // (or `undefined`/`null`) to get a randomly generated one, or be an + // explicit non-empty string. An empty string is almost always an + // accidental value (e.g. an unset variable) and would otherwise slip + // through the `??` below — which only falls back for nullish values — and + // be used as a meaningless, non-deterministic token. + if (options.token === '') { + throw new Error( + '`createHook()` was called with an empty string token. Pass a non-empty token, or omit the `token` option to use a randomly generated one.' + ); + } + // Generate hook ID and token const correlationId = `hook_${ctx.generateUlid()}`; const token = options.token ?? ctx.generateNanoid();