From 5e8f133816b18556d28789a1d5f939a455748a97 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 00:27:32 -0800 Subject: [PATCH] =?UTF-8?q?feat(webhooks):=20add=20webhook=20subscriptions?= =?UTF-8?q?=20module=20=E2=80=94=2015=20event=20types,=20HMAC=20signing,?= =?UTF-8?q?=20retry=20delivery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform-service/src/lib/cosmos-init.ts | 3 + .../src/modules/webhooks/dispatcher.ts | 200 ++++++++++ .../src/modules/webhooks/repository.ts | 192 ++++++++++ .../src/modules/webhooks/routes.ts | 111 ++++++ .../src/modules/webhooks/types.ts | 95 +++++ .../src/modules/webhooks/webhooks.test.ts | 356 ++++++++++++++++++ services/platform-service/src/server.ts | 3 + 7 files changed, 960 insertions(+) create mode 100644 services/platform-service/src/modules/webhooks/dispatcher.ts create mode 100644 services/platform-service/src/modules/webhooks/repository.ts create mode 100644 services/platform-service/src/modules/webhooks/routes.ts create mode 100644 services/platform-service/src/modules/webhooks/types.ts create mode 100644 services/platform-service/src/modules/webhooks/webhooks.test.ts diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index a8cf9a30..68980745 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -42,6 +42,9 @@ const CONTAINER_DEFS: Record = { routines: { partitionKeyPath: '/userId' }, households: { partitionKeyPath: '/id' }, shared_timers: { partitionKeyPath: '/householdId' }, + // ChronoMind webhooks + webhook_subscriptions: { partitionKeyPath: '/userId' }, + webhook_events: { partitionKeyPath: '/subscriptionId', defaultTtl: 30 * 86400 }, // Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md) telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 }, telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 }, diff --git a/services/platform-service/src/modules/webhooks/dispatcher.ts b/services/platform-service/src/modules/webhooks/dispatcher.ts new file mode 100644 index 00000000..8f2af867 --- /dev/null +++ b/services/platform-service/src/modules/webhooks/dispatcher.ts @@ -0,0 +1,200 @@ +import { createHmac } from 'node:crypto'; +import type { WebhookEventType, WebhookSubscriptionDoc, WebhookEventDoc } from './types.js'; +import * as repo from './repository.js'; + +// ── HMAC Signing ────────────────────────────────────────────── + +export function signPayload(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('hex'); +} + +export function buildSignatureHeader(payload: string, secret: string): string { + const timestamp = Math.floor(Date.now() / 1000); + const signature = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex'); + return `t=${timestamp},v1=${signature}`; +} + +// ── Delivery ────────────────────────────────────────────────── + +export interface DeliveryResult { + subscriptionId: string; + eventId: string; + success: boolean; + statusCode?: number; + error?: string; +} + +/** + * Dispatch a webhook event to all matching subscriptions for a user. + * Returns delivery results for each subscription. + */ +export async function dispatchEvent( + userId: string, + productId: string, + eventType: WebhookEventType, + payload: Record, + log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void } +): Promise { + const subscriptions = await repo.findSubscriptionsForEvent(userId, productId, eventType); + + if (subscriptions.length === 0) { + return []; + } + + const results: DeliveryResult[] = []; + + for (const sub of subscriptions) { + const result = await deliverToSubscription(sub, eventType, payload, log); + results.push(result); + } + + return results; +} + +/** + * Deliver a single event to a single subscription. + * Creates an event log entry and handles retries. + */ +async function deliverToSubscription( + sub: WebhookSubscriptionDoc, + eventType: WebhookEventType, + payload: Record, + log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void } +): Promise { + const eventId = crypto.randomUUID(); + const now = new Date().toISOString(); + + // Create event log entry + const eventDoc: WebhookEventDoc = { + id: eventId, + subscriptionId: sub.id, + userId: sub.userId, + productId: sub.productId, + eventType, + payload, + createdAt: now, + attempts: 0, + maxRetries: sub.maxRetries, + }; + + await repo.createEvent(eventDoc); + + // Attempt delivery with retries + const maxAttempts = (sub.maxRetries || 3) + 1; + let lastError: string | undefined; + let statusCode: number | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const bodyJson = JSON.stringify({ + id: eventId, + type: eventType, + timestamp: now, + data: payload, + }); + + const signatureHeader = buildSignatureHeader(bodyJson, sub.secret); + + const controller = new globalThis.AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), 10_000); + + const response = await fetch(sub.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signatureHeader, + 'X-Webhook-Id': eventId, + 'X-Webhook-Event': eventType, + 'User-Agent': 'ChronoMind-Webhooks/1.0', + }, + body: bodyJson, + signal: controller.signal, + }); + + globalThis.clearTimeout(timeout); + statusCode = response.status; + + if (response.ok) { + // Success — update event log + await repo.updateEvent({ + ...eventDoc, + deliveredAt: new Date().toISOString(), + statusCode, + attempts: attempt, + }); + await repo.resetFailureCount(sub.id, sub.userId); + + log?.info({ subscriptionId: sub.id, eventType, attempt, statusCode }, 'webhook delivered'); + + return { + subscriptionId: sub.id, + eventId, + success: true, + statusCode, + }; + } + + lastError = `HTTP ${statusCode}`; + } catch (err: unknown) { + lastError = err instanceof Error ? err.message : String(err); + } + + // Exponential backoff between retries (100ms, 200ms, 400ms, ...) + if (attempt < maxAttempts) { + const delay = Math.min(100 * Math.pow(2, attempt - 1), 5000); + await new Promise(resolve => globalThis.setTimeout(resolve, delay)); + } + } + + // All attempts failed + await repo.updateEvent({ + ...eventDoc, + attempts: maxAttempts, + error: lastError, + statusCode, + }); + await repo.incrementFailureCount(sub.id, sub.userId); + + log?.error({ subscriptionId: sub.id, eventType, error: lastError }, 'webhook delivery failed'); + + return { + subscriptionId: sub.id, + eventId, + success: false, + statusCode, + error: lastError, + }; +} + +// ── Verify Signature (for consumers) ────────────────────────── + +export function verifySignature( + signatureHeader: string, + body: string, + secret: string, + toleranceSeconds = 300 +): boolean { + const parts = signatureHeader.split(','); + const timestampPart = parts.find(p => p.startsWith('t=')); + const signaturePart = parts.find(p => p.startsWith('v1=')); + + if (!timestampPart || !signaturePart) return false; + + const timestamp = parseInt(timestampPart.slice(2), 10); + const signature = signaturePart.slice(3); + + // Check timestamp tolerance + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestamp) > toleranceSeconds) return false; + + // Verify HMAC + const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); + + // Constant-time comparison + if (expected.length !== signature.length) return false; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i); + } + return diff === 0; +} diff --git a/services/platform-service/src/modules/webhooks/repository.ts b/services/platform-service/src/modules/webhooks/repository.ts new file mode 100644 index 00000000..0030e8f3 --- /dev/null +++ b/services/platform-service/src/modules/webhooks/repository.ts @@ -0,0 +1,192 @@ +import { getContainer } from '../../lib/cosmos.js'; +import { NotFoundError, ConflictError } from '../../lib/errors.js'; +import type { + WebhookSubscriptionDoc, + WebhookEventDoc, + CreateSubscription, + UpdateSubscription, + WebhookEventType, +} from './types.js'; + +const SUBS_CONTAINER = 'webhook_subscriptions'; +const EVENTS_CONTAINER = 'webhook_events'; + +function subsContainer() { + return getContainer(SUBS_CONTAINER); +} + +function eventsContainer() { + return getContainer(EVENTS_CONTAINER); +} + +// ── Subscription CRUD ───────────────────────────────────────── + +export async function listSubscriptions( + userId: string, + productId: string +): Promise { + const { resources } = await subsContainer() + .items.query( + { + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId ORDER BY c.createdAt DESC', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + ], + }, + { partitionKey: userId } + ) + .fetchAll(); + + return resources; +} + +export async function getSubscription(id: string, userId: string): Promise { + const { resource } = await subsContainer().item(id, userId).read(); + if (!resource) { + throw new NotFoundError(`Webhook subscription '${id}' not found`); + } + return resource; +} + +export async function createSubscription( + id: string, + userId: string, + productId: string, + input: CreateSubscription +): Promise { + const now = new Date().toISOString(); + const doc: WebhookSubscriptionDoc = { + id, + userId, + productId, + url: input.url, + secret: input.secret, + events: input.events, + active: true, + description: input.description, + createdAt: now, + updatedAt: now, + failureCount: 0, + maxRetries: input.maxRetries ?? 3, + }; + + try { + const { resource } = await subsContainer().items.create(doc); + return resource as WebhookSubscriptionDoc; + } catch (err: unknown) { + if (err && typeof err === 'object' && 'code' in err && err.code === 409) { + throw new ConflictError(`Subscription '${id}' already exists`); + } + throw err; + } +} + +export async function updateSubscription( + id: string, + userId: string, + updates: UpdateSubscription +): Promise { + const existing = await getSubscription(id, userId); + + const updated: WebhookSubscriptionDoc = { + ...existing, + ...updates, + updatedAt: new Date().toISOString(), + }; + + const { resource } = await subsContainer().item(id, userId).replace(updated); + return resource as WebhookSubscriptionDoc; +} + +export async function deleteSubscription(id: string, userId: string): Promise { + await getSubscription(id, userId); // verify exists + await subsContainer().item(id, userId).delete(); +} + +// ── Find Subscriptions for Event ────────────────────────────── + +export async function findSubscriptionsForEvent( + userId: string, + productId: string, + eventType: WebhookEventType +): Promise { + const { resources } = await subsContainer() + .items.query( + { + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.active = true AND ARRAY_CONTAINS(c.events, @eventType)', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + { name: '@eventType', value: eventType }, + ], + }, + { partitionKey: userId } + ) + .fetchAll(); + + return resources; +} + +// ── Increment Failure Count ─────────────────────────────────── + +export async function incrementFailureCount(id: string, userId: string): Promise { + const existing = await getSubscription(id, userId); + const failureCount = (existing.failureCount || 0) + 1; + + // Auto-disable after 10 consecutive failures + const active = failureCount < 10; + + await subsContainer() + .item(id, userId) + .replace({ + ...existing, + failureCount, + active, + updatedAt: new Date().toISOString(), + }); +} + +export async function resetFailureCount(id: string, userId: string): Promise { + const existing = await getSubscription(id, userId); + await subsContainer() + .item(id, userId) + .replace({ + ...existing, + failureCount: 0, + lastDeliveryAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); +} + +// ── Event Log ───────────────────────────────────────────────── + +export async function createEvent(doc: WebhookEventDoc): Promise { + const { resource } = await eventsContainer().items.create(doc); + return resource as WebhookEventDoc; +} + +export async function updateEvent(doc: WebhookEventDoc): Promise { + const { resource } = await eventsContainer().item(doc.id, doc.subscriptionId).replace(doc); + return resource as WebhookEventDoc; +} + +export async function listEvents(subscriptionId: string, limit = 50): Promise { + const { resources } = await eventsContainer() + .items.query( + { + query: + 'SELECT TOP @limit * FROM c WHERE c.subscriptionId = @subscriptionId ORDER BY c.createdAt DESC', + parameters: [ + { name: '@subscriptionId', value: subscriptionId }, + { name: '@limit', value: limit }, + ], + }, + { partitionKey: subscriptionId } + ) + .fetchAll(); + + return resources; +} diff --git a/services/platform-service/src/modules/webhooks/routes.ts b/services/platform-service/src/modules/webhooks/routes.ts new file mode 100644 index 00000000..aedfd9cd --- /dev/null +++ b/services/platform-service/src/modules/webhooks/routes.ts @@ -0,0 +1,111 @@ +import type { FastifyInstance } from 'fastify'; + +import { + CreateSubscriptionSchema, + UpdateSubscriptionSchema, + WEBHOOK_EVENT_TYPES, +} from './types.js'; +import * as repo from './repository.js'; +import { dispatchEvent } from './dispatcher.js'; +import { extractAuth } from '../../lib/auth.js'; +import { BadRequestError } from '../../lib/errors.js'; + +const PRODUCT_ID = 'chronomind'; + +export async function webhookRoutes(app: FastifyInstance) { + // Event types — must be before :id param route + app.get('/webhooks/event-types', async (_req, reply) => { + return reply.send({ + eventTypes: WEBHOOK_EVENT_TYPES.map(type => ({ + type, + category: type.split('.')[0], + action: type.split('.')[1], + })), + }); + }); + + // Test — must be before :id param route + app.post('/webhooks/test', async (req, reply) => { + const auth = await extractAuth(req); + const body = req.body as { subscriptionId?: string; eventType?: string }; + + if (!body.subscriptionId) { + throw new BadRequestError('subscriptionId is required'); + } + + await repo.getSubscription(body.subscriptionId, auth.sub); + + const eventType = (body.eventType || 'timer.fired') as (typeof WEBHOOK_EVENT_TYPES)[number]; + if (!WEBHOOK_EVENT_TYPES.includes(eventType)) { + throw new BadRequestError(`Invalid event type: ${eventType}`); + } + + const results = await dispatchEvent( + auth.sub, + PRODUCT_ID, + eventType, + { + test: true, + message: 'This is a test webhook event from ChronoMind', + timestamp: new Date().toISOString(), + }, + req.log + ); + + return reply.send({ results }); + }); + + // List subscriptions + app.get('/webhooks', async req => { + const auth = await extractAuth(req); + return repo.listSubscriptions(auth.sub, PRODUCT_ID); + }); + + // Get subscription + app.get('/webhooks/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + return repo.getSubscription(id, auth.sub); + }); + + // Create subscription + app.post('/webhooks', async (req, reply) => { + const auth = await extractAuth(req); + const parsed = CreateSubscriptionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const id = crypto.randomUUID(); + const sub = await repo.createSubscription(id, auth.sub, PRODUCT_ID, parsed.data); + return reply.status(201).send(sub); + }); + + // Update subscription + app.put('/webhooks/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const parsed = UpdateSubscriptionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + return repo.updateSubscription(id, auth.sub, parsed.data); + }); + + // Delete subscription + app.delete('/webhooks/:id', async (req, reply) => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + await repo.deleteSubscription(id, auth.sub); + return reply.status(204).send(); + }); + + // List events for subscription + app.get('/webhooks/:id/events', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + // Verify ownership + await repo.getSubscription(id, auth.sub); + const limit = parseInt((req.query as Record).limit || '50', 10); + return repo.listEvents(id, Math.min(limit, 100)); + }); +} diff --git a/services/platform-service/src/modules/webhooks/types.ts b/services/platform-service/src/modules/webhooks/types.ts new file mode 100644 index 00000000..33b3cac6 --- /dev/null +++ b/services/platform-service/src/modules/webhooks/types.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; + +// ── Webhook Event Types ─────────────────────────────────────── + +export const WEBHOOK_EVENT_TYPES = [ + 'timer.created', + 'timer.fired', + 'timer.dismissed', + 'timer.completed', + 'timer.snoozed', + 'timer.paused', + 'timer.resumed', + 'routine.started', + 'routine.completed', + 'routine.step_completed', + 'household.member_joined', + 'household.member_left', + 'shared_timer.created', + 'shared_timer.fired', + 'shared_timer.acknowledged', +] as const; + +export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; + +// ── Subscription Schemas ────────────────────────────────────── + +export const WebhookSubscriptionSchema = z.object({ + id: z.string().min(1), + userId: z.string().min(1), + productId: z.string().min(1), + url: z.string().url(), + secret: z.string().min(16).max(256), + events: z.array(z.enum(WEBHOOK_EVENT_TYPES)).min(1), + active: z.boolean().default(true), + description: z.string().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + lastDeliveryAt: z.string().optional(), + failureCount: z.number().default(0), + maxRetries: z.number().default(3), +}); + +export const CreateSubscriptionSchema = z.object({ + url: z.string().url(), + secret: z.string().min(16).max(256), + events: z.array(z.enum(WEBHOOK_EVENT_TYPES)).min(1), + description: z.string().optional(), + maxRetries: z.number().min(0).max(10).optional(), +}); + +export const UpdateSubscriptionSchema = z.object({ + url: z.string().url().optional(), + secret: z.string().min(16).max(256).optional(), + events: z.array(z.enum(WEBHOOK_EVENT_TYPES)).min(1).optional(), + active: z.boolean().optional(), + description: z.string().optional(), + maxRetries: z.number().min(0).max(10).optional(), +}); + +// ── Event Payload Schema ────────────────────────────────────── + +export const WebhookEventSchema = z.object({ + id: z.string().min(1), + subscriptionId: z.string().min(1), + userId: z.string().min(1), + productId: z.string().min(1), + eventType: z.enum(WEBHOOK_EVENT_TYPES), + payload: z.record(z.unknown()), + createdAt: z.string(), + deliveredAt: z.string().optional(), + statusCode: z.number().optional(), + attempts: z.number().default(0), + maxRetries: z.number().default(3), + nextRetryAt: z.string().optional(), + error: z.string().optional(), +}); + +// ── TypeScript Types ────────────────────────────────────────── + +export type WebhookSubscription = z.infer; +export type CreateSubscription = z.infer; +export type UpdateSubscription = z.infer; +export type WebhookEvent = z.infer; + +// ── Cosmos Document Shapes ──────────────────────────────────── + +export interface WebhookSubscriptionDoc extends WebhookSubscription { + _ts?: number; + _etag?: string; +} + +export interface WebhookEventDoc extends WebhookEvent { + _ts?: number; + _etag?: string; +} diff --git a/services/platform-service/src/modules/webhooks/webhooks.test.ts b/services/platform-service/src/modules/webhooks/webhooks.test.ts new file mode 100644 index 00000000..0160906c --- /dev/null +++ b/services/platform-service/src/modules/webhooks/webhooks.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect } from 'vitest'; +import { + WebhookSubscriptionSchema, + CreateSubscriptionSchema, + UpdateSubscriptionSchema, + WebhookEventSchema, + WEBHOOK_EVENT_TYPES, + type WebhookSubscription, + type CreateSubscription, + type WebhookEvent, +} from './types.js'; +import { signPayload, buildSignatureHeader, verifySignature } from './dispatcher.js'; + +// ── Types & Schema Tests ────────────────────────────────────── + +describe('Webhook Types', () => { + it('should define 15 event types', () => { + expect(WEBHOOK_EVENT_TYPES).toHaveLength(15); + }); + + it('should include all timer event types', () => { + const timerEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('timer.')); + expect(timerEvents).toEqual([ + 'timer.created', + 'timer.fired', + 'timer.dismissed', + 'timer.completed', + 'timer.snoozed', + 'timer.paused', + 'timer.resumed', + ]); + }); + + it('should include all routine event types', () => { + const routineEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('routine.')); + expect(routineEvents).toEqual([ + 'routine.started', + 'routine.completed', + 'routine.step_completed', + ]); + }); + + it('should include all household event types', () => { + const householdEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('household.')); + expect(householdEvents).toEqual(['household.member_joined', 'household.member_left']); + }); + + it('should include all shared_timer event types', () => { + const sharedEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('shared_timer.')); + expect(sharedEvents).toEqual([ + 'shared_timer.created', + 'shared_timer.fired', + 'shared_timer.acknowledged', + ]); + }); + + it('should have unique event types', () => { + const unique = new Set(WEBHOOK_EVENT_TYPES); + expect(unique.size).toBe(WEBHOOK_EVENT_TYPES.length); + }); +}); + +describe('WebhookSubscriptionSchema', () => { + const validSub: WebhookSubscription = { + id: 'sub-1', + userId: 'user-1', + productId: 'chronomind', + url: 'https://example.com/webhook', + secret: 'super-secret-key-1234567', + events: ['timer.fired', 'timer.dismissed'], + active: true, + failureCount: 0, + maxRetries: 3, + }; + + it('should validate a correct subscription', () => { + const result = WebhookSubscriptionSchema.safeParse(validSub); + expect(result.success).toBe(true); + }); + + it('should reject subscription without url', () => { + const result = WebhookSubscriptionSchema.safeParse({ ...validSub, url: '' }); + expect(result.success).toBe(false); + }); + + it('should reject subscription with invalid url', () => { + const result = WebhookSubscriptionSchema.safeParse({ ...validSub, url: 'not-a-url' }); + expect(result.success).toBe(false); + }); + + it('should reject subscription with short secret', () => { + const result = WebhookSubscriptionSchema.safeParse({ ...validSub, secret: 'short' }); + expect(result.success).toBe(false); + }); + + it('should reject subscription with empty events', () => { + const result = WebhookSubscriptionSchema.safeParse({ ...validSub, events: [] }); + expect(result.success).toBe(false); + }); + + it('should reject subscription with invalid event type', () => { + const result = WebhookSubscriptionSchema.safeParse({ + ...validSub, + events: ['timer.fired', 'invalid.event'], + }); + expect(result.success).toBe(false); + }); + + it('should default active to true', () => { + const withoutActive = { ...validSub }; + delete (withoutActive as Record).active; + const result = WebhookSubscriptionSchema.safeParse(withoutActive); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.active).toBe(true); + } + }); + + it('should default failureCount to 0', () => { + const withoutCount = { ...validSub }; + delete (withoutCount as Record).failureCount; + const result = WebhookSubscriptionSchema.safeParse(withoutCount); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.failureCount).toBe(0); + } + }); +}); + +describe('CreateSubscriptionSchema', () => { + const validCreate: CreateSubscription = { + url: 'https://hooks.zapier.com/abc123', + secret: 'webhook-signing-secret-abc123', + events: ['timer.fired'], + }; + + it('should validate a correct create payload', () => { + const result = CreateSubscriptionSchema.safeParse(validCreate); + expect(result.success).toBe(true); + }); + + it('should accept optional description', () => { + const result = CreateSubscriptionSchema.safeParse({ + ...validCreate, + description: 'My Zapier integration', + }); + expect(result.success).toBe(true); + }); + + it('should accept optional maxRetries', () => { + const result = CreateSubscriptionSchema.safeParse({ + ...validCreate, + maxRetries: 5, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maxRetries).toBe(5); + } + }); + + it('should reject maxRetries > 10', () => { + const result = CreateSubscriptionSchema.safeParse({ + ...validCreate, + maxRetries: 15, + }); + expect(result.success).toBe(false); + }); + + it('should accept multiple event types', () => { + const result = CreateSubscriptionSchema.safeParse({ + ...validCreate, + events: ['timer.fired', 'timer.dismissed', 'routine.completed'], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.events).toHaveLength(3); + } + }); +}); + +describe('UpdateSubscriptionSchema', () => { + it('should validate partial updates', () => { + const result = UpdateSubscriptionSchema.safeParse({ active: false }); + expect(result.success).toBe(true); + }); + + it('should validate url-only update', () => { + const result = UpdateSubscriptionSchema.safeParse({ + url: 'https://new-endpoint.example.com/hook', + }); + expect(result.success).toBe(true); + }); + + it('should validate events update', () => { + const result = UpdateSubscriptionSchema.safeParse({ + events: ['timer.created', 'timer.completed'], + }); + expect(result.success).toBe(true); + }); + + it('should reject empty events array in update', () => { + const result = UpdateSubscriptionSchema.safeParse({ events: [] }); + expect(result.success).toBe(false); + }); +}); + +describe('WebhookEventSchema', () => { + const validEvent: WebhookEvent = { + id: 'evt-1', + subscriptionId: 'sub-1', + userId: 'user-1', + productId: 'chronomind', + eventType: 'timer.fired', + payload: { timerId: 'timer-1', label: 'Meeting' }, + createdAt: new Date().toISOString(), + attempts: 1, + maxRetries: 3, + }; + + it('should validate a correct event', () => { + const result = WebhookEventSchema.safeParse(validEvent); + expect(result.success).toBe(true); + }); + + it('should accept delivered event with statusCode', () => { + const result = WebhookEventSchema.safeParse({ + ...validEvent, + deliveredAt: new Date().toISOString(), + statusCode: 200, + }); + expect(result.success).toBe(true); + }); + + it('should accept failed event with error', () => { + const result = WebhookEventSchema.safeParse({ + ...validEvent, + error: 'Connection refused', + attempts: 4, + }); + expect(result.success).toBe(true); + }); +}); + +// ── Dispatcher Tests ────────────────────────────────────────── + +describe('Webhook Dispatcher — HMAC Signing', () => { + const secret = 'test-secret-key-for-hmac-1234'; + const payload = JSON.stringify({ type: 'timer.fired', data: { id: 'timer-1' } }); + + it('should produce consistent HMAC signatures', () => { + const sig1 = signPayload(payload, secret); + const sig2 = signPayload(payload, secret); + expect(sig1).toBe(sig2); + expect(sig1).toMatch(/^[0-9a-f]{64}$/); // SHA-256 hex + }); + + it('should produce different signatures for different payloads', () => { + const sig1 = signPayload('payload-1', secret); + const sig2 = signPayload('payload-2', secret); + expect(sig1).not.toBe(sig2); + }); + + it('should produce different signatures for different secrets', () => { + const sig1 = signPayload(payload, 'secret-1-aaaaaaaaaa'); + const sig2 = signPayload(payload, 'secret-2-bbbbbbbbbb'); + expect(sig1).not.toBe(sig2); + }); +}); + +describe('Webhook Dispatcher — Signature Header', () => { + const secret = 'test-secret-key-for-hmac-5678'; + const body = '{"type":"timer.fired","data":{}}'; + + it('should build a valid signature header', () => { + const header = buildSignatureHeader(body, secret); + expect(header).toMatch(/^t=\d+,v1=[0-9a-f]{64}$/); + }); + + it('should include a recent timestamp', () => { + const header = buildSignatureHeader(body, secret); + const tPart = header.split(',')[0]; + const timestamp = parseInt(tPart.slice(2), 10); + const now = Math.floor(Date.now() / 1000); + expect(Math.abs(now - timestamp)).toBeLessThan(5); + }); +}); + +describe('Webhook Dispatcher — Signature Verification', () => { + const secret = 'test-secret-for-verification!'; + const body = JSON.stringify({ type: 'timer.dismissed', data: { id: 't-99' } }); + + it('should verify a valid signature', () => { + const header = buildSignatureHeader(body, secret); + expect(verifySignature(header, body, secret)).toBe(true); + }); + + it('should reject a tampered body', () => { + const header = buildSignatureHeader(body, secret); + expect(verifySignature(header, body + 'tampered', secret)).toBe(false); + }); + + it('should reject a wrong secret', () => { + const header = buildSignatureHeader(body, secret); + expect(verifySignature(header, body, 'wrong-secret-1234567890')).toBe(false); + }); + + it('should reject a malformed header', () => { + expect(verifySignature('invalid', body, secret)).toBe(false); + }); + + it('should reject missing timestamp', () => { + expect(verifySignature('v1=abc123', body, secret)).toBe(false); + }); + + it('should reject missing signature', () => { + expect(verifySignature('t=1234567890', body, secret)).toBe(false); + }); + + it('should reject expired timestamp', () => { + // Build a header with a timestamp from 10 minutes ago + const oldTimestamp = Math.floor(Date.now() / 1000) - 600; + // signPayload produces HMAC of the raw string, matching verifySignature's `${timestamp}.${body}` pattern + const sig = signPayload(`${oldTimestamp}.${body}`, secret); + const header = `t=${oldTimestamp},v1=${sig}`; + // Default tolerance is 300 seconds (5 minutes) — 600s ago should be rejected + expect(verifySignature(header, body, secret, 300)).toBe(false); + }); + + it('should accept within tolerance window', () => { + const header = buildSignatureHeader(body, secret); + // Use a large tolerance window + expect(verifySignature(header, body, secret, 3600)).toBe(true); + }); +}); + +// ── Event Type Categorization Tests ─────────────────────────── + +describe('Event Type Categories', () => { + it('all event types should have category.action format', () => { + for (const type of WEBHOOK_EVENT_TYPES) { + const parts = type.split('.'); + expect(parts).toHaveLength(2); + expect(parts[0].length).toBeGreaterThan(0); + expect(parts[1].length).toBeGreaterThan(0); + } + }); + + it('should have 4 categories', () => { + const categories = new Set(WEBHOOK_EVENT_TYPES.map(t => t.split('.')[0])); + expect(categories.size).toBe(4); + expect(categories).toContain('timer'); + expect(categories).toContain('routine'); + expect(categories).toContain('household'); + expect(categories).toContain('shared_timer'); + }); +}); diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 9b684424..dcf3ccd0 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -54,6 +54,7 @@ import { timerRoutes } from './modules/timers/routes.js'; import { routineRoutes } from './modules/routines/routes.js'; import { householdRoutes } from './modules/households/routes.js'; import { sharedTimerRoutes } from './modules/shared-timers/routes.js'; +import { webhookRoutes } from './modules/webhooks/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { config } from './lib/config.js'; @@ -137,5 +138,7 @@ await app.register(timerRoutes, { prefix: '/api' }); await app.register(routineRoutes, { prefix: '/api' }); await app.register(householdRoutes, { prefix: '/api' }); await app.register(sharedTimerRoutes, { prefix: '/api' }); +// Webhooks module (subscriptions + event dispatch) +await app.register(webhookRoutes, { prefix: '/api' }); await startService(app, { port: config.PORT, host: config.HOST });