notifkit 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +179 -152
  2. package/dist/index.d.mts +193 -129
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
  7. package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
  8. package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
  9. package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
  10. package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
  11. package/dist/main-BNJtzY61.mjs.map +1 -0
  12. package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
  13. package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
  14. package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
  15. package/dist/main-CiigNpsP.mjs.map +1 -0
  16. package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
  17. package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
  18. package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
  19. package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
  20. package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
  21. package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
  22. package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
  23. package/dist/src-vG79L-8m.mjs.map +1 -0
  24. package/drizzle/0002_wide_colleen_wing.sql +2 -0
  25. package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
  26. package/drizzle/0004_pretty_bruce_banner.sql +1 -0
  27. package/drizzle/meta/0002_snapshot.json +1460 -0
  28. package/drizzle/meta/0003_snapshot.json +1460 -0
  29. package/drizzle/meta/0004_snapshot.json +1470 -0
  30. package/drizzle/meta/_journal.json +21 -0
  31. package/package.json +7 -1
  32. package/scripts/create-project.mjs +61 -0
  33. package/src/client.ts +412 -0
  34. package/src/config/index.ts +107 -0
  35. package/src/contracts/common.ts +28 -0
  36. package/src/contracts/envelope.ts +31 -0
  37. package/src/contracts/events/notification-ai-pending.ts +18 -0
  38. package/src/contracts/events/notification-canceled.ts +7 -0
  39. package/src/contracts/events/notification-created.ts +14 -0
  40. package/src/contracts/events/notification-delivered.ts +17 -0
  41. package/src/contracts/events/notification-dispatched.ts +45 -0
  42. package/src/contracts/events/notification-enriched.ts +46 -0
  43. package/src/contracts/events/notification-failed.ts +19 -0
  44. package/src/contracts/events/notification-requested.ts +36 -0
  45. package/src/contracts/events/notification-scheduled.ts +9 -0
  46. package/src/contracts/events/notification-skipped.ts +9 -0
  47. package/src/contracts/helpers.ts +21 -0
  48. package/src/contracts/index.ts +46 -0
  49. package/src/contracts/metadata.ts +10 -0
  50. package/src/contracts/registry.ts +88 -0
  51. package/src/contracts/sdk.ts +242 -0
  52. package/src/contracts/streams.ts +62 -0
  53. package/src/db/index.ts +69 -0
  54. package/src/db/schema.ts +412 -0
  55. package/src/idempotency/index.ts +50 -0
  56. package/src/index.ts +19 -0
  57. package/src/logger/index.ts +60 -0
  58. package/src/metrics/index.ts +53 -0
  59. package/src/queue/index.ts +501 -0
  60. package/src/rate-limiter/index.ts +210 -0
  61. package/src/redis/index.ts +89 -0
  62. package/src/repositories/index.ts +1246 -0
  63. package/src/server.ts +277 -0
  64. package/src/services/ai/main.ts +404 -0
  65. package/src/services/api/handlers.ts +1734 -0
  66. package/src/services/api/http.ts +64 -0
  67. package/src/services/api/main.ts +693 -0
  68. package/src/services/api/router.ts +82 -0
  69. package/src/services/delivery/main.ts +842 -0
  70. package/src/services/delivery/throttle.ts +71 -0
  71. package/src/services/engine/main.ts +827 -0
  72. package/src/services/enricher/main.ts +594 -0
  73. package/src/services/events/main.ts +365 -0
  74. package/src/services/scheduler/main.ts +319 -0
  75. package/src/services/workflow/main.ts +627 -0
  76. package/src/shared/batch-processor.ts +67 -0
  77. package/src/shared/cache.ts +47 -0
  78. package/src/shared/circuit-breaker.ts +74 -0
  79. package/src/shared/dataloader.ts +41 -0
  80. package/src/shared/events.ts +3 -0
  81. package/src/shared/index.ts +39 -0
  82. package/src/shared/semaphore.ts +33 -0
  83. package/src/shared/utils.ts +64 -0
  84. package/src/templates/cache.ts +32 -0
  85. package/src/templates/index.ts +69 -0
  86. package/src/templates/render.ts +128 -0
  87. package/src/transport/index.ts +96 -0
  88. package/src/unsubscribe/index.ts +127 -0
  89. package/src/workers/health.ts +31 -0
  90. package/src/workers/index.ts +266 -0
  91. package/src/workflows/index.ts +2 -0
  92. package/src/workflows/registry.ts +21 -0
  93. package/src/workflows/sdk.ts +106 -0
  94. package/dist/main-CAH0_Q6d.mjs.map +0 -1
  95. package/dist/main-CCfc45ev.mjs.map +0 -1
  96. package/dist/src-C-PfEDMY.mjs.map +0 -1
@@ -0,0 +1,693 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { loadEnv, readBaseConfig } from "@/config/index.js";
3
+ import { createLogger } from "@/logger/index.js";
4
+ import { RedisClient } from "@/redis/index.js";
5
+ import { StreamProducer } from "@/queue/index.js";
6
+ import { createDatabase } from "@/db/index.js";
7
+ import {
8
+ UserRepository,
9
+ ContactRepository,
10
+ TemplateRepository,
11
+ ProjectRepository,
12
+ WorkflowRepository,
13
+ SegmentRepository,
14
+ } from "@/repositories/index.js";
15
+ import { STREAMS } from "@/contracts/index.js";
16
+ import { readJsonBody, readRawBody, sendJson, HttpError } from "./http.js";
17
+ import { Router } from "./router.js";
18
+ import { createHandlers } from "./handlers.js";
19
+ import { projects, messageLogs, projectApiKeys, suppressions } from "@/db/schema.js";
20
+ import { eq, inArray } from "drizzle-orm";
21
+ import { randomBytes, timingSafeEqual, createHash } from "node:crypto";
22
+ import { LRUCache, normaliseTarget } from "@/shared/index.js";
23
+ import type { Redis } from "@/redis/index.js";
24
+ import { z } from "zod";
25
+ import { getMetricsRegistry } from "@/metrics/index.js";
26
+
27
+ /** Pub/sub channel used to drop a cached API key across every API process. */
28
+ export const API_KEY_INVALIDATION_CHANNEL = "apikey.invalidated";
29
+
30
+ // ─── Bootstrap ─────────────────────────────────────────────────────────────
31
+
32
+ loadEnv();
33
+ const config = readBaseConfig();
34
+
35
+ let logger: ReturnType<typeof createLogger>;
36
+ let redis: RedisClient;
37
+ let sql: any;
38
+ let db: any;
39
+ let producers: Record<string, StreamProducer>;
40
+ let deps: any;
41
+ let h: any;
42
+ // Short-TTL LRU so a hot key does not hit the DB on every request. Kept small
43
+ // and invalidated on key change so a revoked key stops working promptly.
44
+ const AUTH_CACHE_TTL_MS = 60_000;
45
+ let authCache = new LRUCache<string, string>(1000, AUTH_CACHE_TTL_MS);
46
+ let authSubscriber: Redis | null = null;
47
+ let router: Router;
48
+
49
+ export function extractAuthToken(req: Pick<IncomingMessage, "headers">): string | undefined {
50
+ const authHeader = req.headers["authorization"];
51
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) {
52
+ return authHeader.slice(7).trim();
53
+ }
54
+ const apiKeyHeader = req.headers["x-api-key"];
55
+ if (typeof apiKeyHeader === "string") {
56
+ return apiKeyHeader.trim();
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ async function handleCreateProject(req: IncomingMessage, res: ServerResponse): Promise<void> {
62
+ const parsed = z.object({ name: z.string().min(1) }).safeParse(await readJsonBody(req));
63
+ if (!parsed.success) {
64
+ sendJson(res, 400, { error: "validation_error", issues: parsed.error.issues });
65
+ return;
66
+ }
67
+ const apiKey = `nk_live_${randomBytes(32).toString("hex")}`;
68
+ const apiKeyHash = createHash("sha256").update(apiKey).digest("hex");
69
+ const rows = await db.insert(projects).values({ name: parsed.data.name }).returning();
70
+ const projectId = rows[0]!.id;
71
+ await db.insert(projectApiKeys).values({ projectId, keyHash: apiKeyHash, role: "admin" });
72
+ sendJson(res, 201, { id: projectId, apiKey });
73
+ }
74
+
75
+ let cachedHealth: { response: any; statusCode: number; expiresAt: number } | null = null;
76
+
77
+ async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
78
+ if (cachedHealth && cachedHealth.expiresAt > Date.now()) {
79
+ sendJson(res, cachedHealth.statusCode, cachedHealth.response);
80
+ return;
81
+ }
82
+
83
+ const [redisOk, dbOk] = await Promise.all([redis.healthCheck(), dbHealthCheck()]);
84
+
85
+ const workers: Record<string, any> = {};
86
+ let overallOk = redisOk && dbOk;
87
+
88
+ if (redisOk) {
89
+ const keys = ["enricher", "engine", "scheduler", "delivery", "ai", "workflow", "events"];
90
+ try {
91
+ const vals = await redis.native.mget(keys.map((k) => `notif:health:${k}`));
92
+ for (let i = 0; i < keys.length; i++) {
93
+ const key = keys[i] as string;
94
+ const val = vals[i];
95
+ if (val) {
96
+ const parsed = JSON.parse(val);
97
+ workers[key] = parsed;
98
+ if (parsed.redis === false || parsed.state === "error") {
99
+ overallOk = false;
100
+ }
101
+ } else {
102
+ workers[key] = { status: "unknown", message: "No report received from worker" };
103
+ }
104
+ }
105
+ } catch (err) {
106
+ for (const key of keys) {
107
+ workers[key] = { status: "error", error: err instanceof Error ? err.message : String(err) };
108
+ }
109
+ }
110
+ }
111
+
112
+ const statusCode = overallOk ? 200 : 503;
113
+ const response = {
114
+ service: "api",
115
+ status: overallOk ? "ok" : "degraded",
116
+ redis: redisOk,
117
+ database: dbOk,
118
+ workers,
119
+ };
120
+
121
+ cachedHealth = {
122
+ response,
123
+ statusCode,
124
+ expiresAt: Date.now() + 1000,
125
+ };
126
+
127
+ sendJson(res, statusCode, response);
128
+ }
129
+
130
+ async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promise<void> {
131
+ const registry = getMetricsRegistry();
132
+ res.writeHead(200, { "Content-Type": registry.contentType });
133
+ res.end(await registry.metrics());
134
+ }
135
+
136
+ async function handleLive(_req: IncomingMessage, res: ServerResponse): Promise<void> {
137
+ sendJson(res, 200, { status: "ok" });
138
+ }
139
+
140
+ async function handleReady(_req: IncomingMessage, res: ServerResponse): Promise<void> {
141
+ if (cachedHealth && cachedHealth.expiresAt > Date.now()) {
142
+ sendJson(res, cachedHealth.statusCode === 200 ? 200 : 503, {
143
+ status: cachedHealth.statusCode === 200 ? "ready" : "unready",
144
+ });
145
+ return;
146
+ }
147
+ const [redisOk, dbOk] = await Promise.all([redis.healthCheck(), dbHealthCheck()]);
148
+ const isReady = redisOk && dbOk;
149
+ sendJson(res, isReady ? 200 : 503, { status: isReady ? "ready" : "unready" });
150
+ }
151
+
152
+ async function dbHealthCheck(): Promise<boolean> {
153
+ try {
154
+ await sql`SELECT 1`;
155
+ return true;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ // ─── HTTP server ────────────────────────────────────────────────────────────
162
+
163
+ let server: ReturnType<typeof createServer>;
164
+
165
+ export async function startApiServer() {
166
+ logger = createLogger({ name: "api", level: config.LOG_LEVEL });
167
+ redis = new RedisClient({ url: config.REDIS_URL, name: "api", logger });
168
+ const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: "api", logger });
169
+ sql = dbData.sql;
170
+ db = dbData.db;
171
+
172
+ producers = {
173
+ critical: new StreamProducer({ redis: redis.native, stream: STREAMS.INBOUND_CRITICAL, logger }),
174
+ normal: new StreamProducer({ redis: redis.native, stream: STREAMS.INBOUND_NORMAL, logger }),
175
+ low: new StreamProducer({ redis: redis.native, stream: STREAMS.INBOUND_LOW, logger }),
176
+ workflow: new StreamProducer({ redis: redis.native, stream: STREAMS.WORKFLOW_INBOUND, logger }),
177
+ events: new StreamProducer({ redis: redis.native, stream: STREAMS.EVENTS_INBOUND, logger }),
178
+ };
179
+
180
+ deps = {
181
+ logger,
182
+ redis,
183
+ producers,
184
+ userRepo: new UserRepository(db),
185
+ contactRepo: new ContactRepository(db),
186
+ templateRepo: new TemplateRepository(db),
187
+ projectRepo: new ProjectRepository(db),
188
+ workflowRepo: new WorkflowRepository(db),
189
+ segmentRepo: new SegmentRepository(db),
190
+ db,
191
+ };
192
+ h = createHandlers(deps);
193
+
194
+ router = new Router();
195
+ router
196
+ .put("/v1/templates", h.syncTemplates)
197
+ .get("/v1/templates", h.listTemplates)
198
+ .get("/v1/templates/:id", h.getTemplate)
199
+ .delete("/v1/templates/:id", h.deleteTemplate)
200
+ .post("/v1/users", h.addUser)
201
+ .get("/v1/users", h.listUsers)
202
+ .get("/v1/users/:id", h.getUser)
203
+ .get("/v1/users/:id/details", h.getUserDetails)
204
+ .patch("/v1/users/:id", h.updateUser)
205
+ .delete("/v1/users/:id", h.deleteUser)
206
+ .post("/v1/users/:id/contacts", h.addContact)
207
+ .get("/v1/users/:id/contacts", h.getUserContacts)
208
+ .delete("/v1/users/:id/contacts/:channel/:target", h.deleteContact)
209
+ .get("/v1/users/:id/preferences", h.getUserPreferences)
210
+ .patch("/v1/users/:id/preferences", h.updateUserPreferences)
211
+ .post("/v1/notify", h.notify)
212
+ .get("/v1/notifications/scheduled", h.getScheduledMessages)
213
+ .get("/v1/notifications/logs", h.getNotificationLogs)
214
+ .get("/v1/notifications/:taskId", h.getNotificationStatus)
215
+ .delete("/v1/notifications/:taskId", h.cancelNotification)
216
+ .get("/v1/unsubscribe", h.unsubscribePage)
217
+ .post("/v1/unsubscribe", h.unsubscribe)
218
+ .get("/v1/campaigns", h.listCampaigns)
219
+ .get("/v1/campaigns/:campaign/stats", h.getCampaignStats)
220
+ .get("/v1/suppressions", h.listSuppressions)
221
+ .post("/v1/suppressions", h.createSuppression)
222
+ .delete("/v1/suppressions/:channel/:target", h.deleteSuppression)
223
+ .get("/v1/system/health", h.getSystemHealth)
224
+ .get("/v1/system/metrics", h.getSystemMetrics)
225
+ .get("/v1/dlq", h.getDLQMessages)
226
+ .post("/v1/dlq/replay", h.replayDLQMessage)
227
+ .delete("/v1/dlq/:id", h.deleteDLQMessage)
228
+ .post("/v1/workflows", h.createWorkflow)
229
+ .get("/v1/workflows", h.listWorkflows)
230
+ .get("/v1/workflows/instances/:id", h.getWorkflow)
231
+ .delete("/v1/workflows/instances/:id", h.cancelWorkflow)
232
+ .post("/v1/workflows/trigger", h.triggerWorkflow)
233
+ .get("/v1/segments", h.listSegments)
234
+ .post("/v1/events", h.ingestEvent)
235
+ .get("/v1/events/stream", h.getEventsStream)
236
+ .get("/v1/projects", h.listProjects)
237
+ .post("/v1/projects", handleCreateProject)
238
+ .delete("/v1/projects/:id", h.deleteProject)
239
+ .patch("/v1/projects/:id", h.updateProject)
240
+ .post("/v1/projects/:id/keys", h.createProjectKey)
241
+ .get("/v1/projects/:id/keys", h.listProjectKeys)
242
+ .delete("/v1/projects/:id/keys/:keyId", h.deleteProjectKey)
243
+ .get("/health", handleHealth)
244
+ .get("/metrics", handleMetrics)
245
+ .get("/live", handleLive)
246
+ .get("/ready", handleReady);
247
+
248
+ server = createServer((req, res) => {
249
+ void handleRequest(req, res);
250
+ });
251
+ server.requestTimeout = 30_000;
252
+ server.headersTimeout = 10_000;
253
+ server.keepAliveTimeout = 5_000;
254
+
255
+ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
256
+ // Add CORS headers for browser clients (like the Next.js dashboard)
257
+ res.setHeader("Access-Control-Allow-Origin", "*");
258
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
259
+ res.setHeader(
260
+ "Access-Control-Allow-Headers",
261
+ "Content-Type, Authorization, x-api-key, x-project-id",
262
+ );
263
+
264
+ if (req.method === "OPTIONS") {
265
+ res.writeHead(204);
266
+ res.end();
267
+ return;
268
+ }
269
+
270
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
271
+
272
+ let projectId: string | undefined = undefined;
273
+ let projectRateLimitRpm = 600;
274
+ let keyRole: "admin" | "read_only" = "admin";
275
+
276
+ const isProjectManagement =
277
+ url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/");
278
+
279
+ // Unsubscribe is reached from a mail client, which has no API key and never
280
+ // will. The signed token in the URL is the credential, and it authorises
281
+ // exactly one action for one address.
282
+ const isPublicUnsubscribe = url.pathname === "/v1/unsubscribe";
283
+
284
+ if (url.pathname.startsWith("/v1/") && !isProjectManagement && !isPublicUnsubscribe) {
285
+ let token = extractAuthToken(req);
286
+ if (!token && url.searchParams.has("token")) {
287
+ token = url.searchParams.get("token") || undefined;
288
+ }
289
+
290
+ if (!token) {
291
+ sendJson(res, 401, { error: "unauthorized", message: "Invalid or missing API key" });
292
+ return;
293
+ }
294
+
295
+ let isAdminToken = false;
296
+ if (config.ADMIN_API_KEY) {
297
+ const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
298
+ const providedBuffer = Buffer.from(token);
299
+ if (
300
+ expectedBuffer.length === providedBuffer.length &&
301
+ timingSafeEqual(expectedBuffer, providedBuffer)
302
+ ) {
303
+ isAdminToken = true;
304
+ }
305
+ }
306
+
307
+ if (isAdminToken) {
308
+ const headerProjectId =
309
+ (req.headers["x-project-id"] as string | undefined) ||
310
+ url.searchParams.get("projectId") ||
311
+ undefined;
312
+ if (!headerProjectId) {
313
+ sendJson(res, 400, {
314
+ error: "bad_request",
315
+ message: "x-project-id header or projectId query param required when using admin token",
316
+ });
317
+ return;
318
+ }
319
+ projectId = headerProjectId;
320
+ projectRateLimitRpm = 6000;
321
+ keyRole = "admin";
322
+ } else {
323
+ const tokenHash = createHash("sha256").update(token).digest("hex");
324
+ const cached = authCache.get(tokenHash);
325
+ if (cached) {
326
+ const parts = cached.split(":");
327
+ projectId = parts[0];
328
+ projectRateLimitRpm = parseInt(parts[1] || "600", 10);
329
+ keyRole = parts[2] as "admin" | "read_only";
330
+ } else {
331
+ const rows = await db
332
+ .select({
333
+ id: projects.id,
334
+ rateLimitRpm: projects.rateLimitRpm,
335
+ role: projectApiKeys.role,
336
+ })
337
+ .from(projectApiKeys)
338
+ .innerJoin(projects, eq(projects.id, projectApiKeys.projectId))
339
+ .where(eq(projectApiKeys.keyHash, tokenHash))
340
+ .limit(1);
341
+ if (!rows.length) {
342
+ sendJson(res, 401, { error: "unauthorized", message: "Invalid or missing API key" });
343
+ return;
344
+ }
345
+ projectId = rows[0]!.id;
346
+ projectRateLimitRpm = rows[0]!.rateLimitRpm ?? 600;
347
+ keyRole = rows[0]!.role;
348
+ authCache.set(tokenHash, `${projectId}:${projectRateLimitRpm}:${keyRole}`);
349
+ }
350
+ }
351
+
352
+ // Role Check
353
+ if (keyRole === "read_only") {
354
+ const isMutating = req.method !== "GET" && req.method !== "OPTIONS";
355
+ if (isMutating) {
356
+ sendJson(res, 403, { error: "forbidden", message: "API key is read-only" });
357
+ return;
358
+ }
359
+ }
360
+
361
+ // Project rate limit - Sliding Window
362
+ const LUA_LIMIT = `
363
+ local key = KEYS[1]
364
+ local now = tonumber(ARGV[1])
365
+ local window = tonumber(ARGV[2])
366
+ local maxReqs = tonumber(ARGV[3])
367
+ local cutoff = now - window
368
+ redis.call("ZREMRANGEBYSCORE", key, "-inf", cutoff)
369
+ local count = redis.call("ZCARD", key)
370
+ if count < maxReqs then
371
+ redis.call("ZADD", key, now, now .. "-" .. ARGV[4])
372
+ redis.call("EXPIRE", key, math.ceil(window / 1000))
373
+ return count + 1
374
+ end
375
+ return -1
376
+ `;
377
+ const rlKey = `rate-limit:api:req:${projectId}`;
378
+ const nowMs = Date.now();
379
+ const count = (await redis.native.eval(
380
+ LUA_LIMIT,
381
+ 1,
382
+ rlKey,
383
+ nowMs,
384
+ 60000,
385
+ projectRateLimitRpm,
386
+ randomBytes(4).toString("hex"),
387
+ )) as number;
388
+ if (count === -1) {
389
+ if (!res.headersSent) {
390
+ res.setHeader("Retry-After", "60");
391
+ sendJson(res, 429, {
392
+ error: "too_many_requests",
393
+ message: `Project rate limit exceeded (max ${projectRateLimitRpm} req/min)`,
394
+ });
395
+ }
396
+ return;
397
+ }
398
+ } else if (isProjectManagement) {
399
+ const token = extractAuthToken(req);
400
+
401
+ if (!config.ADMIN_API_KEY) {
402
+ sendJson(res, 403, {
403
+ error: "forbidden",
404
+ message: "Project management disabled (no ADMIN_API_KEY set)",
405
+ });
406
+ return;
407
+ }
408
+ if (!token) {
409
+ sendJson(res, 401, { error: "unauthorized", message: "Missing admin token" });
410
+ return;
411
+ }
412
+
413
+ const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
414
+ const providedBuffer = Buffer.from(token);
415
+ if (
416
+ expectedBuffer.length !== providedBuffer.length ||
417
+ !timingSafeEqual(expectedBuffer, providedBuffer)
418
+ ) {
419
+ sendJson(res, 401, { error: "unauthorized", message: "Invalid admin token" });
420
+ return;
421
+ }
422
+ }
423
+
424
+ // The unsubscribe routes skip the block above, and with it the per-project
425
+ // rate limit — they are public by necessity. Cap them per client instead so
426
+ // an open endpoint cannot be used to hammer the process. Generous, because
427
+ // a shared corporate egress IP can legitimately produce a burst.
428
+ if (isPublicUnsubscribe) {
429
+ const clientIp =
430
+ (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ||
431
+ req.socket.remoteAddress ||
432
+ "unknown";
433
+ try {
434
+ const key = `rate-limit:api:unsub:${clientIp}`;
435
+ const count = await redis.native.incr(key);
436
+ if (count === 1) await redis.native.expire(key, 60);
437
+ if (count > 60) {
438
+ res.setHeader("Retry-After", "60");
439
+ sendJson(res, 429, { error: "too_many_requests" });
440
+ return;
441
+ }
442
+ } catch (err) {
443
+ // Redis being down must not stop someone unsubscribing — failing open
444
+ // here is the lesser evil against a recipient who cannot opt out.
445
+ logger.warn({ err }, "unsubscribe rate limit unavailable — allowing request");
446
+ }
447
+ }
448
+
449
+ const route = router.match(req.method ?? "GET", url.pathname);
450
+
451
+ if (!route) {
452
+ sendJson(res, 404, { error: "not_found" });
453
+ return;
454
+ }
455
+
456
+ const ctx = { params: route.params, query: url.searchParams, projectId, role: keyRole };
457
+
458
+ void Promise.resolve(route.handler(req, res, ctx)).catch((err: unknown) => {
459
+ if (err instanceof HttpError) {
460
+ if (!res.headersSent) sendJson(res, err.status, { error: err.code, message: err.message });
461
+ return;
462
+ }
463
+ logger.error({ err, path: url.pathname }, "unhandled request error");
464
+ if (!res.headersSent) sendJson(res, 500, { error: "internal_error" });
465
+ });
466
+ }
467
+
468
+ // Let key rotation take effect immediately instead of waiting out the TTL.
469
+ authSubscriber = redis.native.duplicate();
470
+ await authSubscriber.subscribe(API_KEY_INVALIDATION_CHANNEL);
471
+ authSubscriber.on("message", (channel: string, tokenHash: string) => {
472
+ if (channel !== API_KEY_INVALIDATION_CHANNEL) return;
473
+ if (tokenHash === "*") authCache.clear();
474
+ else authCache.delete(tokenHash);
475
+ logger.info({ tokenHash }, "api key cache invalidated");
476
+ });
477
+
478
+ const { transportRegistry } = await import("../../transport/index.js");
479
+ for (const channel of transportRegistry.registeredChannels()) {
480
+ const transport = transportRegistry.get(channel as any);
481
+ if (!transport?.webhookPath) continue;
482
+
483
+ if (transport.verifyWebhookChallenge) {
484
+ router.get(transport.webhookPath, async (_req, res, ctx) => {
485
+ try {
486
+ const challenge = await transport.verifyWebhookChallenge!(ctx.query);
487
+ if (challenge === undefined) {
488
+ res.writeHead(403, { "Content-Type": "text/plain" });
489
+ res.end("Forbidden");
490
+ return;
491
+ }
492
+ res.writeHead(200, { "Content-Type": "text/plain" });
493
+ res.end(challenge);
494
+ } catch (err) {
495
+ logger.error({ err, channel }, "webhook verification challenge error");
496
+ if (!res.headersSent) {
497
+ res.writeHead(500, { "Content-Type": "text/plain" });
498
+ res.end("internal_error");
499
+ }
500
+ }
501
+ });
502
+ logger.info(`Mounted webhook verification GET for ${channel} at ${transport.webhookPath}`);
503
+ }
504
+
505
+ if (transport.parseWebhook) {
506
+ router.post(transport.webhookPath, async (req, res) => {
507
+ try {
508
+ await Promise.race([
509
+ (async () => {
510
+ const rawBody = await readRawBody(req);
511
+ if (!transport.verifyWebhook) {
512
+ if (!res.headersSent)
513
+ sendJson(res, 501, {
514
+ error: "not_implemented",
515
+ message: "Webhook signature verification is not implemented for this provider",
516
+ });
517
+ return;
518
+ }
519
+
520
+ const isValid = await transport.verifyWebhook(rawBody, req.headers);
521
+ if (!isValid) {
522
+ if (!res.headersSent)
523
+ sendJson(res, 401, {
524
+ error: "unauthorized",
525
+ message: "Invalid webhook signature",
526
+ });
527
+ return;
528
+ }
529
+ let body;
530
+ try {
531
+ body = rawBody ? JSON.parse(rawBody) : {};
532
+ } catch {
533
+ body = {};
534
+ }
535
+ const events = await transport.parseWebhook!(body, rawBody, req.headers);
536
+
537
+ if (events.length > 0) {
538
+ const providerIds = events
539
+ .map((e) => e.providerMessageId)
540
+ .filter(Boolean) as string[];
541
+ const logsToProject = new Map<string, string>();
542
+ const logsToTask = new Map<string, string>();
543
+ const logsToCampaign = new Map<string, string | null>();
544
+ if (providerIds.length > 0) {
545
+ const existing = await db
546
+ .select({
547
+ providerMessageId: messageLogs.providerMessageId,
548
+ projectId: messageLogs.projectId,
549
+ taskId: messageLogs.taskId,
550
+ campaignId: messageLogs.campaignId,
551
+ })
552
+ .from(messageLogs)
553
+ .where(inArray(messageLogs.providerMessageId, providerIds));
554
+ for (const row of existing) {
555
+ if (row.providerMessageId) {
556
+ logsToProject.set(row.providerMessageId, row.projectId);
557
+ logsToTask.set(row.providerMessageId, row.taskId);
558
+ // Inherit the campaign from the delivery this event is
559
+ // about, or an open never counts toward the send that
560
+ // earned it.
561
+ logsToCampaign.set(row.providerMessageId, row.campaignId);
562
+ }
563
+ }
564
+ }
565
+
566
+ // Only record events we can attribute to a real message. An
567
+ // unresolvable providerMessageId used to be written against a
568
+ // zero-UUID project, which is unjoinable and leaks across tenants.
569
+ const attributable = events.filter(
570
+ (e) => e.providerMessageId && logsToProject.has(e.providerMessageId),
571
+ );
572
+
573
+ const rows = attributable.map((e) => ({
574
+ projectId: logsToProject.get(e.providerMessageId)!,
575
+ taskId: logsToTask.get(e.providerMessageId)!,
576
+ providerMessageId: e.providerMessageId,
577
+ channel: transport.channel,
578
+ // Engagement events are not delivery attempts.
579
+ attempt: 0,
580
+ kind: e.status,
581
+ status: e.status,
582
+ campaignId: logsToCampaign.get(e.providerMessageId) ?? null,
583
+ metadata: e.metadata ?? null,
584
+ }));
585
+
586
+ const skipped = events.length - rows.length;
587
+ if (skipped > 0) {
588
+ logger.warn(
589
+ { channel, skipped },
590
+ "webhook events skipped: unknown providerMessageId",
591
+ );
592
+ }
593
+
594
+ if (rows.length > 0) {
595
+ // Providers retry on any non-2xx, so redelivery of an event we have
596
+ // already recorded must be a no-op rather than a constraint error.
597
+ await db.insert(messageLogs).values(rows).onConflictDoNothing();
598
+ }
599
+
600
+ // An unsubscribe, a spam complaint, or a permanently dead
601
+ // address must stop future sends — logging it is not enough.
602
+ // Soft bounces are excluded: the address is still good.
603
+ const suppressionRows = attributable
604
+ .filter(
605
+ (e) =>
606
+ e.recipient &&
607
+ (e.status === "unsubscribed" ||
608
+ e.status === "complained" ||
609
+ (e.status === "bounced" && e.bounceType === "hard")),
610
+ )
611
+ .map((e) => ({
612
+ projectId: logsToProject.get(e.providerMessageId)!,
613
+ channel: transport.channel,
614
+ target: normaliseTarget(e.recipient!),
615
+ reason: e.status,
616
+ source: channel,
617
+ taskId: logsToTask.get(e.providerMessageId)!,
618
+ }));
619
+
620
+ if (suppressionRows.length > 0) {
621
+ // First reason wins: a later bounce should not overwrite the
622
+ // record that the person actively unsubscribed.
623
+ await db.insert(suppressions).values(suppressionRows).onConflictDoNothing();
624
+ logger.info(
625
+ { channel, count: suppressionRows.length },
626
+ "addresses suppressed from provider webhook",
627
+ );
628
+ }
629
+
630
+ const unsuppressable = attributable.filter(
631
+ (e) =>
632
+ !e.recipient &&
633
+ (e.status === "unsubscribed" ||
634
+ e.status === "complained" ||
635
+ e.status === "bounced"),
636
+ ).length;
637
+ if (unsuppressable > 0) {
638
+ // Worth shouting about: the event arrived, we logged it, and
639
+ // we still cannot stop mailing the person.
640
+ logger.warn(
641
+ { channel, count: unsuppressable },
642
+ "suppression events carried no recipient address — transport must set WebhookEvent.recipient",
643
+ );
644
+ }
645
+ }
646
+ if (!res.headersSent) sendJson(res, 200, { success: true });
647
+ })(),
648
+ new Promise((_, reject) =>
649
+ setTimeout(() => reject(new Error("Webhook processing timeout")), 15000),
650
+ ),
651
+ ]);
652
+ } catch (err: any) {
653
+ logger.error({ err, channel }, "webhook processing error or timeout");
654
+ if (!res.headersSent) {
655
+ sendJson(res, 500, { error: "internal_error", message: err.message });
656
+ }
657
+ }
658
+ });
659
+ logger.info(`Mounted webhook for ${channel} at ${transport.webhookPath}`);
660
+ }
661
+ }
662
+
663
+ server.listen(config.PORT, config.HOST, () => {
664
+ logger.info(
665
+ { port: config.PORT, host: config.HOST, env: config.NODE_ENV },
666
+ "api server listening",
667
+ );
668
+ });
669
+ }
670
+
671
+ // ─── Shutdown ──────────────────────────────────────────────────────────────
672
+
673
+ export async function stopApiServer(): Promise<void> {
674
+ logger?.info("api shutdown initiated");
675
+ if (server) {
676
+ await new Promise<void>((resolve, reject) => {
677
+ server.close((err) => (err ? reject(err) : resolve()));
678
+ });
679
+ server.closeIdleConnections?.();
680
+ server = null as any;
681
+ }
682
+ if (authSubscriber) {
683
+ authSubscriber.disconnect();
684
+ authSubscriber = null;
685
+ }
686
+ // These are module-scoped, so a restart in the same process must not inherit
687
+ // the previous instance's cached auth or health.
688
+ authCache = new LRUCache<string, string>(1000, AUTH_CACHE_TTL_MS);
689
+ cachedHealth = null;
690
+ if (sql) await sql.end();
691
+ if (redis) await redis.disconnect();
692
+ logger?.info("api stopped");
693
+ }