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' },
|
routines: { partitionKeyPath: '/userId' },
|
||||||
households: { partitionKeyPath: '/id' },
|
households: { partitionKeyPath: '/id' },
|
||||||
shared_timers: { partitionKeyPath: '/householdId' },
|
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 (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md)
|
||||||
telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
||||||
telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 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 { routineRoutes } from './modules/routines/routes.js';
|
||||||
import { householdRoutes } from './modules/households/routes.js';
|
import { householdRoutes } from './modules/households/routes.js';
|
||||||
import { sharedTimerRoutes } from './modules/shared-timers/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 { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { config } from './lib/config.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(routineRoutes, { prefix: '/api' });
|
||||||
await app.register(householdRoutes, { prefix: '/api' });
|
await app.register(householdRoutes, { prefix: '/api' });
|
||||||
await app.register(sharedTimerRoutes, { 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 });
|
await startService(app, { port: config.PORT, host: config.HOST });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user