feat(webhooks): add webhook subscriptions module — 15 event types, HMAC signing, retry delivery
This commit is contained in:
parent
b4237acaa2
commit
5e8f133816
@ -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 },
|
||||
|
||||
200
services/platform-service/src/modules/webhooks/dispatcher.ts
Normal file
200
services/platform-service/src/modules/webhooks/dispatcher.ts
Normal 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;
|
||||
}
|
||||
192
services/platform-service/src/modules/webhooks/repository.ts
Normal file
192
services/platform-service/src/modules/webhooks/repository.ts
Normal 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;
|
||||
}
|
||||
111
services/platform-service/src/modules/webhooks/routes.ts
Normal file
111
services/platform-service/src/modules/webhooks/routes.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
95
services/platform-service/src/modules/webhooks/types.ts
Normal file
95
services/platform-service/src/modules/webhooks/types.ts
Normal 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;
|
||||
}
|
||||
356
services/platform-service/src/modules/webhooks/webhooks.test.ts
Normal file
356
services/platform-service/src/modules/webhooks/webhooks.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user