feat(webhooks): add webhook subscriptions module — 15 event types, HMAC signing, retry delivery

This commit is contained in:
saravanakumardb1 2026-02-28 00:27:32 -08:00
parent b4237acaa2
commit 5e8f133816
7 changed files with 960 additions and 0 deletions

View File

@ -42,6 +42,9 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
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 },

View File

@ -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<string, unknown>,
log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }
): Promise<DeliveryResult[]> {
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<string, unknown>,
log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }
): Promise<DeliveryResult> {
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<void>(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;
}

View File

@ -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<WebhookSubscriptionDoc[]> {
const { resources } = await subsContainer()
.items.query<WebhookSubscriptionDoc>(
{
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<WebhookSubscriptionDoc> {
const { resource } = await subsContainer().item(id, userId).read<WebhookSubscriptionDoc>();
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<WebhookSubscriptionDoc> {
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<WebhookSubscriptionDoc> {
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<void> {
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<WebhookSubscriptionDoc[]> {
const { resources } = await subsContainer()
.items.query<WebhookSubscriptionDoc>(
{
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<void> {
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<void> {
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<WebhookEventDoc> {
const { resource } = await eventsContainer().items.create(doc);
return resource as WebhookEventDoc;
}
export async function updateEvent(doc: WebhookEventDoc): Promise<WebhookEventDoc> {
const { resource } = await eventsContainer().item(doc.id, doc.subscriptionId).replace(doc);
return resource as WebhookEventDoc;
}
export async function listEvents(subscriptionId: string, limit = 50): Promise<WebhookEventDoc[]> {
const { resources } = await eventsContainer()
.items.query<WebhookEventDoc>(
{
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;
}

View File

@ -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<string, string>).limit || '50', 10);
return repo.listEvents(id, Math.min(limit, 100));
});
}

View File

@ -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<typeof WebhookSubscriptionSchema>;
export type CreateSubscription = z.infer<typeof CreateSubscriptionSchema>;
export type UpdateSubscription = z.infer<typeof UpdateSubscriptionSchema>;
export type WebhookEvent = z.infer<typeof WebhookEventSchema>;
// ── Cosmos Document Shapes ────────────────────────────────────
export interface WebhookSubscriptionDoc extends WebhookSubscription {
_ts?: number;
_etag?: string;
}
export interface WebhookEventDoc extends WebhookEvent {
_ts?: number;
_etag?: string;
}

View File

@ -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<string, unknown>).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<string, unknown>).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');
});
});

View File

@ -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 });