notifkit 0.1.7 → 0.1.8

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 (39) 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 +433 -8
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +2 -2
  9. package/dist/{main-D6SG3Isk.mjs → main-0FHNtN5g.mjs} +2 -2
  10. package/dist/{main-D6SG3Isk.mjs.map → main-0FHNtN5g.mjs.map} +1 -1
  11. package/dist/{main-pFAfCkVX.mjs → main-7KMlWnDx.mjs} +2 -2
  12. package/dist/{main-pFAfCkVX.mjs.map → main-7KMlWnDx.mjs.map} +1 -1
  13. package/dist/{main-Bgi2wWck.mjs → main-B0BcY_JJ.mjs} +2 -2
  14. package/dist/{main-Bgi2wWck.mjs.map → main-B0BcY_JJ.mjs.map} +1 -1
  15. package/dist/{main-CFSukWm8.mjs → main-BAO4kKce.mjs} +366 -26
  16. package/dist/main-BAO4kKce.mjs.map +1 -0
  17. package/dist/{main-CRPxM-O3.mjs → main-BjXgGJBd.mjs} +2 -2
  18. package/dist/{main-CRPxM-O3.mjs.map → main-BjXgGJBd.mjs.map} +1 -1
  19. package/dist/{main-CjbVdUqa.mjs → main-BtXITy0k.mjs} +2 -2
  20. package/dist/{main-CjbVdUqa.mjs.map → main-BtXITy0k.mjs.map} +1 -1
  21. package/dist/{main-Yorxzw0Z.mjs → main-BxqAXTr6.mjs} +2 -2
  22. package/dist/{main-Yorxzw0Z.mjs.map → main-BxqAXTr6.mjs.map} +1 -1
  23. package/dist/{main-DFMHcN_d.mjs → main-zCpvA9Rx.mjs} +2 -2
  24. package/dist/{main-DFMHcN_d.mjs.map → main-zCpvA9Rx.mjs.map} +1 -1
  25. package/dist/{src-CPMwsUCJ.mjs → src-DkvHYM3y.mjs} +76 -19
  26. package/dist/src-DkvHYM3y.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 +3 -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 +224 -0
  35. package/src/services/api/handlers.ts +129 -12
  36. package/src/services/api/main.ts +72 -19
  37. package/src/services/auth/index.ts +103 -0
  38. package/dist/main-CFSukWm8.mjs.map +0 -1
  39. package/dist/src-CPMwsUCJ.mjs.map +0 -1
@@ -1,8 +1,11 @@
1
- import { Bt as PreferencesSchema, C as ProjectRepository, D as WorkflowRepository, E as UserRepository, Ft as ContactChannelSchema, G as normaliseTarget, Ht as SyncTemplatesSchema, It as CreateWorkflowSchema, K as LRUCache, Kt as UpdateUserSchema, Nt as AddUserSchema, Pt as BatchAddContactsSchema, Q as metrics, T as TemplateRepository, W as getPriorityBucket, Wt as TriggerWorkflowSchema, X as StreamProducer, Xt as buildStreamEvent, Z as getMetricsRegistry, _n as readBaseConfig, ct as projectApiKeys, dt as suppressions, et as createLogger, ft as userTopicPreferences, hn as loadEnv, it as createDatabase, k as RedisClient, lt as projects, mt as workflowDefinitions, nn as STREAMS, p as verifyUnsubscribeToken, pt as users, q as globalEmitter, st as messageLogs, tn as PUBSUB_CHANNELS, ut as scheduledPayloads, w as SegmentRepository, x as ContactRepository, zt as NotifyRequestSchema } from "./src-CPMwsUCJ.mjs";
1
+ import { $ as metrics, A as RedisClient, Bt as NotifyRequestSchema, D as UserRepository, E as TemplateRepository, Ft as BatchAddContactsSchema, G as getPriorityBucket, Gt as TriggerWorkflowSchema, It as ContactChannelSchema, J as globalEmitter, K as normaliseTarget, Lt as CreateWorkflowSchema, O as WorkflowRepository, Pt as AddUserSchema, Q as getMetricsRegistry, S as ContactRepository, T as SegmentRepository, Ut as SyncTemplatesSchema, Vt as PreferencesSchema, Z as StreamProducer, Zt as buildStreamEvent, at as createDatabase, ct as messageLogs, dt as scheduledPayloads, ft as suppressions, gn as loadEnv, ht as workflowDefinitions, lt as projectApiKeys, mt as users, nn as PUBSUB_CHANNELS, p as verifyUnsubscribeToken, pt as userTopicPreferences, q as LRUCache, qt as UpdateUserSchema, rn as STREAMS, tt as createLogger, ut as projects, vn as readBaseConfig, w as ProjectRepository, x as AdminUserRepository } from "./src-DkvHYM3y.mjs";
2
+ import { extname, join, resolve } from "node:path";
2
3
  import { z } from "zod";
3
- import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
4
5
  import { and, desc, eq, gte, inArray, isNotNull, like, lt, lte, or, sql } from "drizzle-orm";
5
- import { createServer } from "node:http";
6
+ import { createServer, request } from "node:http";
7
+ import { promisify } from "node:util";
8
+ import { createReadStream, existsSync, statSync } from "node:fs";
6
9
  //#region src/services/api/http.ts
7
10
  async function readRawBody(req) {
8
11
  return await new Promise((resolve, reject) => {
@@ -122,6 +125,73 @@ function split(path) {
122
125
  return path.split("/").filter((s) => s.length > 0);
123
126
  }
124
127
  //#endregion
128
+ //#region src/services/auth/index.ts
129
+ const scryptAsync = promisify(scrypt);
130
+ const DEFAULT_SESSION_TTL_SECONDS = 604800;
131
+ const SESSION_PREFIX = "notif:session:";
132
+ /**
133
+ * Hashes a plaintext password using crypto.scrypt with a random 16-byte salt.
134
+ * Returns salt and hash separated by a colon.
135
+ */
136
+ async function hashPassword(password) {
137
+ const salt = randomBytes(16).toString("hex");
138
+ return `${salt}:${(await scryptAsync(password, salt, 64)).toString("hex")}`;
139
+ }
140
+ /**
141
+ * Verifies a plaintext password against a stored salt:hash string using constant-time comparison.
142
+ */
143
+ async function verifyPassword(password, storedHash) {
144
+ const [salt, key] = storedHash.split(":");
145
+ if (!salt || !key) return false;
146
+ const keyBuffer = Buffer.from(key, "hex");
147
+ const derivedKey = await scryptAsync(password, salt, keyBuffer.length);
148
+ if (derivedKey.length !== keyBuffer.length) return false;
149
+ return timingSafeEqual(derivedKey, keyBuffer);
150
+ }
151
+ /**
152
+ * Creates a secure session in Redis for an authenticated admin user.
153
+ */
154
+ async function createAdminSession(redis, user, ttlSeconds = DEFAULT_SESSION_TTL_SECONDS) {
155
+ const token = `nk_sess_${randomBytes(32).toString("hex")}`;
156
+ const now = Date.now();
157
+ const session = {
158
+ token,
159
+ adminId: user.id,
160
+ email: user.email,
161
+ username: user.username,
162
+ role: user.role,
163
+ createdAt: now,
164
+ expiresAt: now + ttlSeconds * 1e3
165
+ };
166
+ await redis.set(`${SESSION_PREFIX}${token}`, JSON.stringify(session), "EX", ttlSeconds);
167
+ return session;
168
+ }
169
+ /**
170
+ * Retrieves an admin session from Redis if valid and unexpired.
171
+ */
172
+ async function getAdminSession(redis, token) {
173
+ if (!token || !token.startsWith("nk_sess_")) return null;
174
+ const raw = await redis.get(`${SESSION_PREFIX}${token}`);
175
+ if (!raw) return null;
176
+ try {
177
+ const session = JSON.parse(raw);
178
+ if (session.expiresAt <= Date.now()) {
179
+ await redis.del(`${SESSION_PREFIX}${token}`);
180
+ return null;
181
+ }
182
+ return session;
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+ /**
188
+ * Revokes an active admin session.
189
+ */
190
+ async function revokeAdminSession(redis, token) {
191
+ if (!token) return;
192
+ await redis.del(`${SESSION_PREFIX}${token}`);
193
+ }
194
+ //#endregion
125
195
  //#region src/services/api/handlers.ts
126
196
  const IngestEventSchema = z.object({
127
197
  name: z.string().min(1),
@@ -809,17 +879,28 @@ function createHandlers(deps) {
809
879
  } catch {
810
880
  streamDepths[key] = 0;
811
881
  }
812
- const totalTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(eq(messageLogs.projectId, ctx.projectId));
813
- const deliveredTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(and(eq(messageLogs.projectId, ctx.projectId), eq(messageLogs.status, "delivered")));
814
- const total = Number(totalTasksRes[0]?.count ?? 0);
815
- const delivered = Number(deliveredTasksRes[0]?.count ?? 0);
882
+ let total = 0;
883
+ let delivered = 0;
884
+ if (ctx.projectId) {
885
+ const totalTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(eq(messageLogs.projectId, ctx.projectId));
886
+ const deliveredTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(and(eq(messageLogs.projectId, ctx.projectId), eq(messageLogs.status, "delivered")));
887
+ total = Number(totalTasksRes[0]?.count ?? 0);
888
+ delivered = Number(deliveredTasksRes[0]?.count ?? 0);
889
+ } else {
890
+ const totalTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs);
891
+ const deliveredTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(eq(messageLogs.status, "delivered"));
892
+ total = Number(totalTasksRes[0]?.count ?? 0);
893
+ delivered = Number(deliveredTasksRes[0]?.count ?? 0);
894
+ }
895
+ const failed = streamDepths.DEAD_LETTER || 0;
896
+ const successRate = total > 0 ? Number((delivered / total * 100).toFixed(2)) : 100;
816
897
  sendJson(res, 200, {
817
898
  streams: streamDepths,
818
899
  deliveryStats: {
819
900
  total,
820
901
  delivered,
821
- failed: streamDepths.DEAD_LETTER || 0,
822
- successRate: total > 0 ? Number((delivered / total * 100).toFixed(2)) : 100
902
+ failed,
903
+ successRate
823
904
  }
824
905
  });
825
906
  }
@@ -1151,7 +1232,88 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
1151
1232
  await deps.db.delete(suppressions).where(and(eq(suppressions.projectId, ctx.projectId), eq(suppressions.channel, channel), eq(suppressions.target, normaliseTarget(decodeURIComponent(target)))));
1152
1233
  sendNoContent(res);
1153
1234
  }
1235
+ async function login(req, res) {
1236
+ if (!deps.adminUserRepo) {
1237
+ sendJson(res, 500, {
1238
+ error: "internal_error",
1239
+ message: "Admin user repository not initialized"
1240
+ });
1241
+ return;
1242
+ }
1243
+ const body = await readJsonBody(req);
1244
+ const parsed = z.object({
1245
+ identifier: z.string().min(1, "Identifier (email or username) is required"),
1246
+ password: z.string().min(1, "Password is required")
1247
+ }).safeParse(body);
1248
+ if (!parsed.success) {
1249
+ sendValidationError(res, parsed.error);
1250
+ return;
1251
+ }
1252
+ const { identifier, password } = parsed.data;
1253
+ const user = await deps.adminUserRepo.findByEmailOrUsername(identifier);
1254
+ if (!user) {
1255
+ sendJson(res, 401, {
1256
+ error: "unauthorized",
1257
+ message: "Invalid credentials"
1258
+ });
1259
+ return;
1260
+ }
1261
+ if (!await verifyPassword(password, user.passwordHash)) {
1262
+ sendJson(res, 401, {
1263
+ error: "unauthorized",
1264
+ message: "Invalid credentials"
1265
+ });
1266
+ return;
1267
+ }
1268
+ const session = await createAdminSession(deps.redis.native, user);
1269
+ sendJson(res, 200, {
1270
+ token: session.token,
1271
+ user: {
1272
+ id: user.id,
1273
+ email: user.email,
1274
+ username: user.username,
1275
+ role: user.role
1276
+ },
1277
+ expiresAt: session.expiresAt
1278
+ });
1279
+ }
1280
+ async function logout(req, res) {
1281
+ const authHeader = req.headers["authorization"];
1282
+ let token;
1283
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) token = authHeader.slice(7).trim();
1284
+ if (token) await revokeAdminSession(deps.redis.native, token);
1285
+ sendJson(res, 200, { message: "Logged out successfully" });
1286
+ }
1287
+ async function getMe(req, res) {
1288
+ const authHeader = req.headers["authorization"];
1289
+ let token;
1290
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) token = authHeader.slice(7).trim();
1291
+ if (!token) {
1292
+ sendJson(res, 401, {
1293
+ error: "unauthorized",
1294
+ message: "Missing token"
1295
+ });
1296
+ return;
1297
+ }
1298
+ const session = await getAdminSession(deps.redis.native, token);
1299
+ if (!session) {
1300
+ sendJson(res, 401, {
1301
+ error: "unauthorized",
1302
+ message: "Invalid or expired session"
1303
+ });
1304
+ return;
1305
+ }
1306
+ sendJson(res, 200, { user: {
1307
+ id: session.adminId,
1308
+ email: session.email,
1309
+ username: session.username,
1310
+ role: session.role
1311
+ } });
1312
+ }
1154
1313
  return {
1314
+ login,
1315
+ logout,
1316
+ getMe,
1155
1317
  syncTemplates,
1156
1318
  addUser,
1157
1319
  updateUser,
@@ -1201,6 +1363,158 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
1201
1363
  };
1202
1364
  }
1203
1365
  //#endregion
1366
+ //#region src/services/api/admin-static.ts
1367
+ const MIME_TYPES = {
1368
+ ".html": "text/html; charset=utf-8",
1369
+ ".js": "application/javascript; charset=utf-8",
1370
+ ".mjs": "application/javascript; charset=utf-8",
1371
+ ".css": "text/css; charset=utf-8",
1372
+ ".json": "application/json; charset=utf-8",
1373
+ ".png": "image/png",
1374
+ ".jpg": "image/jpeg",
1375
+ ".jpeg": "image/jpeg",
1376
+ ".gif": "image/gif",
1377
+ ".svg": "image/svg+xml",
1378
+ ".ico": "image/x-icon",
1379
+ ".woff": "font/woff",
1380
+ ".woff2": "font/woff2",
1381
+ ".ttf": "font/ttf",
1382
+ ".webp": "image/webp",
1383
+ ".txt": "text/plain; charset=utf-8",
1384
+ ".map": "application/json; charset=utf-8"
1385
+ };
1386
+ const POSSIBLE_DASHBOARD_DIRS = [
1387
+ resolve(process.cwd(), "dashboard", "dist"),
1388
+ resolve(process.cwd(), "dashboard", "out"),
1389
+ resolve(process.cwd(), "dist", "admin"),
1390
+ resolve(process.cwd(), "dist", "dashboard")
1391
+ ];
1392
+ function getDashboardDir() {
1393
+ for (const dir of POSSIBLE_DASHBOARD_DIRS) if (existsSync(dir) && existsSync(join(dir, "index.html"))) return dir;
1394
+ return null;
1395
+ }
1396
+ /**
1397
+ * Proxies request to frontend dev server if running.
1398
+ */
1399
+ function proxyToDevServer(req, res, targetHost, targetPort) {
1400
+ return new Promise((resolvePromise) => {
1401
+ const proxyReq = request({
1402
+ host: targetHost,
1403
+ port: targetPort,
1404
+ path: req.url,
1405
+ method: req.method,
1406
+ headers: req.headers
1407
+ }, (proxyRes) => {
1408
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
1409
+ proxyRes.pipe(res);
1410
+ resolvePromise(true);
1411
+ });
1412
+ proxyReq.on("error", () => {
1413
+ resolvePromise(false);
1414
+ });
1415
+ if (req.readable) req.pipe(proxyReq);
1416
+ else proxyReq.end();
1417
+ });
1418
+ }
1419
+ /**
1420
+ * Handles incoming HTTP requests for `/admin` and `/admin/*`.
1421
+ * Serves static exported assets from Vite SPA build with index.html fallback,
1422
+ * or proxies to Vite dev server if running in development mode.
1423
+ */
1424
+ async function handleAdminRequest(req, res, url) {
1425
+ if (url.pathname !== "/admin" && !url.pathname.startsWith("/admin/")) return false;
1426
+ if (url.pathname === "/admin") {
1427
+ res.writeHead(301, { Location: "/admin/" + (url.search || "") });
1428
+ res.end();
1429
+ return true;
1430
+ }
1431
+ if (process.env.NODE_ENV !== "production") {
1432
+ const devProxyUrl = process.env.VITE_DEV_URL || process.env.ADMIN_DEV_URL || process.env.NEXT_DEV_URL;
1433
+ const targetPort = devProxyUrl ? parseInt(new URL(devProxyUrl).port, 10) : 5173;
1434
+ if (await proxyToDevServer(req, res, devProxyUrl ? new URL(devProxyUrl).hostname : "127.0.0.1", targetPort)) return true;
1435
+ }
1436
+ const dashboardDir = getDashboardDir();
1437
+ if (!dashboardDir) {
1438
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1439
+ res.end(`
1440
+ <!DOCTYPE html>
1441
+ <html lang="en">
1442
+ <head>
1443
+ <meta charset="utf-8">
1444
+ <title>Notifkit Admin Dashboard</title>
1445
+ <style>
1446
+ body { font-family: system-ui, -apple-system, sans-serif; background: #09090b; color: #f4f4f5; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
1447
+ .card { background: #18181b; border: 1px solid #27272a; padding: 2rem; border-radius: 0.75rem; max-width: 500px; text-align: center; }
1448
+ h1 { margin-top: 0; color: #fafafa; }
1449
+ code { background: #27272a; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.9em; }
1450
+ </style>
1451
+ </head>
1452
+ <body>
1453
+ <div class="card">
1454
+ <h1>Notifkit Admin Dashboard</h1>
1455
+ <p>The dashboard static bundle has not been built yet.</p>
1456
+ <p>Please build it by running:</p>
1457
+ <p><code>npm run build:dashboard</code></p>
1458
+ <p>or run the dev server with <code>npm --prefix dashboard run dev</code>.</p>
1459
+ </div>
1460
+ </body>
1461
+ </html>
1462
+ `);
1463
+ return true;
1464
+ }
1465
+ let subPath = url.pathname.slice(6);
1466
+ if (subPath.startsWith("/")) subPath = subPath.slice(1);
1467
+ if (!subPath) subPath = "index.html";
1468
+ const normalizedSubPath = subPath.replace(/\/$/, "");
1469
+ let filePath = resolve(dashboardDir, subPath);
1470
+ if (!filePath.startsWith(dashboardDir)) {
1471
+ res.writeHead(403, { "Content-Type": "text/plain" });
1472
+ res.end("Forbidden");
1473
+ return true;
1474
+ }
1475
+ let stat = null;
1476
+ if (existsSync(filePath)) {
1477
+ stat = statSync(filePath);
1478
+ if (stat.isDirectory()) {
1479
+ filePath = join(filePath, "index.html");
1480
+ stat = existsSync(filePath) ? statSync(filePath) : null;
1481
+ }
1482
+ }
1483
+ if (!stat) {
1484
+ const htmlPath = resolve(dashboardDir, `${normalizedSubPath}.html`);
1485
+ if (existsSync(htmlPath)) {
1486
+ filePath = htmlPath;
1487
+ stat = statSync(filePath);
1488
+ }
1489
+ }
1490
+ if (!stat) {
1491
+ const dirIndexPath = resolve(dashboardDir, normalizedSubPath, "index.html");
1492
+ if (existsSync(dirIndexPath)) {
1493
+ filePath = dirIndexPath;
1494
+ stat = statSync(filePath);
1495
+ }
1496
+ }
1497
+ if (!stat) {
1498
+ filePath = resolve(dashboardDir, "index.html");
1499
+ if (existsSync(filePath)) stat = statSync(filePath);
1500
+ }
1501
+ if (!stat) {
1502
+ res.writeHead(404, { "Content-Type": "text/plain" });
1503
+ res.end("Not Found");
1504
+ return true;
1505
+ }
1506
+ const ext = extname(filePath).toLowerCase();
1507
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
1508
+ const cacheControl = filePath.includes("_next") || ext === ".js" || ext === ".css" ? "public, max-age=31536000, immutable" : "public, max-age=0, must-revalidate";
1509
+ res.writeHead(200, {
1510
+ "Content-Type": contentType,
1511
+ "Content-Length": stat.size,
1512
+ "Cache-Control": cacheControl
1513
+ });
1514
+ createReadStream(filePath).pipe(res);
1515
+ return true;
1516
+ }
1517
+ //#endregion
1204
1518
  //#region src/services/api/main.ts
1205
1519
  /** Pub/sub channel used to drop a cached API key across every API process. */
1206
1520
  const API_KEY_INVALIDATION_CHANNEL = "apikey.invalidated";
@@ -1370,6 +1684,21 @@ async function startApiServer() {
1370
1684
  logger
1371
1685
  })
1372
1686
  };
1687
+ const adminUserRepo = new AdminUserRepository(db);
1688
+ if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) try {
1689
+ if (!await adminUserRepo.findByEmailOrUsername(config.ADMIN_EMAIL)) {
1690
+ const passwordHash = await hashPassword(config.ADMIN_PASSWORD);
1691
+ await adminUserRepo.create({
1692
+ email: config.ADMIN_EMAIL,
1693
+ username: config.ADMIN_USERNAME,
1694
+ passwordHash,
1695
+ role: "superadmin"
1696
+ });
1697
+ logger.info({ email: config.ADMIN_EMAIL }, "Bootstrapped initial admin user");
1698
+ }
1699
+ } catch (err) {
1700
+ logger.error({ err }, "Failed to bootstrap default admin user");
1701
+ }
1373
1702
  deps = {
1374
1703
  logger,
1375
1704
  redis,
@@ -1380,11 +1709,12 @@ async function startApiServer() {
1380
1709
  projectRepo: new ProjectRepository(db),
1381
1710
  workflowRepo: new WorkflowRepository(db),
1382
1711
  segmentRepo: new SegmentRepository(db),
1712
+ adminUserRepo,
1383
1713
  db
1384
1714
  };
1385
1715
  h = createHandlers(deps);
1386
1716
  router = new Router();
1387
- router.put("/v1/templates", h.syncTemplates).get("/v1/templates", h.listTemplates).get("/v1/templates/:id", h.getTemplate).delete("/v1/templates/:id", h.deleteTemplate).post("/v1/users", h.addUser).get("/v1/users", h.listUsers).get("/v1/users/:id", h.getUser).get("/v1/users/:id/details", h.getUserDetails).patch("/v1/users/:id", h.updateUser).delete("/v1/users/:id", h.deleteUser).post("/v1/users/:id/contacts", h.addContact).get("/v1/users/:id/contacts", h.getUserContacts).delete("/v1/users/:id/contacts/:channel/:target", h.deleteContact).get("/v1/users/:id/preferences", h.getUserPreferences).patch("/v1/users/:id/preferences", h.updateUserPreferences).post("/v1/notify", h.notify).get("/v1/notifications/scheduled", h.getScheduledMessages).get("/v1/notifications/logs", h.getNotificationLogs).get("/v1/notifications/:taskId", h.getNotificationStatus).delete("/v1/notifications/:taskId", h.cancelNotification).get("/v1/unsubscribe", h.unsubscribePage).post("/v1/unsubscribe", h.unsubscribe).get("/v1/campaigns", h.listCampaigns).get("/v1/campaigns/:campaign/stats", h.getCampaignStats).get("/v1/suppressions", h.listSuppressions).post("/v1/suppressions", h.createSuppression).delete("/v1/suppressions/:channel/:target", h.deleteSuppression).get("/v1/system/health", h.getSystemHealth).get("/v1/system/metrics", h.getSystemMetrics).get("/v1/dlq", h.getDLQMessages).post("/v1/dlq/replay", h.replayDLQMessage).delete("/v1/dlq/:id", h.deleteDLQMessage).post("/v1/workflows", h.createWorkflow).get("/v1/workflows", h.listWorkflows).get("/v1/workflows/instances/:id", h.getWorkflow).delete("/v1/workflows/instances/:id", h.cancelWorkflow).post("/v1/workflows/trigger", h.triggerWorkflow).get("/v1/segments", h.listSegments).post("/v1/events", h.ingestEvent).get("/v1/events/stream", h.getEventsStream).get("/v1/projects", h.listProjects).post("/v1/projects", handleCreateProject).delete("/v1/projects/:id", h.deleteProject).patch("/v1/projects/:id", h.updateProject).post("/v1/projects/:id/keys", h.createProjectKey).get("/v1/projects/:id/keys", h.listProjectKeys).delete("/v1/projects/:id/keys/:keyId", h.deleteProjectKey).get("/health", handleHealth).get("/metrics", handleMetrics).get("/live", handleLive).get("/ready", handleReady);
1717
+ router.post("/v1/auth/login", h.login).post("/v1/auth/logout", h.logout).get("/v1/auth/me", h.getMe).put("/v1/templates", h.syncTemplates).get("/v1/templates", h.listTemplates).get("/v1/templates/:id", h.getTemplate).delete("/v1/templates/:id", h.deleteTemplate).post("/v1/users", h.addUser).get("/v1/users", h.listUsers).get("/v1/users/:id", h.getUser).get("/v1/users/:id/details", h.getUserDetails).patch("/v1/users/:id", h.updateUser).delete("/v1/users/:id", h.deleteUser).post("/v1/users/:id/contacts", h.addContact).get("/v1/users/:id/contacts", h.getUserContacts).delete("/v1/users/:id/contacts/:channel/:target", h.deleteContact).get("/v1/users/:id/preferences", h.getUserPreferences).patch("/v1/users/:id/preferences", h.updateUserPreferences).post("/v1/notify", h.notify).get("/v1/notifications/scheduled", h.getScheduledMessages).get("/v1/notifications/logs", h.getNotificationLogs).get("/v1/notifications/:taskId", h.getNotificationStatus).delete("/v1/notifications/:taskId", h.cancelNotification).get("/v1/unsubscribe", h.unsubscribePage).post("/v1/unsubscribe", h.unsubscribe).get("/v1/campaigns", h.listCampaigns).get("/v1/campaigns/:campaign/stats", h.getCampaignStats).get("/v1/suppressions", h.listSuppressions).post("/v1/suppressions", h.createSuppression).delete("/v1/suppressions/:channel/:target", h.deleteSuppression).get("/v1/system/health", h.getSystemHealth).get("/v1/system/metrics", h.getSystemMetrics).get("/v1/dlq", h.getDLQMessages).post("/v1/dlq/replay", h.replayDLQMessage).delete("/v1/dlq/:id", h.deleteDLQMessage).post("/v1/workflows", h.createWorkflow).get("/v1/workflows", h.listWorkflows).get("/v1/workflows/instances/:id", h.getWorkflow).delete("/v1/workflows/instances/:id", h.cancelWorkflow).post("/v1/workflows/trigger", h.triggerWorkflow).get("/v1/segments", h.listSegments).post("/v1/events", h.ingestEvent).get("/v1/events/stream", h.getEventsStream).get("/v1/projects", h.listProjects).post("/v1/projects", handleCreateProject).delete("/v1/projects/:id", h.deleteProject).patch("/v1/projects/:id", h.updateProject).post("/v1/projects/:id/keys", h.createProjectKey).get("/v1/projects/:id/keys", h.listProjectKeys).delete("/v1/projects/:id/keys/:keyId", h.deleteProjectKey).get("/health", handleHealth).get("/metrics", handleMetrics).get("/live", handleLive).get("/ready", handleReady);
1388
1718
  server = createServer((req, res) => {
1389
1719
  handleRequest(req, res);
1390
1720
  });
@@ -1401,12 +1731,16 @@ async function startApiServer() {
1401
1731
  return;
1402
1732
  }
1403
1733
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1734
+ if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) {
1735
+ if (await handleAdminRequest(req, res, url)) return;
1736
+ }
1404
1737
  let projectId = void 0;
1405
1738
  let projectRateLimitRpm = 600;
1406
1739
  let keyRole = "admin";
1407
1740
  const isProjectManagement = url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/");
1408
1741
  const isPublicUnsubscribe = url.pathname === "/v1/unsubscribe";
1409
- if (url.pathname.startsWith("/v1/") && !isProjectManagement && !isPublicUnsubscribe) {
1742
+ const isPublicAuth = url.pathname === "/v1/auth/login";
1743
+ if (url.pathname.startsWith("/v1/") && !isProjectManagement && !isPublicUnsubscribe && !isPublicAuth) {
1410
1744
  let token = extractAuthToken(req);
1411
1745
  if (!token && url.searchParams.has("token")) token = url.searchParams.get("token") || void 0;
1412
1746
  if (!token) {
@@ -1417,14 +1751,21 @@ async function startApiServer() {
1417
1751
  return;
1418
1752
  }
1419
1753
  let isAdminToken = false;
1420
- if (config.ADMIN_API_KEY) {
1754
+ if (token.startsWith("nk_sess_")) {
1755
+ const session = await getAdminSession(redis.native, token);
1756
+ if (session) {
1757
+ isAdminToken = true;
1758
+ keyRole = session.role === "superadmin" ? "admin" : session.role;
1759
+ }
1760
+ } else if (config.ADMIN_API_KEY) {
1421
1761
  const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1422
1762
  const providedBuffer = Buffer.from(token);
1423
1763
  if (expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer)) isAdminToken = true;
1424
1764
  }
1425
1765
  if (isAdminToken) {
1426
1766
  const headerProjectId = req.headers["x-project-id"] || url.searchParams.get("projectId") || void 0;
1427
- if (!headerProjectId) {
1767
+ const isProjectAgnostic = url.pathname.startsWith("/v1/auth/") || url.pathname.startsWith("/v1/system/") || url.pathname.startsWith("/v1/dlq");
1768
+ if (!headerProjectId && !isProjectAgnostic) {
1428
1769
  sendJson(res, 400, {
1429
1770
  error: "bad_request",
1430
1771
  message: "x-project-id header or projectId query param required when using admin token"
@@ -1433,7 +1774,7 @@ async function startApiServer() {
1433
1774
  }
1434
1775
  projectId = headerProjectId;
1435
1776
  projectRateLimitRpm = 6e3;
1436
- keyRole = "admin";
1777
+ if (!token.startsWith("nk_sess_")) keyRole = "admin";
1437
1778
  } else {
1438
1779
  const tokenHash = createHash("sha256").update(token).digest("hex");
1439
1780
  const cached = authCache.get(tokenHash);
@@ -1485,7 +1826,7 @@ async function startApiServer() {
1485
1826
  end
1486
1827
  return -1
1487
1828
  `;
1488
- const rlKey = `rate-limit:api:req:${projectId}`;
1829
+ const rlKey = `rate-limit:api:req:${projectId || "global"}`;
1489
1830
  const nowMs = Date.now();
1490
1831
  if (await redis.native.eval(LUA_LIMIT, 1, rlKey, nowMs, 6e4, projectRateLimitRpm, randomBytes(4).toString("hex")) === -1) {
1491
1832
  if (!res.headersSent) {
@@ -1499,13 +1840,6 @@ async function startApiServer() {
1499
1840
  }
1500
1841
  } else if (isProjectManagement) {
1501
1842
  const token = extractAuthToken(req);
1502
- if (!config.ADMIN_API_KEY) {
1503
- sendJson(res, 403, {
1504
- error: "forbidden",
1505
- message: "Project management disabled (no ADMIN_API_KEY set)"
1506
- });
1507
- return;
1508
- }
1509
1843
  if (!token) {
1510
1844
  sendJson(res, 401, {
1511
1845
  error: "unauthorized",
@@ -1513,9 +1847,15 @@ async function startApiServer() {
1513
1847
  });
1514
1848
  return;
1515
1849
  }
1516
- const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1517
- const providedBuffer = Buffer.from(token);
1518
- if (expectedBuffer.length !== providedBuffer.length || !timingSafeEqual(expectedBuffer, providedBuffer)) {
1850
+ let authorized = false;
1851
+ if (token.startsWith("nk_sess_")) {
1852
+ if (await getAdminSession(redis.native, token)) authorized = true;
1853
+ } else if (config.ADMIN_API_KEY) {
1854
+ const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1855
+ const providedBuffer = Buffer.from(token);
1856
+ if (expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer)) authorized = true;
1857
+ }
1858
+ if (!authorized) {
1519
1859
  sendJson(res, 401, {
1520
1860
  error: "unauthorized",
1521
1861
  message: "Invalid admin token"
@@ -1729,4 +2069,4 @@ async function stopApiServer() {
1729
2069
  //#endregion
1730
2070
  export { startApiServer, stopApiServer };
1731
2071
 
1732
- //# sourceMappingURL=main-CFSukWm8.mjs.map
2072
+ //# sourceMappingURL=main-BAO4kKce.mjs.map