From 6be28f18fb4df659597b2bc5034222ec5b713d43 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:58:20 +0000 Subject: [PATCH 1/4] fix: use endpoint_url for webhook endpoints and truncate long event lists The WorkOS API returns endpoint_url (not url) in webhook endpoint responses. The CLI was reading ep.url which resolved to undefined, causing an empty URL column in workos webhook list output. - Rename url to endpoint_url in WebhookEndpoint interface and emulator entity to match the real API response shape - Update webhook list command to read ep.endpoint_url - Truncate long event lists at 60 chars in table output - Update emulator routes to accept endpoint_url (with url fallback) - Update all related tests and mocks Closes #133 Co-Authored-By: nick.nisi@workos.com --- src/commands/webhook.spec.ts | 2 +- src/commands/webhook.ts | 8 +++++++- src/emulate/workos/entities.ts | 2 +- src/emulate/workos/event-bus.spec.ts | 8 ++++---- src/emulate/workos/event-bus.ts | 2 +- src/emulate/workos/helpers.ts | 2 +- src/emulate/workos/index.ts | 4 ++-- .../workos/routes/webhook-endpoints.spec.ts | 14 +++++++------- src/emulate/workos/routes/webhook-endpoints.ts | 17 +++++++++-------- src/emulate/workos/store.ts | 2 +- src/lib/workos-client.ts | 2 +- 11 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/commands/webhook.spec.ts b/src/commands/webhook.spec.ts index 1ed61a31..88c8ede6 100644 --- a/src/commands/webhook.spec.ts +++ b/src/commands/webhook.spec.ts @@ -19,7 +19,7 @@ const { runWebhookList, runWebhookCreate, runWebhookDelete } = await import('./w const mockWebhook = { id: 'we_123', - url: 'https://example.com/hook', + endpoint_url: 'https://example.com/hook', events: ['dsync.user.created'], created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index 2ed701f5..4fd33ad4 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -29,7 +29,13 @@ export async function runWebhookList(apiKey: string, baseUrl?: string): Promise< return; } - const rows = result.data.map((ep) => [ep.id, ep.url, ep.events.join(', '), ep.created_at]); + const rows = result.data.map((ep) => { + const eventStr = ep.events.join(', '); + const maxEvents = 60; + const truncatedEvents = + eventStr.length > maxEvents ? `${eventStr.slice(0, maxEvents)}… (+${ep.events.length})` : eventStr; + return [ep.id, ep.endpoint_url, truncatedEvents, ep.created_at]; + }); console.log(formatTable([{ header: 'ID' }, { header: 'URL' }, { header: 'Events' }, { header: 'Created' }], rows)); diff --git a/src/emulate/workos/entities.ts b/src/emulate/workos/entities.ts index 5f9bf31a..2171d079 100644 --- a/src/emulate/workos/entities.ts +++ b/src/emulate/workos/entities.ts @@ -393,7 +393,7 @@ export interface WorkOSEvent extends Entity { export interface WorkOSWebhookEndpoint extends Entity { object: 'webhook_endpoint'; - url: string; + endpoint_url: string; secret: string; enabled: boolean; events: string[]; diff --git a/src/emulate/workos/event-bus.spec.ts b/src/emulate/workos/event-bus.spec.ts index 591c6bf4..9819bdf2 100644 --- a/src/emulate/workos/event-bus.spec.ts +++ b/src/emulate/workos/event-bus.spec.ts @@ -47,7 +47,7 @@ describe('EventBus', () => { const ws = getWorkOSStore(store); ws.webhookEndpoints.insert({ object: 'webhook_endpoint', - url: 'http://localhost:9999/webhook', + endpoint_url: 'http://localhost:9999/webhook', secret: 'whsec_test', enabled: false, events: [], @@ -64,7 +64,7 @@ describe('EventBus', () => { const ws = getWorkOSStore(store); ws.webhookEndpoints.insert({ object: 'webhook_endpoint', - url: 'http://localhost:9999/webhook', + endpoint_url: 'http://localhost:9999/webhook', secret: 'whsec_test', enabled: true, events: ['organization.created'], @@ -88,7 +88,7 @@ describe('EventBus', () => { ws.webhookEndpoints.insert({ object: 'webhook_endpoint', - url: 'http://localhost:9999/webhook', + endpoint_url: 'http://localhost:9999/webhook', secret, enabled: true, events: [], @@ -128,7 +128,7 @@ describe('EventBus', () => { ws.webhookEndpoints.insert({ object: 'webhook_endpoint', - url: 'http://localhost:9999/webhook', + endpoint_url: 'http://localhost:9999/webhook', secret: 'whsec_test', enabled: true, events: [], diff --git a/src/emulate/workos/event-bus.ts b/src/emulate/workos/event-bus.ts index 27397ad3..f41413ae 100644 --- a/src/emulate/workos/event-bus.ts +++ b/src/emulate/workos/event-bus.ts @@ -68,7 +68,7 @@ export class EventBus { const signature = signWebhookPayload(body, endpoint.secret); - await fetch(endpoint.url, { + await fetch(endpoint.endpoint_url, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/emulate/workos/helpers.ts b/src/emulate/workos/helpers.ts index 9448e1fa..5b665c01 100644 --- a/src/emulate/workos/helpers.ts +++ b/src/emulate/workos/helpers.ts @@ -298,7 +298,7 @@ export function formatWebhookEndpoint( return { object: 'webhook_endpoint', id: ep.id, - url: ep.url, + endpoint_url: ep.endpoint_url, secret: opts?.includeSecret ? ep.secret : `${ep.secret.slice(0, 8)}****`, enabled: ep.enabled, events: ep.events, diff --git a/src/emulate/workos/index.ts b/src/emulate/workos/index.ts index fe07d0d3..23188f49 100644 --- a/src/emulate/workos/index.ts +++ b/src/emulate/workos/index.ts @@ -131,7 +131,7 @@ export interface WorkOSSeedPermission { } export interface WorkOSSeedWebhookEndpoint { - url: string; + endpoint_url: string; events?: string[]; enabled?: boolean; } @@ -317,7 +317,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee for (const whConfig of config.webhookEndpoints) { ws.webhookEndpoints.insert({ object: 'webhook_endpoint', - url: whConfig.url, + endpoint_url: whConfig.endpoint_url, secret: randomBytes(32).toString('hex'), enabled: whConfig.enabled !== false, events: whConfig.events ?? [], diff --git a/src/emulate/workos/routes/webhook-endpoints.spec.ts b/src/emulate/workos/routes/webhook-endpoints.spec.ts index 244b81d0..2756d092 100644 --- a/src/emulate/workos/routes/webhook-endpoints.spec.ts +++ b/src/emulate/workos/routes/webhook-endpoints.spec.ts @@ -22,12 +22,12 @@ describe('Webhook endpoint routes', () => { it('creates a webhook endpoint with auto-generated secret', async () => { const res = await req('/webhook_endpoints', { method: 'POST', - body: JSON.stringify({ url: 'http://localhost:3000/webhooks' }), + body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), }); expect(res.status).toBe(201); const ep = await json(res); expect(ep.object).toBe('webhook_endpoint'); - expect(ep.url).toBe('http://localhost:3000/webhooks'); + expect(ep.endpoint_url).toBe('http://localhost:3000/webhooks'); expect(ep.secret).toHaveLength(64); // full hex secret on create expect(ep.enabled).toBe(true); expect(ep.events).toEqual([]); @@ -38,7 +38,7 @@ describe('Webhook endpoint routes', () => { const res = await req('/webhook_endpoints', { method: 'POST', body: JSON.stringify({ - url: 'http://localhost:3000/webhooks', + endpoint_url: 'http://localhost:3000/webhooks', secret: 'my_custom_secret', events: ['user.created', 'user.deleted'], description: 'Test endpoint', @@ -53,7 +53,7 @@ describe('Webhook endpoint routes', () => { it('masks secret on GET', async () => { const createRes = await req('/webhook_endpoints', { method: 'POST', - body: JSON.stringify({ url: 'http://localhost:3000/webhooks' }), + body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), }); const created = await json(createRes); @@ -66,7 +66,7 @@ describe('Webhook endpoint routes', () => { it('masks secret on list', async () => { await req('/webhook_endpoints', { method: 'POST', - body: JSON.stringify({ url: 'http://localhost:3000/webhooks' }), + body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), }); const listRes = await req('/webhook_endpoints'); @@ -78,7 +78,7 @@ describe('Webhook endpoint routes', () => { it('updates a webhook endpoint', async () => { const createRes = await req('/webhook_endpoints', { method: 'POST', - body: JSON.stringify({ url: 'http://localhost:3000/webhooks' }), + body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), }); const created = await json(createRes); @@ -94,7 +94,7 @@ describe('Webhook endpoint routes', () => { it('deletes a webhook endpoint', async () => { const createRes = await req('/webhook_endpoints', { method: 'POST', - body: JSON.stringify({ url: 'http://localhost:3000/webhooks' }), + body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), }); const created = await json(createRes); diff --git a/src/emulate/workos/routes/webhook-endpoints.ts b/src/emulate/workos/routes/webhook-endpoints.ts index 810a7930..7c36d907 100644 --- a/src/emulate/workos/routes/webhook-endpoints.ts +++ b/src/emulate/workos/routes/webhook-endpoints.ts @@ -9,16 +9,16 @@ export function webhookEndpointRoutes(ctx: RouteContext): void { app.post('/webhook_endpoints', async (c) => { const body = await parseJsonBody(c); - const url = body.url as string | undefined; - if (!url || typeof url !== 'string') { - throw validationError('URL is required', [{ field: 'url', code: 'required' }]); + const endpointUrl = (body.endpoint_url ?? body.url) as string | undefined; + if (!endpointUrl || typeof endpointUrl !== 'string') { + throw validationError('endpoint_url is required', [{ field: 'endpoint_url', code: 'required' }]); } const secret = (body.secret as string) ?? randomBytes(32).toString('hex'); const endpoint = ws.webhookEndpoints.insert({ object: 'webhook_endpoint', - url, + endpoint_url: endpointUrl, secret, enabled: body.enabled !== false, events: Array.isArray(body.events) ? (body.events as string[]) : [], @@ -49,11 +49,12 @@ export function webhookEndpointRoutes(ctx: RouteContext): void { const body = await parseJsonBody(c); const updates: Record = {}; - if ('url' in body) { - if (!body.url || typeof body.url !== 'string') { - throw validationError('URL is required', [{ field: 'url', code: 'required' }]); + if ('endpoint_url' in body || 'url' in body) { + const newUrl = (body.endpoint_url ?? body.url) as string | undefined; + if (!newUrl || typeof newUrl !== 'string') { + throw validationError('endpoint_url is required', [{ field: 'endpoint_url', code: 'required' }]); } - updates.url = body.url; + updates.endpoint_url = newUrl; } if ('enabled' in body) updates.enabled = !!body.enabled; if ('events' in body) updates.events = Array.isArray(body.events) ? body.events : []; diff --git a/src/emulate/workos/store.ts b/src/emulate/workos/store.ts index 2c2729a5..3ff12271 100644 --- a/src/emulate/workos/store.ts +++ b/src/emulate/workos/store.ts @@ -237,7 +237,7 @@ export function getWorkOSStore(store: Store): WorkOSStore { webhookEndpoints: store.collection( 'workos.webhook_endpoints', ID_PREFIXES.webhook_endpoint, - ['url'], + ['endpoint_url'], ), }; diff --git a/src/lib/workos-client.ts b/src/lib/workos-client.ts index b500f80e..52bf3383 100644 --- a/src/lib/workos-client.ts +++ b/src/lib/workos-client.ts @@ -12,7 +12,7 @@ import { resolveApiKey, resolveApiBaseUrl } from './api-key.js'; export interface WebhookEndpoint { id: string; - url: string; + endpoint_url: string; events: string[]; secret?: string; created_at: string; From 50ed0230b8f251616d81b2a01e4aa9c1b14022cf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:07:13 +0000 Subject: [PATCH 2/4] fix: improve event truncation and add seed/emulator backwards compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Truncate events at whole-token boundaries and show hidden count instead of total (e.g. '… (+3 more)' not '… (+5)') - Add url fallback in seedFromConfig for legacy seed configs - Add regression tests for legacy url input on POST/PUT Co-Authored-By: nick.nisi@workos.com --- src/commands/webhook.ts | 21 ++++++++++++---- src/emulate/workos/index.ts | 10 ++++++-- .../workos/routes/webhook-endpoints.spec.ts | 25 +++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index 4fd33ad4..20fbfa85 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -29,12 +29,23 @@ export async function runWebhookList(apiKey: string, baseUrl?: string): Promise< return; } + const maxEventsChars = 60; const rows = result.data.map((ep) => { - const eventStr = ep.events.join(', '); - const maxEvents = 60; - const truncatedEvents = - eventStr.length > maxEvents ? `${eventStr.slice(0, maxEvents)}… (+${ep.events.length})` : eventStr; - return [ep.id, ep.endpoint_url, truncatedEvents, ep.created_at]; + const joined = ep.events.join(', '); + if (joined.length <= maxEventsChars) { + return [ep.id, ep.endpoint_url, joined, ep.created_at]; + } + const visible: string[] = []; + let len = 0; + for (const evt of ep.events) { + const next = len === 0 ? evt.length : len + 2 + evt.length; + if (next > maxEventsChars) break; + visible.push(evt); + len = next; + } + const hidden = ep.events.length - visible.length; + const prefix = visible.length > 0 ? `${visible.join(', ')}, ` : ''; + return [ep.id, ep.endpoint_url, `${prefix}… (+${hidden} more)`, ep.created_at]; }); console.log(formatTable([{ header: 'ID' }, { header: 'URL' }, { header: 'Events' }, { header: 'Created' }], rows)); diff --git a/src/emulate/workos/index.ts b/src/emulate/workos/index.ts index 23188f49..fd3daeac 100644 --- a/src/emulate/workos/index.ts +++ b/src/emulate/workos/index.ts @@ -131,7 +131,9 @@ export interface WorkOSSeedPermission { } export interface WorkOSSeedWebhookEndpoint { - endpoint_url: string; + endpoint_url?: string; + /** @deprecated Use endpoint_url */ + url?: string; events?: string[]; enabled?: boolean; } @@ -315,9 +317,13 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee if (config.webhookEndpoints) { for (const whConfig of config.webhookEndpoints) { + const endpointUrl = whConfig.endpoint_url ?? whConfig.url; + if (!endpointUrl) { + throw new Error('workos seed config: webhookEndpoints[].endpoint_url is required'); + } ws.webhookEndpoints.insert({ object: 'webhook_endpoint', - endpoint_url: whConfig.endpoint_url, + endpoint_url: endpointUrl, secret: randomBytes(32).toString('hex'), enabled: whConfig.enabled !== false, events: whConfig.events ?? [], diff --git a/src/emulate/workos/routes/webhook-endpoints.spec.ts b/src/emulate/workos/routes/webhook-endpoints.spec.ts index 2756d092..0cc6f343 100644 --- a/src/emulate/workos/routes/webhook-endpoints.spec.ts +++ b/src/emulate/workos/routes/webhook-endpoints.spec.ts @@ -105,6 +105,31 @@ describe('Webhook endpoint routes', () => { expect(getRes.status).toBe(404); }); + it('accepts legacy url on create for backward compatibility', async () => { + const res = await req('/webhook_endpoints', { + method: 'POST', + body: JSON.stringify({ url: 'http://localhost:3000/legacy' }), + }); + expect(res.status).toBe(201); + const ep = await json(res); + expect(ep.endpoint_url).toBe('http://localhost:3000/legacy'); + }); + + it('accepts legacy url on update for backward compatibility', async () => { + const createRes = await req('/webhook_endpoints', { + method: 'POST', + body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), + }); + const created = await json(createRes); + const updateRes = await req(`/webhook_endpoints/${created.id}`, { + method: 'PUT', + body: JSON.stringify({ url: 'http://localhost:3000/updated-legacy' }), + }); + expect(updateRes.status).toBe(200); + const updated = await json(updateRes); + expect(updated.endpoint_url).toBe('http://localhost:3000/updated-legacy'); + }); + it('returns 422 for missing url', async () => { const res = await req('/webhook_endpoints', { method: 'POST', From ca908e90e2731b15249a3238a800ddb1382ce889 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 28 Apr 2026 16:49:57 -0500 Subject: [PATCH 3/4] chore: cleanup and simplify --- src/commands/webhook.spec.ts | 33 +++++++++++++++++++++++++++++++++ src/commands/webhook.ts | 15 ++++++++------- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/commands/webhook.spec.ts b/src/commands/webhook.spec.ts index 88c8ede6..3bbd583c 100644 --- a/src/commands/webhook.spec.ts +++ b/src/commands/webhook.spec.ts @@ -59,6 +59,39 @@ describe('webhook commands', () => { await runWebhookList('sk_test'); expect(consoleOutput.some((l) => l.includes('No webhook endpoints found'))).toBe(true); }); + + it('truncates long event lists with a "+N more" suffix', async () => { + mockClient.webhooks.list.mockResolvedValue({ + data: [ + { + ...mockWebhook, + events: [ + 'user.created', + 'user.updated', + 'user.deleted', + 'session.created', + 'session.revoked', + 'organization.created', + 'organization.updated', + ], + }, + ], + list_metadata: { before: null, after: null }, + }); + await runWebhookList('sk_test'); + expect(consoleOutput.some((l) => /\+\d+ more/.test(l))).toBe(true); + }); + + it('always shows at least one event when a single event name exceeds the budget', async () => { + const longEvent = 'a.very.long.namespace.with.many.segments.that.exceeds.sixty.chars.event'; + mockClient.webhooks.list.mockResolvedValue({ + data: [{ ...mockWebhook, events: [longEvent, 'user.created'] }], + list_metadata: { before: null, after: null }, + }); + await runWebhookList('sk_test'); + expect(consoleOutput.some((l) => l.includes(longEvent))).toBe(true); + expect(consoleOutput.some((l) => l.includes('(+1 more)'))).toBe(true); + }); }); describe('runWebhookCreate', () => { diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index 20fbfa85..501dc46a 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -35,17 +35,18 @@ export async function runWebhookList(apiKey: string, baseUrl?: string): Promise< if (joined.length <= maxEventsChars) { return [ep.id, ep.endpoint_url, joined, ep.created_at]; } - const visible: string[] = []; - let len = 0; - for (const evt of ep.events) { - const next = len === 0 ? evt.length : len + 2 + evt.length; + // Always include the first event so the cell isn't content-free when a single event name exceeds the budget. + const visible: string[] = [ep.events[0]]; + let len = ep.events[0].length; + for (let i = 1; i < ep.events.length; i++) { + const next = len + 2 + ep.events[i].length; if (next > maxEventsChars) break; - visible.push(evt); + visible.push(ep.events[i]); len = next; } const hidden = ep.events.length - visible.length; - const prefix = visible.length > 0 ? `${visible.join(', ')}, ` : ''; - return [ep.id, ep.endpoint_url, `${prefix}… (+${hidden} more)`, ep.created_at]; + const suffix = hidden > 0 ? `, … (+${hidden} more)` : ''; + return [ep.id, ep.endpoint_url, `${visible.join(', ')}${suffix}`, ep.created_at]; }); console.log(formatTable([{ header: 'ID' }, { header: 'URL' }, { header: 'Events' }, { header: 'Created' }], rows)); From eaccc6de24249f7e5db33f54ba72346c115193cf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:55:17 +0000 Subject: [PATCH 4/4] fix: tighten seed validation for webhook endpoint URL Co-Authored-By: nick.nisi@workos.com --- src/emulate/workos/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/emulate/workos/index.ts b/src/emulate/workos/index.ts index fd3daeac..a5541c3c 100644 --- a/src/emulate/workos/index.ts +++ b/src/emulate/workos/index.ts @@ -318,7 +318,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee if (config.webhookEndpoints) { for (const whConfig of config.webhookEndpoints) { const endpointUrl = whConfig.endpoint_url ?? whConfig.url; - if (!endpointUrl) { + if (!endpointUrl || typeof endpointUrl !== 'string') { throw new Error('workos seed config: webhookEndpoints[].endpoint_url is required'); } ws.webhookEndpoints.insert({