notifkit 0.1.7 → 0.1.9

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 (40) hide show
  1. package/dashboard/README.md +29 -0
  2. package/dashboard/dist/assets/index-7mvXb4bS.js +310 -0
  3. package/dashboard/dist/assets/index-o7L-f3-c.css +1 -0
  4. package/dashboard/dist/favicon.svg +4 -0
  5. package/dashboard/dist/index.html +14 -0
  6. package/dist/index.d.mts +447 -9
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +2 -2
  9. package/dist/{main-CRPxM-O3.mjs → main-4Wyge9ms.mjs} +2 -2
  10. package/dist/{main-CRPxM-O3.mjs.map → main-4Wyge9ms.mjs.map} +1 -1
  11. package/dist/{main-Bgi2wWck.mjs → main-AO0JaY5z.mjs} +2 -2
  12. package/dist/{main-Bgi2wWck.mjs.map → main-AO0JaY5z.mjs.map} +1 -1
  13. package/dist/{main-DFMHcN_d.mjs → main-BMYSYcv2.mjs} +2 -2
  14. package/dist/{main-DFMHcN_d.mjs.map → main-BMYSYcv2.mjs.map} +1 -1
  15. package/dist/{main-Yorxzw0Z.mjs → main-BPbPnBIG.mjs} +2 -2
  16. package/dist/{main-Yorxzw0Z.mjs.map → main-BPbPnBIG.mjs.map} +1 -1
  17. package/dist/{main-pFAfCkVX.mjs → main-CyeQ_yDt.mjs} +2 -2
  18. package/dist/{main-pFAfCkVX.mjs.map → main-CyeQ_yDt.mjs.map} +1 -1
  19. package/dist/{main-CjbVdUqa.mjs → main-D19Wxs0x.mjs} +2 -2
  20. package/dist/{main-CjbVdUqa.mjs.map → main-D19Wxs0x.mjs.map} +1 -1
  21. package/dist/{main-D6SG3Isk.mjs → main-DSmh1e-V.mjs} +2 -2
  22. package/dist/{main-D6SG3Isk.mjs.map → main-DSmh1e-V.mjs.map} +1 -1
  23. package/dist/{main-CFSukWm8.mjs → main-JdOvJ70b.mjs} +495 -41
  24. package/dist/main-JdOvJ70b.mjs.map +1 -0
  25. package/dist/{src-CPMwsUCJ.mjs → src-CGmXRfsM.mjs} +86 -20
  26. package/dist/src-CGmXRfsM.mjs.map +1 -0
  27. package/drizzle/0005_admin_users.sql +11 -0
  28. package/drizzle/meta/_journal.json +7 -0
  29. package/package.json +8 -3
  30. package/scripts/create-admin.mjs +67 -0
  31. package/src/config/index.ts +12 -0
  32. package/src/db/schema.ts +13 -0
  33. package/src/repositories/index.ts +82 -1
  34. package/src/services/api/admin-static.ts +225 -0
  35. package/src/services/api/handlers.ts +224 -33
  36. package/src/services/api/main.ts +183 -29
  37. package/src/services/api/router.ts +7 -1
  38. package/src/services/auth/index.ts +111 -0
  39. package/dist/main-CFSukWm8.mjs.map +0 -1
  40. package/dist/src-CPMwsUCJ.mjs.map +0 -1
@@ -11,11 +11,14 @@ import {
11
11
  ProjectRepository,
12
12
  WorkflowRepository,
13
13
  SegmentRepository,
14
+ AdminUserRepository,
14
15
  } from "@/repositories/index.js";
15
16
  import { STREAMS } from "@/contracts/index.js";
16
17
  import { readJsonBody, readRawBody, sendJson, HttpError } from "./http.js";
17
18
  import { Router } from "./router.js";
18
19
  import { createHandlers } from "./handlers.js";
20
+ import { handleAdminRequest } from "./admin-static.js";
21
+ import { getAdminSession, hashPassword } from "@/services/auth/index.js";
19
22
  import { projects, messageLogs, projectApiKeys, suppressions } from "@/db/schema.js";
20
23
  import { eq, inArray } from "drizzle-orm";
21
24
  import { randomBytes, timingSafeEqual, createHash } from "node:crypto";
@@ -58,6 +61,20 @@ export function extractAuthToken(req: Pick<IncomingMessage, "headers">): string
58
61
  return undefined;
59
62
  }
60
63
 
64
+ /**
65
+ * Resolves client IP address, respecting reverse proxy headers only when TRUST_PROXY is enabled.
66
+ */
67
+ export function getClientIp(req: IncomingMessage, trustProxy: boolean): string {
68
+ if (trustProxy) {
69
+ const forwarded = req.headers["x-forwarded-for"];
70
+ if (typeof forwarded === "string") {
71
+ const first = forwarded.split(",")[0]?.trim();
72
+ if (first) return first;
73
+ }
74
+ }
75
+ return req.socket.remoteAddress || "unknown";
76
+ }
77
+
61
78
  async function handleCreateProject(req: IncomingMessage, res: ServerResponse): Promise<void> {
62
79
  const parsed = z.object({ name: z.string().min(1) }).safeParse(await readJsonBody(req));
63
80
  if (!parsed.success) {
@@ -127,7 +144,42 @@ async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise
127
144
  sendJson(res, statusCode, response);
128
145
  }
129
146
 
130
- async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promise<void> {
147
+ async function handleMetrics(req: IncomingMessage, res: ServerResponse): Promise<void> {
148
+ const remoteIp = req.socket.remoteAddress ?? "";
149
+ const isLoopback =
150
+ remoteIp === "127.0.0.1" ||
151
+ remoteIp === "::1" ||
152
+ remoteIp === "::ffff:127.0.0.1" ||
153
+ remoteIp === "localhost";
154
+
155
+ let authorized = isLoopback;
156
+ if (!authorized) {
157
+ const token = extractAuthToken(req);
158
+ if (token) {
159
+ if (token.startsWith("nk_sess_") && redis?.native) {
160
+ const session = await getAdminSession(redis.native, token);
161
+ if (session) authorized = true;
162
+ } else if (config.ADMIN_API_KEY) {
163
+ const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
164
+ const providedBuffer = Buffer.from(token);
165
+ if (
166
+ expectedBuffer.length === providedBuffer.length &&
167
+ timingSafeEqual(expectedBuffer, providedBuffer)
168
+ ) {
169
+ authorized = true;
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ if (!authorized) {
176
+ sendJson(res, 401, {
177
+ error: "unauthorized",
178
+ message: "Metrics endpoint requires admin credentials",
179
+ });
180
+ return;
181
+ }
182
+
131
183
  const registry = getMetricsRegistry();
132
184
  res.writeHead(200, { "Content-Type": registry.contentType });
133
185
  res.end(await registry.metrics());
@@ -164,6 +216,15 @@ let server: ReturnType<typeof createServer>;
164
216
 
165
217
  export async function startApiServer() {
166
218
  logger = createLogger({ name: "api", level: config.LOG_LEVEL });
219
+
220
+ if (config.NODE_ENV === "production" && !config.UNSUBSCRIBE_SECRET) {
221
+ logger.fatal(
222
+ "UNSUBSCRIBE_SECRET is required in production. Without it, all unsubscribe links " +
223
+ "return 400 and opt-outs are silently dropped. Set a random string of 32+ characters and restart.",
224
+ );
225
+ process.exit(1);
226
+ }
227
+
167
228
  redis = new RedisClient({ url: config.REDIS_URL, name: "api", logger });
168
229
  const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: "api", logger });
169
230
  sql = dbData.sql;
@@ -177,6 +238,26 @@ export async function startApiServer() {
177
238
  events: new StreamProducer({ redis: redis.native, stream: STREAMS.EVENTS_INBOUND, logger }),
178
239
  };
179
240
 
241
+ const adminUserRepo = new AdminUserRepository(db);
242
+
243
+ if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) {
244
+ try {
245
+ const existing = await adminUserRepo.findByEmailOrUsername(config.ADMIN_EMAIL);
246
+ if (!existing) {
247
+ const passwordHash = await hashPassword(config.ADMIN_PASSWORD);
248
+ await adminUserRepo.create({
249
+ email: config.ADMIN_EMAIL,
250
+ username: config.ADMIN_USERNAME,
251
+ passwordHash,
252
+ role: "superadmin",
253
+ });
254
+ logger.info({ email: config.ADMIN_EMAIL }, "Bootstrapped initial admin user");
255
+ }
256
+ } catch (err) {
257
+ logger.error({ err }, "Failed to bootstrap default admin user");
258
+ }
259
+ }
260
+
180
261
  deps = {
181
262
  logger,
182
263
  redis,
@@ -187,12 +268,16 @@ export async function startApiServer() {
187
268
  projectRepo: new ProjectRepository(db),
188
269
  workflowRepo: new WorkflowRepository(db),
189
270
  segmentRepo: new SegmentRepository(db),
271
+ adminUserRepo,
190
272
  db,
191
273
  };
192
274
  h = createHandlers(deps);
193
275
 
194
276
  router = new Router();
195
277
  router
278
+ .post("/v1/auth/login", h.login)
279
+ .post("/v1/auth/logout", h.logout)
280
+ .get("/v1/auth/me", h.getMe)
196
281
  .put("/v1/templates", h.syncTemplates)
197
282
  .get("/v1/templates", h.listTemplates)
198
283
  .get("/v1/templates/:id", h.getTemplate)
@@ -253,8 +338,28 @@ export async function startApiServer() {
253
338
  server.keepAliveTimeout = 5_000;
254
339
 
255
340
  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", "*");
341
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
342
+ const origin = req.headers["origin"] as string | undefined;
343
+ const allowedOrigins = (config.CORS_ORIGIN ?? "")
344
+ .split(",")
345
+ .map((s) => s.trim())
346
+ .filter(Boolean);
347
+ const isSessionRoute =
348
+ url.pathname.startsWith("/v1/auth/") ||
349
+ url.pathname === "/v1/projects" ||
350
+ url.pathname.startsWith("/v1/projects/") ||
351
+ url.pathname.startsWith("/v1/system/") ||
352
+ url.pathname.startsWith("/v1/dlq");
353
+
354
+ if (isSessionRoute) {
355
+ if (origin && allowedOrigins.includes(origin)) {
356
+ res.setHeader("Access-Control-Allow-Origin", origin);
357
+ res.setHeader("Vary", "Origin");
358
+ }
359
+ } else {
360
+ res.setHeader("Access-Control-Allow-Origin", "*");
361
+ }
362
+
258
363
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
259
364
  res.setHeader(
260
365
  "Access-Control-Allow-Headers",
@@ -267,11 +372,15 @@ export async function startApiServer() {
267
372
  return;
268
373
  }
269
374
 
270
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
375
+ if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) {
376
+ const handled = await handleAdminRequest(req, res, url);
377
+ if (handled) return;
378
+ }
271
379
 
272
380
  let projectId: string | undefined = undefined;
273
381
  let projectRateLimitRpm = 600;
274
382
  let keyRole: "admin" | "read_only" = "admin";
383
+ let isAdminToken = false;
275
384
 
276
385
  const isProjectManagement =
277
386
  url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/");
@@ -280,8 +389,14 @@ export async function startApiServer() {
280
389
  // will. The signed token in the URL is the credential, and it authorises
281
390
  // exactly one action for one address.
282
391
  const isPublicUnsubscribe = url.pathname === "/v1/unsubscribe";
283
-
284
- if (url.pathname.startsWith("/v1/") && !isProjectManagement && !isPublicUnsubscribe) {
392
+ const isPublicAuth = url.pathname === "/v1/auth/login";
393
+
394
+ if (
395
+ url.pathname.startsWith("/v1/") &&
396
+ !isProjectManagement &&
397
+ !isPublicUnsubscribe &&
398
+ !isPublicAuth
399
+ ) {
285
400
  let token = extractAuthToken(req);
286
401
  if (!token && url.searchParams.has("token")) {
287
402
  token = url.searchParams.get("token") || undefined;
@@ -292,8 +407,13 @@ export async function startApiServer() {
292
407
  return;
293
408
  }
294
409
 
295
- let isAdminToken = false;
296
- if (config.ADMIN_API_KEY) {
410
+ if (token.startsWith("nk_sess_")) {
411
+ const session = await getAdminSession(redis.native, token);
412
+ if (session) {
413
+ isAdminToken = true;
414
+ keyRole = session.role === "superadmin" ? "admin" : (session.role as any);
415
+ }
416
+ } else if (config.ADMIN_API_KEY) {
297
417
  const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
298
418
  const providedBuffer = Buffer.from(token);
299
419
  if (
@@ -309,7 +429,11 @@ export async function startApiServer() {
309
429
  (req.headers["x-project-id"] as string | undefined) ||
310
430
  url.searchParams.get("projectId") ||
311
431
  undefined;
312
- if (!headerProjectId) {
432
+ const isProjectAgnostic =
433
+ url.pathname.startsWith("/v1/auth/") ||
434
+ url.pathname.startsWith("/v1/system/") ||
435
+ url.pathname.startsWith("/v1/dlq");
436
+ if (!headerProjectId && !isProjectAgnostic) {
313
437
  sendJson(res, 400, {
314
438
  error: "bad_request",
315
439
  message: "x-project-id header or projectId query param required when using admin token",
@@ -318,7 +442,9 @@ export async function startApiServer() {
318
442
  }
319
443
  projectId = headerProjectId;
320
444
  projectRateLimitRpm = 6000;
321
- keyRole = "admin";
445
+ if (!token.startsWith("nk_sess_")) {
446
+ keyRole = "admin";
447
+ }
322
448
  } else {
323
449
  const tokenHash = createHash("sha256").update(token).digest("hex");
324
450
  const cached = authCache.get(tokenHash);
@@ -374,7 +500,7 @@ export async function startApiServer() {
374
500
  end
375
501
  return -1
376
502
  `;
377
- const rlKey = `rate-limit:api:req:${projectId}`;
503
+ const rlKey = `rate-limit:api:req:${projectId || "global"}`;
378
504
  const nowMs = Date.now();
379
505
  const count = (await redis.native.eval(
380
506
  LUA_LIMIT,
@@ -398,27 +524,31 @@ export async function startApiServer() {
398
524
  } else if (isProjectManagement) {
399
525
  const token = extractAuthToken(req);
400
526
 
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
527
  if (!token) {
409
528
  sendJson(res, 401, { error: "unauthorized", message: "Missing admin token" });
410
529
  return;
411
530
  }
412
531
 
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
- ) {
532
+ let authorized = false;
533
+ if (token.startsWith("nk_sess_")) {
534
+ const session = await getAdminSession(redis.native, token);
535
+ if (session) authorized = true;
536
+ } else if (config.ADMIN_API_KEY) {
537
+ const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
538
+ const providedBuffer = Buffer.from(token);
539
+ if (
540
+ expectedBuffer.length === providedBuffer.length &&
541
+ timingSafeEqual(expectedBuffer, providedBuffer)
542
+ ) {
543
+ authorized = true;
544
+ }
545
+ }
546
+
547
+ if (!authorized) {
419
548
  sendJson(res, 401, { error: "unauthorized", message: "Invalid admin token" });
420
549
  return;
421
550
  }
551
+ isAdminToken = true;
422
552
  }
423
553
 
424
554
  // The unsubscribe routes skip the block above, and with it the per-project
@@ -426,10 +556,7 @@ export async function startApiServer() {
426
556
  // an open endpoint cannot be used to hammer the process. Generous, because
427
557
  // a shared corporate egress IP can legitimately produce a burst.
428
558
  if (isPublicUnsubscribe) {
429
- const clientIp =
430
- (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ||
431
- req.socket.remoteAddress ||
432
- "unknown";
559
+ const clientIp = getClientIp(req, Boolean(config.TRUST_PROXY));
433
560
  try {
434
561
  const key = `rate-limit:api:unsub:${clientIp}`;
435
562
  const count = await redis.native.incr(key);
@@ -446,6 +573,27 @@ export async function startApiServer() {
446
573
  }
447
574
  }
448
575
 
576
+ // The login route is unauthenticated and CPU-intensive due to password hashing (scrypt).
577
+ // Throttle login attempts per IP to prevent credential brute-forcing and CPU exhaustion.
578
+ if (isPublicAuth) {
579
+ const clientIp = getClientIp(req, Boolean(config.TRUST_PROXY));
580
+ try {
581
+ const key = `rate-limit:api:auth:${clientIp}`;
582
+ const count = await redis.native.incr(key);
583
+ if (count === 1) await redis.native.expire(key, 60);
584
+ if (count > 10) {
585
+ res.setHeader("Retry-After", "60");
586
+ sendJson(res, 429, {
587
+ error: "too_many_requests",
588
+ message: "Too many login attempts. Please try again later.",
589
+ });
590
+ return;
591
+ }
592
+ } catch (err) {
593
+ logger.warn({ err }, "auth rate limit unavailable — allowing request");
594
+ }
595
+ }
596
+
449
597
  const route = router.match(req.method ?? "GET", url.pathname);
450
598
 
451
599
  if (!route) {
@@ -453,7 +601,13 @@ export async function startApiServer() {
453
601
  return;
454
602
  }
455
603
 
456
- const ctx = { params: route.params, query: url.searchParams, projectId, role: keyRole };
604
+ const ctx = {
605
+ params: route.params,
606
+ query: url.searchParams,
607
+ projectId,
608
+ role: keyRole,
609
+ isAdmin: isAdminToken,
610
+ };
457
611
 
458
612
  void Promise.resolve(route.handler(req, res, ctx)).catch((err: unknown) => {
459
613
  if (err instanceof HttpError) {
@@ -5,6 +5,7 @@ export interface RouteContext {
5
5
  query: URLSearchParams;
6
6
  projectId?: string;
7
7
  role?: "admin" | "read_only";
8
+ isAdmin?: boolean;
8
9
  }
9
10
 
10
11
  export type RouteHandler = (
@@ -65,7 +66,12 @@ export class Router {
65
66
  const seg = route.segments[i]!;
66
67
  const part = parts[i]!;
67
68
  if (seg.startsWith(":")) {
68
- params[seg.slice(1)] = decodeURIComponent(part);
69
+ try {
70
+ params[seg.slice(1)] = decodeURIComponent(part);
71
+ } catch {
72
+ ok = false;
73
+ break;
74
+ }
69
75
  } else if (seg !== part) {
70
76
  ok = false;
71
77
  break;
@@ -0,0 +1,111 @@
1
+ import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
2
+ import { promisify } from "node:util";
3
+ import type { RedisClient } from "@/redis/index.js";
4
+ import type { AdminUserRecord } from "@/repositories/index.js";
5
+
6
+ const scryptAsync = promisify(scrypt);
7
+
8
+ const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
9
+ const SESSION_PREFIX = "notif:session:";
10
+
11
+ /**
12
+ * Pre-computed dummy hash conforming to salt(16 bytes hex):hash(64 bytes hex).
13
+ * Used during authentication failure to equalize execution time and prevent
14
+ * user enumeration via scrypt timing side-channels.
15
+ */
16
+ export const DUMMY_PASSWORD_HASH =
17
+ "00000000000000000000000000000000:00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
18
+
19
+ export interface AdminSession {
20
+ token: string;
21
+ adminId: string;
22
+ email: string;
23
+ username: string | null;
24
+ role: string;
25
+ createdAt: number;
26
+ expiresAt: number;
27
+ }
28
+
29
+ /**
30
+ * Hashes a plaintext password using crypto.scrypt with a random 16-byte salt.
31
+ * Returns salt and hash separated by a colon.
32
+ */
33
+ export async function hashPassword(password: string): Promise<string> {
34
+ const salt = randomBytes(16).toString("hex");
35
+ const derivedKey = (await scryptAsync(password, salt, 64)) as Buffer;
36
+ return `${salt}:${derivedKey.toString("hex")}`;
37
+ }
38
+
39
+ /**
40
+ * Verifies a plaintext password against a stored salt:hash string using constant-time comparison.
41
+ */
42
+ export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
43
+ const [salt, key] = storedHash.split(":");
44
+ if (!salt || !key) return false;
45
+
46
+ const keyBuffer = Buffer.from(key, "hex");
47
+ const derivedKey = (await scryptAsync(password, salt, keyBuffer.length)) as Buffer;
48
+
49
+ if (derivedKey.length !== keyBuffer.length) {
50
+ return false;
51
+ }
52
+ return timingSafeEqual(derivedKey, keyBuffer);
53
+ }
54
+
55
+ /**
56
+ * Creates a secure session in Redis for an authenticated admin user.
57
+ */
58
+ export async function createAdminSession(
59
+ redis: RedisClient["native"],
60
+ user: AdminUserRecord,
61
+ ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS,
62
+ ): Promise<AdminSession> {
63
+ const token = `nk_sess_${randomBytes(32).toString("hex")}`;
64
+ const now = Date.now();
65
+ const session: AdminSession = {
66
+ token,
67
+ adminId: user.id,
68
+ email: user.email,
69
+ username: user.username,
70
+ role: user.role,
71
+ createdAt: now,
72
+ expiresAt: now + ttlSeconds * 1000,
73
+ };
74
+
75
+ await redis.set(`${SESSION_PREFIX}${token}`, JSON.stringify(session), "EX", ttlSeconds);
76
+ return session;
77
+ }
78
+
79
+ /**
80
+ * Retrieves an admin session from Redis if valid and unexpired.
81
+ */
82
+ export async function getAdminSession(
83
+ redis: RedisClient["native"],
84
+ token: string,
85
+ ): Promise<AdminSession | null> {
86
+ if (!token || !token.startsWith("nk_sess_")) return null;
87
+ const raw = await redis.get(`${SESSION_PREFIX}${token}`);
88
+ if (!raw) return null;
89
+
90
+ try {
91
+ const session = JSON.parse(raw) as AdminSession;
92
+ if (session.expiresAt <= Date.now()) {
93
+ await redis.del(`${SESSION_PREFIX}${token}`);
94
+ return null;
95
+ }
96
+ return session;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Revokes an active admin session.
104
+ */
105
+ export async function revokeAdminSession(
106
+ redis: RedisClient["native"],
107
+ token: string,
108
+ ): Promise<void> {
109
+ if (!token) return;
110
+ await redis.del(`${SESSION_PREFIX}${token}`);
111
+ }