diff --git a/src/commands/webhook.spec.ts b/src/commands/webhook.spec.ts index 1ed61a31..3bbd583c 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', @@ -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 2ed701f5..501dc46a 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -29,7 +29,25 @@ 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 maxEventsChars = 60; + const rows = result.data.map((ep) => { + const joined = ep.events.join(', '); + if (joined.length <= maxEventsChars) { + return [ep.id, ep.endpoint_url, joined, ep.created_at]; + } + // 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(ep.events[i]); + len = next; + } + const hidden = ep.events.length - visible.length; + 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)); 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..a5541c3c 100644 --- a/src/emulate/workos/index.ts +++ b/src/emulate/workos/index.ts @@ -131,7 +131,9 @@ export interface WorkOSSeedPermission { } export interface WorkOSSeedWebhookEndpoint { - 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 || typeof endpointUrl !== 'string') { + throw new Error('workos seed config: webhookEndpoints[].endpoint_url is required'); + } ws.webhookEndpoints.insert({ object: 'webhook_endpoint', - url: whConfig.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 244b81d0..0cc6f343 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); @@ -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', 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;