Skip to content

Commit 2b880f9

Browse files
authored
[world-local] Implement Hook "token" uniqueness validation (#295)
Signed-off-by: Nathan Rajlich <n@n8.io>
1 parent 11469d8 commit 2b880f9

File tree

3 files changed

+227
-92
lines changed

3 files changed

+227
-92
lines changed

.changeset/busy-ears-switch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/world-local": patch
3+
---
4+
5+
Enforce uniqueness on hook "token" values

packages/world-local/src/storage.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,106 @@ describe('Storage', () => {
927927
.catch(() => false);
928928
expect(fileExists).toBe(true);
929929
});
930+
931+
it('should throw error when creating a hook with a duplicate token', async () => {
932+
// Create first hook with a token
933+
const hookData = {
934+
hookId: 'hook_1',
935+
token: 'duplicate-test-token',
936+
};
937+
938+
await storage.hooks.create(testRunId, hookData);
939+
940+
// Try to create another hook with the same token
941+
const duplicateHookData = {
942+
hookId: 'hook_2',
943+
token: 'duplicate-test-token',
944+
};
945+
946+
await expect(
947+
storage.hooks.create(testRunId, duplicateHookData)
948+
).rejects.toThrow(
949+
'Hook with token duplicate-test-token already exists for this project'
950+
);
951+
});
952+
953+
it('should allow multiple hooks with different tokens for the same run', async () => {
954+
const hook1 = await storage.hooks.create(testRunId, {
955+
hookId: 'hook_1',
956+
token: 'token-1',
957+
});
958+
959+
const hook2 = await storage.hooks.create(testRunId, {
960+
hookId: 'hook_2',
961+
token: 'token-2',
962+
});
963+
964+
expect(hook1.token).toBe('token-1');
965+
expect(hook2.token).toBe('token-2');
966+
});
967+
968+
it('should allow the same token only after disposing the previous hook', async () => {
969+
const token = 'reusable-token';
970+
971+
// Create first hook
972+
const hook1 = await storage.hooks.create(testRunId, {
973+
hookId: 'hook_1',
974+
token,
975+
});
976+
977+
expect(hook1.token).toBe(token);
978+
979+
// Try to create another hook with the same token - should fail
980+
await expect(
981+
storage.hooks.create(testRunId, {
982+
hookId: 'hook_2',
983+
token,
984+
})
985+
).rejects.toThrow(
986+
`Hook with token ${token} already exists for this project`
987+
);
988+
989+
// Dispose the first hook
990+
await storage.hooks.dispose('hook_1');
991+
992+
// Now we should be able to create a new hook with the same token
993+
const hook2 = await storage.hooks.create(testRunId, {
994+
hookId: 'hook_2',
995+
token,
996+
});
997+
998+
expect(hook2.token).toBe(token);
999+
expect(hook2.hookId).toBe('hook_2');
1000+
});
1001+
1002+
it('should enforce token uniqueness across different runs within the same project', async () => {
1003+
// Create a second run
1004+
const run2 = await storage.runs.create({
1005+
deploymentId: 'deployment-456',
1006+
workflowName: 'another-workflow',
1007+
input: [],
1008+
});
1009+
1010+
const token = 'shared-token-across-runs';
1011+
1012+
// Create hook in first run
1013+
const hook1 = await storage.hooks.create(testRunId, {
1014+
hookId: 'hook_1',
1015+
token,
1016+
});
1017+
1018+
expect(hook1.token).toBe(token);
1019+
1020+
// Try to create hook with same token in second run - should fail
1021+
await expect(
1022+
storage.hooks.create(run2.runId, {
1023+
hookId: 'hook_2',
1024+
token,
1025+
})
1026+
).rejects.toThrow(
1027+
`Hook with token ${token} already exists for this project`
1028+
);
1029+
});
9301030
});
9311031

9321032
describe('get', () => {

packages/world-local/src/storage.ts

Lines changed: 122 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import path from 'node:path';
22
import { WorkflowRunNotFoundError } from '@workflow/errors';
33
import {
4+
type CreateHookRequest,
45
type Event,
56
EventSchema,
7+
type GetHookParams,
68
type Hook,
79
HookSchema,
10+
type ListHooksParams,
11+
type PaginatedResponse,
812
type Step,
913
StepSchema,
1014
type Storage,
@@ -94,6 +98,123 @@ const getObjectCreatedAt =
9498
return ulidToDate(ulid);
9599
};
96100

101+
/**
102+
* Creates a hooks storage implementation using the filesystem.
103+
* Implements the Storage['hooks'] interface with hook CRUD operations.
104+
*/
105+
function createHooksStorage(basedir: string): Storage['hooks'] {
106+
// Helper function to find a hook by token (shared between create and getByToken)
107+
async function findHookByToken(token: string): Promise<Hook | null> {
108+
const hooksDir = path.join(basedir, 'hooks');
109+
const files = await listJSONFiles(hooksDir);
110+
111+
for (const file of files) {
112+
const hookPath = path.join(hooksDir, `${file}.json`);
113+
const hook = await readJSON(hookPath, HookSchema);
114+
if (hook && hook.token === token) {
115+
return hook;
116+
}
117+
}
118+
119+
return null;
120+
}
121+
122+
async function create(runId: string, data: CreateHookRequest): Promise<Hook> {
123+
// Check if a hook with the same token already exists
124+
// Token uniqueness is enforced globally per embedded environment
125+
const existingHook = await findHookByToken(data.token);
126+
if (existingHook) {
127+
throw new Error(
128+
`Hook with token ${data.token} already exists for this project`
129+
);
130+
}
131+
132+
const now = new Date();
133+
134+
const result = {
135+
runId,
136+
hookId: data.hookId,
137+
token: data.token,
138+
metadata: data.metadata,
139+
ownerId: 'embedded-owner',
140+
projectId: 'embedded-project',
141+
environment: 'embedded',
142+
createdAt: now,
143+
} as Hook;
144+
145+
const hookPath = path.join(basedir, 'hooks', `${data.hookId}.json`);
146+
await writeJSON(hookPath, result);
147+
return result;
148+
}
149+
150+
async function get(hookId: string, params?: GetHookParams): Promise<Hook> {
151+
const hookPath = path.join(basedir, 'hooks', `${hookId}.json`);
152+
const hook = await readJSON(hookPath, HookSchema);
153+
if (!hook) {
154+
throw new Error(`Hook ${hookId} not found`);
155+
}
156+
const resolveData = params?.resolveData || DEFAULT_RESOLVE_DATA_OPTION;
157+
return filterHookData(hook, resolveData);
158+
}
159+
160+
async function getByToken(token: string): Promise<Hook> {
161+
const hook = await findHookByToken(token);
162+
if (!hook) {
163+
throw new Error(`Hook with token ${token} not found`);
164+
}
165+
return hook;
166+
}
167+
168+
async function list(
169+
params: ListHooksParams
170+
): Promise<PaginatedResponse<Hook>> {
171+
const hooksDir = path.join(basedir, 'hooks');
172+
const resolveData = params.resolveData || DEFAULT_RESOLVE_DATA_OPTION;
173+
174+
const result = await paginatedFileSystemQuery({
175+
directory: hooksDir,
176+
schema: HookSchema,
177+
sortOrder: params.pagination?.sortOrder,
178+
limit: params.pagination?.limit,
179+
cursor: params.pagination?.cursor,
180+
filePrefix: undefined, // Hooks don't have ULIDs, so we can't optimize by filename
181+
filter: (hook) => {
182+
// Filter by runId if provided
183+
if (params.runId && hook.runId !== params.runId) {
184+
return false;
185+
}
186+
return true;
187+
},
188+
getCreatedAt: () => {
189+
// Hook files don't have ULID timestamps in filename
190+
// We need to read the file to get createdAt, but that's inefficient
191+
// So we return the hook's createdAt directly (item.createdAt will be used for sorting)
192+
// Return a dummy date to pass the null check, actual sorting uses item.createdAt
193+
return new Date(0);
194+
},
195+
getId: (hook) => hook.hookId,
196+
});
197+
198+
// Transform the data after pagination
199+
return {
200+
...result,
201+
data: result.data.map((hook) => filterHookData(hook, resolveData)),
202+
};
203+
}
204+
205+
async function dispose(hookId: string): Promise<Hook> {
206+
const hookPath = path.join(basedir, 'hooks', `${hookId}.json`);
207+
const hook = await readJSON(hookPath, HookSchema);
208+
if (!hook) {
209+
throw new Error(`Hook ${hookId} not found`);
210+
}
211+
await deleteJSON(hookPath);
212+
return hook;
213+
}
214+
215+
return { create, get, getByToken, list, dispose };
216+
}
217+
97218
export function createStorage(basedir: string): Storage {
98219
return {
99220
runs: {
@@ -415,97 +536,6 @@ export function createStorage(basedir: string): Storage {
415536
},
416537

417538
// Hooks
418-
hooks: {
419-
async create(runId, data) {
420-
const now = new Date();
421-
422-
const result = {
423-
runId,
424-
hookId: data.hookId,
425-
token: data.token,
426-
metadata: data.metadata,
427-
ownerId: 'embedded-owner',
428-
projectId: 'embedded-project',
429-
environment: 'embedded',
430-
createdAt: now,
431-
} as Hook;
432-
433-
const hookPath = path.join(basedir, 'hooks', `${data.hookId}.json`);
434-
await writeJSON(hookPath, result);
435-
return result;
436-
},
437-
438-
async get(hookId, params) {
439-
const hookPath = path.join(basedir, 'hooks', `${hookId}.json`);
440-
// Try webhook schema first (which includes response), fall back to HookSchema
441-
const hook = await readJSON(hookPath, HookSchema);
442-
if (!hook) {
443-
throw new Error(`Hook ${hookId} not found`);
444-
}
445-
const resolveData = params?.resolveData || DEFAULT_RESOLVE_DATA_OPTION;
446-
return filterHookData(hook, resolveData);
447-
},
448-
449-
async getByToken(token) {
450-
// Need to search through all hooks to find one with matching token
451-
const hooksDir = path.join(basedir, 'hooks');
452-
const files = await listJSONFiles(hooksDir);
453-
454-
for (const file of files) {
455-
const hookPath = path.join(hooksDir, `${file}.json`);
456-
const hook = await readJSON(hookPath, HookSchema);
457-
if (hook && hook.token === token) {
458-
return hook;
459-
}
460-
}
461-
462-
throw new Error(`Hook with token ${token} not found`);
463-
},
464-
465-
async list(params) {
466-
const hooksDir = path.join(basedir, 'hooks');
467-
const resolveData = params.resolveData || DEFAULT_RESOLVE_DATA_OPTION;
468-
469-
const result = await paginatedFileSystemQuery({
470-
directory: hooksDir,
471-
schema: HookSchema,
472-
sortOrder: params.pagination?.sortOrder,
473-
limit: params.pagination?.limit,
474-
cursor: params.pagination?.cursor,
475-
filePrefix: undefined, // Hooks don't have ULIDs, so we can't optimize by filename
476-
filter: (hook) => {
477-
// Filter by runId if provided
478-
if (params.runId && hook.runId !== params.runId) {
479-
return false;
480-
}
481-
return true;
482-
},
483-
getCreatedAt: () => {
484-
// Hook files don't have ULID timestamps in filename
485-
// We need to read the file to get createdAt, but that's inefficient
486-
// So we return the hook's createdAt directly (item.createdAt will be used for sorting)
487-
// Return a dummy date to pass the null check, actual sorting uses item.createdAt
488-
return new Date(0);
489-
},
490-
getId: (hook) => hook.hookId,
491-
});
492-
493-
// Transform the data after pagination
494-
return {
495-
...result,
496-
data: result.data.map((hook) => filterHookData(hook, resolveData)),
497-
};
498-
},
499-
500-
async dispose(hookId) {
501-
const hookPath = path.join(basedir, 'hooks', `${hookId}.json`);
502-
const hook = await readJSON(hookPath, HookSchema);
503-
if (!hook) {
504-
throw new Error(`Hook ${hookId} not found`);
505-
}
506-
await deleteJSON(hookPath);
507-
return hook;
508-
},
509-
},
539+
hooks: createHooksStorage(basedir),
510540
};
511541
}

0 commit comments

Comments
 (0)