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
@@ -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, st as deliveryOutbox, tt as createLogger, ut as projects, vn as readBaseConfig, w as ProjectRepository, x as AdminUserRepository } from "./src-CGmXRfsM.mjs";
2
+ import { extname, isAbsolute, join, relative, 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) => {
@@ -104,7 +107,12 @@ var Router = class {
104
107
  for (let i = 0; i < route.segments.length; i++) {
105
108
  const seg = route.segments[i];
106
109
  const part = parts[i];
107
- if (seg.startsWith(":")) params[seg.slice(1)] = decodeURIComponent(part);
110
+ if (seg.startsWith(":")) try {
111
+ params[seg.slice(1)] = decodeURIComponent(part);
112
+ } catch {
113
+ ok = false;
114
+ break;
115
+ }
108
116
  else if (seg !== part) {
109
117
  ok = false;
110
118
  break;
@@ -122,6 +130,79 @@ function split(path) {
122
130
  return path.split("/").filter((s) => s.length > 0);
123
131
  }
124
132
  //#endregion
133
+ //#region src/services/auth/index.ts
134
+ const scryptAsync = promisify(scrypt);
135
+ const DEFAULT_SESSION_TTL_SECONDS = 604800;
136
+ const SESSION_PREFIX = "notif:session:";
137
+ /**
138
+ * Pre-computed dummy hash conforming to salt(16 bytes hex):hash(64 bytes hex).
139
+ * Used during authentication failure to equalize execution time and prevent
140
+ * user enumeration via scrypt timing side-channels.
141
+ */
142
+ const DUMMY_PASSWORD_HASH = "00000000000000000000000000000000:00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
143
+ /**
144
+ * Hashes a plaintext password using crypto.scrypt with a random 16-byte salt.
145
+ * Returns salt and hash separated by a colon.
146
+ */
147
+ async function hashPassword(password) {
148
+ const salt = randomBytes(16).toString("hex");
149
+ return `${salt}:${(await scryptAsync(password, salt, 64)).toString("hex")}`;
150
+ }
151
+ /**
152
+ * Verifies a plaintext password against a stored salt:hash string using constant-time comparison.
153
+ */
154
+ async function verifyPassword(password, storedHash) {
155
+ const [salt, key] = storedHash.split(":");
156
+ if (!salt || !key) return false;
157
+ const keyBuffer = Buffer.from(key, "hex");
158
+ const derivedKey = await scryptAsync(password, salt, keyBuffer.length);
159
+ if (derivedKey.length !== keyBuffer.length) return false;
160
+ return timingSafeEqual(derivedKey, keyBuffer);
161
+ }
162
+ /**
163
+ * Creates a secure session in Redis for an authenticated admin user.
164
+ */
165
+ async function createAdminSession(redis, user, ttlSeconds = DEFAULT_SESSION_TTL_SECONDS) {
166
+ const token = `nk_sess_${randomBytes(32).toString("hex")}`;
167
+ const now = Date.now();
168
+ const session = {
169
+ token,
170
+ adminId: user.id,
171
+ email: user.email,
172
+ username: user.username,
173
+ role: user.role,
174
+ createdAt: now,
175
+ expiresAt: now + ttlSeconds * 1e3
176
+ };
177
+ await redis.set(`${SESSION_PREFIX}${token}`, JSON.stringify(session), "EX", ttlSeconds);
178
+ return session;
179
+ }
180
+ /**
181
+ * Retrieves an admin session from Redis if valid and unexpired.
182
+ */
183
+ async function getAdminSession(redis, token) {
184
+ if (!token || !token.startsWith("nk_sess_")) return null;
185
+ const raw = await redis.get(`${SESSION_PREFIX}${token}`);
186
+ if (!raw) return null;
187
+ try {
188
+ const session = JSON.parse(raw);
189
+ if (session.expiresAt <= Date.now()) {
190
+ await redis.del(`${SESSION_PREFIX}${token}`);
191
+ return null;
192
+ }
193
+ return session;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+ /**
199
+ * Revokes an active admin session.
200
+ */
201
+ async function revokeAdminSession(redis, token) {
202
+ if (!token) return;
203
+ await redis.del(`${SESSION_PREFIX}${token}`);
204
+ }
205
+ //#endregion
125
206
  //#region src/services/api/handlers.ts
126
207
  const IngestEventSchema = z.object({
127
208
  name: z.string().min(1),
@@ -804,43 +885,63 @@ function createHandlers(deps) {
804
885
  DEAD_LETTER: STREAMS.DEAD_LETTER
805
886
  };
806
887
  const streamDepths = {};
807
- for (const [key, realRedisKey] of Object.entries(streamMap)) try {
888
+ if (ctx.isAdmin !== false) for (const [key, realRedisKey] of Object.entries(streamMap)) try {
808
889
  streamDepths[key] = await deps.redis.native.xlen(realRedisKey);
809
890
  } catch {
810
891
  streamDepths[key] = 0;
811
892
  }
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);
893
+ let total = 0;
894
+ let delivered = 0;
895
+ if (ctx.projectId) {
896
+ const totalTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(eq(messageLogs.projectId, ctx.projectId));
897
+ 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")));
898
+ total = Number(totalTasksRes[0]?.count ?? 0);
899
+ delivered = Number(deliveredTasksRes[0]?.count ?? 0);
900
+ } else {
901
+ const totalTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs);
902
+ const deliveredTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(eq(messageLogs.status, "delivered"));
903
+ total = Number(totalTasksRes[0]?.count ?? 0);
904
+ delivered = Number(deliveredTasksRes[0]?.count ?? 0);
905
+ }
906
+ const failed = ctx.projectId ? Math.max(0, total - delivered) : streamDepths.DEAD_LETTER || 0;
907
+ const successRate = total > 0 ? Number((delivered / total * 100).toFixed(2)) : 100;
816
908
  sendJson(res, 200, {
817
909
  streams: streamDepths,
818
910
  deliveryStats: {
819
911
  total,
820
912
  delivered,
821
- failed: streamDepths.DEAD_LETTER || 0,
822
- successRate: total > 0 ? Number((delivered / total * 100).toFixed(2)) : 100
913
+ failed,
914
+ successRate
823
915
  }
824
916
  });
825
917
  }
826
- async function getDLQMessages(_req, res, _ctx) {
918
+ async function getDLQMessages(_req, res, ctx) {
827
919
  try {
828
- sendJson(res, 200, { messages: (await deps.redis.native.xrevrange(STREAMS.DEAD_LETTER, "+", "-", "COUNT", "50") || []).map(([id, fields]) => {
920
+ const allMessages = (await deps.redis.native.xrevrange(STREAMS.DEAD_LETTER, "+", "-", "COUNT", "100") || []).map(([id, fields]) => {
829
921
  const fieldMap = {};
830
922
  for (let i = 0; i < fields.length; i += 2) fieldMap[fields[i]] = fields[i + 1];
923
+ let payload = fieldMap;
924
+ if (fieldMap.payload) try {
925
+ payload = JSON.parse(fieldMap.payload);
926
+ } catch {
927
+ payload = fieldMap.payload;
928
+ }
831
929
  return {
832
930
  id,
833
931
  eventType: fieldMap.eventType || fieldMap.event_type || "unknown",
834
- payload: fieldMap.payload ? JSON.parse(fieldMap.payload) : fieldMap,
932
+ payload,
835
933
  error: fieldMap.error || fieldMap.reason || "Dead letter payload",
836
934
  timestamp: fieldMap.timestamp || (/* @__PURE__ */ new Date()).toISOString()
837
935
  };
838
- }) });
936
+ });
937
+ sendJson(res, 200, { messages: (ctx.projectId ? allMessages.filter((m) => {
938
+ return (m.payload?.projectId ?? m.payload?.project_id ?? m.projectId) === ctx.projectId;
939
+ }) : allMessages).slice(0, 50) });
839
940
  } catch {
840
941
  sendJson(res, 200, { messages: [] });
841
942
  }
842
943
  }
843
- async function replayDLQMessage(req, res, _ctx) {
944
+ async function replayDLQMessage(req, res, ctx) {
844
945
  const messageId = (await readJsonBody(req).catch(() => ({})))?.id;
845
946
  if (!messageId) {
846
947
  sendJson(res, 400, { error: "missing_message_id" });
@@ -855,6 +956,16 @@ function createHandlers(deps) {
855
956
  const fields = rawEntries[0][1];
856
957
  const fieldMap = {};
857
958
  for (let i = 0; i < fields.length; i += 2) fieldMap[fields[i]] = fields[i + 1];
959
+ let payload = null;
960
+ if (fieldMap.payload) try {
961
+ payload = JSON.parse(fieldMap.payload);
962
+ } catch {}
963
+ if (ctx.projectId) {
964
+ if ((payload?.projectId ?? payload?.project_id ?? fieldMap.projectId) !== ctx.projectId) {
965
+ sendJson(res, 404, { error: "dlq_message_not_found" });
966
+ return;
967
+ }
968
+ }
858
969
  const priority = fieldMap.priority || "normal";
859
970
  const p = getPriorityBucket(priority);
860
971
  const targetStream = STREAMS[`INBOUND_${p.toUpperCase()}`] || STREAMS.INBOUND_NORMAL;
@@ -877,6 +988,24 @@ function createHandlers(deps) {
877
988
  const messageId = ctx.params.id;
878
989
  if (!messageId) return sendJson(res, 400, { error: "missing_id" });
879
990
  try {
991
+ if (ctx.projectId) {
992
+ const rawEntries = await deps.redis.native.xrange(STREAMS.DEAD_LETTER, messageId, messageId);
993
+ if (!rawEntries || rawEntries.length === 0 || !rawEntries[0]) {
994
+ sendJson(res, 404, { error: "dlq_message_not_found" });
995
+ return;
996
+ }
997
+ const fields = rawEntries[0][1];
998
+ const fieldMap = {};
999
+ for (let i = 0; i < fields.length; i += 2) fieldMap[fields[i]] = fields[i + 1];
1000
+ let payload = null;
1001
+ if (fieldMap.payload) try {
1002
+ payload = JSON.parse(fieldMap.payload);
1003
+ } catch {}
1004
+ if ((payload?.projectId ?? payload?.project_id ?? fieldMap.projectId) !== ctx.projectId) {
1005
+ sendJson(res, 404, { error: "dlq_message_not_found" });
1006
+ return;
1007
+ }
1008
+ }
880
1009
  await deps.redis.native.xdel(STREAMS.DEAD_LETTER, messageId);
881
1010
  sendJson(res, 200, { success: true });
882
1011
  } catch (err) {
@@ -910,7 +1039,13 @@ function createHandlers(deps) {
910
1039
  id: userId
911
1040
  });
912
1041
  const contacts = await deps.contactRepo.findByUserId(ctx.projectId, userId);
913
- const logs = await deps.db.select().from(messageLogs).where(eq(messageLogs.projectId, ctx.projectId)).orderBy(desc(messageLogs.timestamp)).limit(50);
1042
+ const contactTargets = contacts.map((c) => c.target);
1043
+ let logs = [];
1044
+ if (contactTargets.length > 0) {
1045
+ const outboxRows = await deps.db.select({ taskId: deliveryOutbox.taskId }).from(deliveryOutbox).where(inArray(deliveryOutbox.destination, contactTargets));
1046
+ const taskIds = Array.from(new Set(outboxRows.map((r) => r.taskId)));
1047
+ if (taskIds.length > 0) logs = await deps.db.select().from(messageLogs).where(and(eq(messageLogs.projectId, ctx.projectId), inArray(messageLogs.taskId, taskIds))).orderBy(desc(messageLogs.timestamp)).limit(50);
1048
+ }
914
1049
  sendJson(res, 200, {
915
1050
  ...user,
916
1051
  contacts,
@@ -1148,10 +1283,85 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
1148
1283
  async function deleteSuppression(_req, res, ctx) {
1149
1284
  const { channel, target } = ctx.params;
1150
1285
  if (!channel || !target) return sendJson(res, 400, { error: "missing_channel_or_target" });
1151
- await deps.db.delete(suppressions).where(and(eq(suppressions.projectId, ctx.projectId), eq(suppressions.channel, channel), eq(suppressions.target, normaliseTarget(decodeURIComponent(target)))));
1286
+ await deps.db.delete(suppressions).where(and(eq(suppressions.projectId, ctx.projectId), eq(suppressions.channel, channel), eq(suppressions.target, normaliseTarget(target))));
1152
1287
  sendNoContent(res);
1153
1288
  }
1289
+ async function login(req, res) {
1290
+ if (!deps.adminUserRepo) {
1291
+ sendJson(res, 500, {
1292
+ error: "internal_error",
1293
+ message: "Admin user repository not initialized"
1294
+ });
1295
+ return;
1296
+ }
1297
+ const body = await readJsonBody(req);
1298
+ const parsed = z.object({
1299
+ identifier: z.string().min(1, "Identifier (email or username) is required"),
1300
+ password: z.string().min(1, "Password is required")
1301
+ }).safeParse(body);
1302
+ if (!parsed.success) {
1303
+ sendValidationError(res, parsed.error);
1304
+ return;
1305
+ }
1306
+ const { identifier, password } = parsed.data;
1307
+ const user = await deps.adminUserRepo.findByEmailOrUsername(identifier);
1308
+ const isValid = await verifyPassword(password, user ? user.passwordHash : DUMMY_PASSWORD_HASH);
1309
+ if (!user || !isValid) {
1310
+ sendJson(res, 401, {
1311
+ error: "unauthorized",
1312
+ message: "Invalid credentials"
1313
+ });
1314
+ return;
1315
+ }
1316
+ const session = await createAdminSession(deps.redis.native, user);
1317
+ sendJson(res, 200, {
1318
+ token: session.token,
1319
+ user: {
1320
+ id: user.id,
1321
+ email: user.email,
1322
+ username: user.username,
1323
+ role: user.role
1324
+ },
1325
+ expiresAt: session.expiresAt
1326
+ });
1327
+ }
1328
+ async function logout(req, res) {
1329
+ const authHeader = req.headers["authorization"];
1330
+ let token;
1331
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) token = authHeader.slice(7).trim();
1332
+ if (token) await revokeAdminSession(deps.redis.native, token);
1333
+ sendJson(res, 200, { message: "Logged out successfully" });
1334
+ }
1335
+ async function getMe(req, res) {
1336
+ const authHeader = req.headers["authorization"];
1337
+ let token;
1338
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) token = authHeader.slice(7).trim();
1339
+ if (!token) {
1340
+ sendJson(res, 401, {
1341
+ error: "unauthorized",
1342
+ message: "Missing token"
1343
+ });
1344
+ return;
1345
+ }
1346
+ const session = await getAdminSession(deps.redis.native, token);
1347
+ if (!session) {
1348
+ sendJson(res, 401, {
1349
+ error: "unauthorized",
1350
+ message: "Invalid or expired session"
1351
+ });
1352
+ return;
1353
+ }
1354
+ sendJson(res, 200, { user: {
1355
+ id: session.adminId,
1356
+ email: session.email,
1357
+ username: session.username,
1358
+ role: session.role
1359
+ } });
1360
+ }
1154
1361
  return {
1362
+ login,
1363
+ logout,
1364
+ getMe,
1155
1365
  syncTemplates,
1156
1366
  addUser,
1157
1367
  updateUser,
@@ -1201,6 +1411,159 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
1201
1411
  };
1202
1412
  }
1203
1413
  //#endregion
1414
+ //#region src/services/api/admin-static.ts
1415
+ const MIME_TYPES = {
1416
+ ".html": "text/html; charset=utf-8",
1417
+ ".js": "application/javascript; charset=utf-8",
1418
+ ".mjs": "application/javascript; charset=utf-8",
1419
+ ".css": "text/css; charset=utf-8",
1420
+ ".json": "application/json; charset=utf-8",
1421
+ ".png": "image/png",
1422
+ ".jpg": "image/jpeg",
1423
+ ".jpeg": "image/jpeg",
1424
+ ".gif": "image/gif",
1425
+ ".svg": "image/svg+xml",
1426
+ ".ico": "image/x-icon",
1427
+ ".woff": "font/woff",
1428
+ ".woff2": "font/woff2",
1429
+ ".ttf": "font/ttf",
1430
+ ".webp": "image/webp",
1431
+ ".txt": "text/plain; charset=utf-8",
1432
+ ".map": "application/json; charset=utf-8"
1433
+ };
1434
+ const POSSIBLE_DASHBOARD_DIRS = [
1435
+ resolve(process.cwd(), "dashboard", "dist"),
1436
+ resolve(process.cwd(), "dashboard", "out"),
1437
+ resolve(process.cwd(), "dist", "admin"),
1438
+ resolve(process.cwd(), "dist", "dashboard")
1439
+ ];
1440
+ function getDashboardDir() {
1441
+ for (const dir of POSSIBLE_DASHBOARD_DIRS) if (existsSync(dir) && existsSync(join(dir, "index.html"))) return dir;
1442
+ return null;
1443
+ }
1444
+ /**
1445
+ * Proxies request to frontend dev server if running.
1446
+ */
1447
+ function proxyToDevServer(req, res, targetHost, targetPort) {
1448
+ return new Promise((resolvePromise) => {
1449
+ const proxyReq = request({
1450
+ host: targetHost,
1451
+ port: targetPort,
1452
+ path: req.url,
1453
+ method: req.method,
1454
+ headers: req.headers
1455
+ }, (proxyRes) => {
1456
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
1457
+ proxyRes.pipe(res);
1458
+ resolvePromise(true);
1459
+ });
1460
+ proxyReq.on("error", () => {
1461
+ resolvePromise(false);
1462
+ });
1463
+ if (req.readable) req.pipe(proxyReq);
1464
+ else proxyReq.end();
1465
+ });
1466
+ }
1467
+ /**
1468
+ * Handles incoming HTTP requests for `/admin` and `/admin/*`.
1469
+ * Serves static exported assets from Vite SPA build with index.html fallback,
1470
+ * or proxies to Vite dev server if running in development mode.
1471
+ */
1472
+ async function handleAdminRequest(req, res, url) {
1473
+ if (url.pathname !== "/admin" && !url.pathname.startsWith("/admin/")) return false;
1474
+ if (url.pathname === "/admin") {
1475
+ res.writeHead(301, { Location: "/admin/" + (url.search || "") });
1476
+ res.end();
1477
+ return true;
1478
+ }
1479
+ if (process.env.NODE_ENV !== "production") {
1480
+ const devProxyUrl = process.env.VITE_DEV_URL || process.env.ADMIN_DEV_URL || process.env.NEXT_DEV_URL;
1481
+ const targetPort = devProxyUrl ? parseInt(new URL(devProxyUrl).port, 10) : 5173;
1482
+ if (await proxyToDevServer(req, res, devProxyUrl ? new URL(devProxyUrl).hostname : "127.0.0.1", targetPort)) return true;
1483
+ }
1484
+ const dashboardDir = getDashboardDir();
1485
+ if (!dashboardDir) {
1486
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1487
+ res.end(`
1488
+ <!DOCTYPE html>
1489
+ <html lang="en">
1490
+ <head>
1491
+ <meta charset="utf-8">
1492
+ <title>Notifkit Admin Dashboard</title>
1493
+ <style>
1494
+ 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; }
1495
+ .card { background: #18181b; border: 1px solid #27272a; padding: 2rem; border-radius: 0.75rem; max-width: 500px; text-align: center; }
1496
+ h1 { margin-top: 0; color: #fafafa; }
1497
+ code { background: #27272a; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.9em; }
1498
+ </style>
1499
+ </head>
1500
+ <body>
1501
+ <div class="card">
1502
+ <h1>Notifkit Admin Dashboard</h1>
1503
+ <p>The dashboard static bundle has not been built yet.</p>
1504
+ <p>Please build it by running:</p>
1505
+ <p><code>npm run build:dashboard</code></p>
1506
+ <p>or run the dev server with <code>npm --prefix dashboard run dev</code>.</p>
1507
+ </div>
1508
+ </body>
1509
+ </html>
1510
+ `);
1511
+ return true;
1512
+ }
1513
+ let subPath = url.pathname.slice(6);
1514
+ if (subPath.startsWith("/")) subPath = subPath.slice(1);
1515
+ if (!subPath) subPath = "index.html";
1516
+ const normalizedSubPath = subPath.replace(/\/$/, "");
1517
+ let filePath = resolve(dashboardDir, subPath);
1518
+ const rel = relative(dashboardDir, filePath);
1519
+ if (rel.startsWith("..") || isAbsolute(rel)) {
1520
+ res.writeHead(403, { "Content-Type": "text/plain" });
1521
+ res.end("Forbidden");
1522
+ return true;
1523
+ }
1524
+ let stat = null;
1525
+ if (existsSync(filePath)) {
1526
+ stat = statSync(filePath);
1527
+ if (stat.isDirectory()) {
1528
+ filePath = join(filePath, "index.html");
1529
+ stat = existsSync(filePath) ? statSync(filePath) : null;
1530
+ }
1531
+ }
1532
+ if (!stat) {
1533
+ const htmlPath = resolve(dashboardDir, `${normalizedSubPath}.html`);
1534
+ if (existsSync(htmlPath)) {
1535
+ filePath = htmlPath;
1536
+ stat = statSync(filePath);
1537
+ }
1538
+ }
1539
+ if (!stat) {
1540
+ const dirIndexPath = resolve(dashboardDir, normalizedSubPath, "index.html");
1541
+ if (existsSync(dirIndexPath)) {
1542
+ filePath = dirIndexPath;
1543
+ stat = statSync(filePath);
1544
+ }
1545
+ }
1546
+ if (!stat) {
1547
+ filePath = resolve(dashboardDir, "index.html");
1548
+ if (existsSync(filePath)) stat = statSync(filePath);
1549
+ }
1550
+ if (!stat) {
1551
+ res.writeHead(404, { "Content-Type": "text/plain" });
1552
+ res.end("Not Found");
1553
+ return true;
1554
+ }
1555
+ const ext = extname(filePath).toLowerCase();
1556
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
1557
+ const cacheControl = filePath.includes("_next") || ext === ".js" || ext === ".css" ? "public, max-age=31536000, immutable" : "public, max-age=0, must-revalidate";
1558
+ res.writeHead(200, {
1559
+ "Content-Type": contentType,
1560
+ "Content-Length": stat.size,
1561
+ "Cache-Control": cacheControl
1562
+ });
1563
+ createReadStream(filePath).pipe(res);
1564
+ return true;
1565
+ }
1566
+ //#endregion
1204
1567
  //#region src/services/api/main.ts
1205
1568
  /** Pub/sub channel used to drop a cached API key across every API process. */
1206
1569
  const API_KEY_INVALIDATION_CHANNEL = "apikey.invalidated";
@@ -1223,6 +1586,19 @@ function extractAuthToken(req) {
1223
1586
  const apiKeyHeader = req.headers["x-api-key"];
1224
1587
  if (typeof apiKeyHeader === "string") return apiKeyHeader.trim();
1225
1588
  }
1589
+ /**
1590
+ * Resolves client IP address, respecting reverse proxy headers only when TRUST_PROXY is enabled.
1591
+ */
1592
+ function getClientIp(req, trustProxy) {
1593
+ if (trustProxy) {
1594
+ const forwarded = req.headers["x-forwarded-for"];
1595
+ if (typeof forwarded === "string") {
1596
+ const first = forwarded.split(",")[0]?.trim();
1597
+ if (first) return first;
1598
+ }
1599
+ }
1600
+ return req.socket.remoteAddress || "unknown";
1601
+ }
1226
1602
  async function handleCreateProject(req, res) {
1227
1603
  const parsed = z.object({ name: z.string().min(1) }).safeParse(await readJsonBody(req));
1228
1604
  if (!parsed.success) {
@@ -1300,7 +1676,28 @@ async function handleHealth(_req, res) {
1300
1676
  };
1301
1677
  sendJson(res, statusCode, response);
1302
1678
  }
1303
- async function handleMetrics(_req, res) {
1679
+ async function handleMetrics(req, res) {
1680
+ const remoteIp = req.socket.remoteAddress ?? "";
1681
+ let authorized = remoteIp === "127.0.0.1" || remoteIp === "::1" || remoteIp === "::ffff:127.0.0.1" || remoteIp === "localhost";
1682
+ if (!authorized) {
1683
+ const token = extractAuthToken(req);
1684
+ if (token) {
1685
+ if (token.startsWith("nk_sess_") && redis?.native) {
1686
+ if (await getAdminSession(redis.native, token)) authorized = true;
1687
+ } else if (config.ADMIN_API_KEY) {
1688
+ const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1689
+ const providedBuffer = Buffer.from(token);
1690
+ if (expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer)) authorized = true;
1691
+ }
1692
+ }
1693
+ }
1694
+ if (!authorized) {
1695
+ sendJson(res, 401, {
1696
+ error: "unauthorized",
1697
+ message: "Metrics endpoint requires admin credentials"
1698
+ });
1699
+ return;
1700
+ }
1304
1701
  const registry = getMetricsRegistry();
1305
1702
  res.writeHead(200, { "Content-Type": registry.contentType });
1306
1703
  res.end(await registry.metrics());
@@ -1331,6 +1728,10 @@ async function startApiServer() {
1331
1728
  name: "api",
1332
1729
  level: config.LOG_LEVEL
1333
1730
  });
1731
+ if (config.NODE_ENV === "production" && !config.UNSUBSCRIBE_SECRET) {
1732
+ logger.fatal("UNSUBSCRIBE_SECRET is required in production. Without it, all unsubscribe links return 400 and opt-outs are silently dropped. Set a random string of 32+ characters and restart.");
1733
+ process.exit(1);
1734
+ }
1334
1735
  redis = new RedisClient({
1335
1736
  url: config.REDIS_URL,
1336
1737
  name: "api",
@@ -1370,6 +1771,21 @@ async function startApiServer() {
1370
1771
  logger
1371
1772
  })
1372
1773
  };
1774
+ const adminUserRepo = new AdminUserRepository(db);
1775
+ if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) try {
1776
+ if (!await adminUserRepo.findByEmailOrUsername(config.ADMIN_EMAIL)) {
1777
+ const passwordHash = await hashPassword(config.ADMIN_PASSWORD);
1778
+ await adminUserRepo.create({
1779
+ email: config.ADMIN_EMAIL,
1780
+ username: config.ADMIN_USERNAME,
1781
+ passwordHash,
1782
+ role: "superadmin"
1783
+ });
1784
+ logger.info({ email: config.ADMIN_EMAIL }, "Bootstrapped initial admin user");
1785
+ }
1786
+ } catch (err) {
1787
+ logger.error({ err }, "Failed to bootstrap default admin user");
1788
+ }
1373
1789
  deps = {
1374
1790
  logger,
1375
1791
  redis,
@@ -1380,11 +1796,12 @@ async function startApiServer() {
1380
1796
  projectRepo: new ProjectRepository(db),
1381
1797
  workflowRepo: new WorkflowRepository(db),
1382
1798
  segmentRepo: new SegmentRepository(db),
1799
+ adminUserRepo,
1383
1800
  db
1384
1801
  };
1385
1802
  h = createHandlers(deps);
1386
1803
  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);
1804
+ 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
1805
  server = createServer((req, res) => {
1389
1806
  handleRequest(req, res);
1390
1807
  });
@@ -1392,7 +1809,15 @@ async function startApiServer() {
1392
1809
  server.headersTimeout = 1e4;
1393
1810
  server.keepAliveTimeout = 5e3;
1394
1811
  async function handleRequest(req, res) {
1395
- res.setHeader("Access-Control-Allow-Origin", "*");
1812
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1813
+ const origin = req.headers["origin"];
1814
+ const allowedOrigins = (config.CORS_ORIGIN ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1815
+ if (url.pathname.startsWith("/v1/auth/") || url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/") || url.pathname.startsWith("/v1/system/") || url.pathname.startsWith("/v1/dlq")) {
1816
+ if (origin && allowedOrigins.includes(origin)) {
1817
+ res.setHeader("Access-Control-Allow-Origin", origin);
1818
+ res.setHeader("Vary", "Origin");
1819
+ }
1820
+ } else res.setHeader("Access-Control-Allow-Origin", "*");
1396
1821
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
1397
1822
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key, x-project-id");
1398
1823
  if (req.method === "OPTIONS") {
@@ -1400,13 +1825,17 @@ async function startApiServer() {
1400
1825
  res.end();
1401
1826
  return;
1402
1827
  }
1403
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1828
+ if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) {
1829
+ if (await handleAdminRequest(req, res, url)) return;
1830
+ }
1404
1831
  let projectId = void 0;
1405
1832
  let projectRateLimitRpm = 600;
1406
1833
  let keyRole = "admin";
1834
+ let isAdminToken = false;
1407
1835
  const isProjectManagement = url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/");
1408
1836
  const isPublicUnsubscribe = url.pathname === "/v1/unsubscribe";
1409
- if (url.pathname.startsWith("/v1/") && !isProjectManagement && !isPublicUnsubscribe) {
1837
+ const isPublicAuth = url.pathname === "/v1/auth/login";
1838
+ if (url.pathname.startsWith("/v1/") && !isProjectManagement && !isPublicUnsubscribe && !isPublicAuth) {
1410
1839
  let token = extractAuthToken(req);
1411
1840
  if (!token && url.searchParams.has("token")) token = url.searchParams.get("token") || void 0;
1412
1841
  if (!token) {
@@ -1416,15 +1845,21 @@ async function startApiServer() {
1416
1845
  });
1417
1846
  return;
1418
1847
  }
1419
- let isAdminToken = false;
1420
- if (config.ADMIN_API_KEY) {
1848
+ if (token.startsWith("nk_sess_")) {
1849
+ const session = await getAdminSession(redis.native, token);
1850
+ if (session) {
1851
+ isAdminToken = true;
1852
+ keyRole = session.role === "superadmin" ? "admin" : session.role;
1853
+ }
1854
+ } else if (config.ADMIN_API_KEY) {
1421
1855
  const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1422
1856
  const providedBuffer = Buffer.from(token);
1423
1857
  if (expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer)) isAdminToken = true;
1424
1858
  }
1425
1859
  if (isAdminToken) {
1426
1860
  const headerProjectId = req.headers["x-project-id"] || url.searchParams.get("projectId") || void 0;
1427
- if (!headerProjectId) {
1861
+ const isProjectAgnostic = url.pathname.startsWith("/v1/auth/") || url.pathname.startsWith("/v1/system/") || url.pathname.startsWith("/v1/dlq");
1862
+ if (!headerProjectId && !isProjectAgnostic) {
1428
1863
  sendJson(res, 400, {
1429
1864
  error: "bad_request",
1430
1865
  message: "x-project-id header or projectId query param required when using admin token"
@@ -1433,7 +1868,7 @@ async function startApiServer() {
1433
1868
  }
1434
1869
  projectId = headerProjectId;
1435
1870
  projectRateLimitRpm = 6e3;
1436
- keyRole = "admin";
1871
+ if (!token.startsWith("nk_sess_")) keyRole = "admin";
1437
1872
  } else {
1438
1873
  const tokenHash = createHash("sha256").update(token).digest("hex");
1439
1874
  const cached = authCache.get(tokenHash);
@@ -1485,7 +1920,7 @@ async function startApiServer() {
1485
1920
  end
1486
1921
  return -1
1487
1922
  `;
1488
- const rlKey = `rate-limit:api:req:${projectId}`;
1923
+ const rlKey = `rate-limit:api:req:${projectId || "global"}`;
1489
1924
  const nowMs = Date.now();
1490
1925
  if (await redis.native.eval(LUA_LIMIT, 1, rlKey, nowMs, 6e4, projectRateLimitRpm, randomBytes(4).toString("hex")) === -1) {
1491
1926
  if (!res.headersSent) {
@@ -1499,13 +1934,6 @@ async function startApiServer() {
1499
1934
  }
1500
1935
  } else if (isProjectManagement) {
1501
1936
  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
1937
  if (!token) {
1510
1938
  sendJson(res, 401, {
1511
1939
  error: "unauthorized",
@@ -1513,18 +1941,25 @@ async function startApiServer() {
1513
1941
  });
1514
1942
  return;
1515
1943
  }
1516
- const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1517
- const providedBuffer = Buffer.from(token);
1518
- if (expectedBuffer.length !== providedBuffer.length || !timingSafeEqual(expectedBuffer, providedBuffer)) {
1944
+ let authorized = false;
1945
+ if (token.startsWith("nk_sess_")) {
1946
+ if (await getAdminSession(redis.native, token)) authorized = true;
1947
+ } else if (config.ADMIN_API_KEY) {
1948
+ const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1949
+ const providedBuffer = Buffer.from(token);
1950
+ if (expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer)) authorized = true;
1951
+ }
1952
+ if (!authorized) {
1519
1953
  sendJson(res, 401, {
1520
1954
  error: "unauthorized",
1521
1955
  message: "Invalid admin token"
1522
1956
  });
1523
1957
  return;
1524
1958
  }
1959
+ isAdminToken = true;
1525
1960
  }
1526
1961
  if (isPublicUnsubscribe) {
1527
- const clientIp = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket.remoteAddress || "unknown";
1962
+ const clientIp = getClientIp(req, Boolean(config.TRUST_PROXY));
1528
1963
  try {
1529
1964
  const key = `rate-limit:api:unsub:${clientIp}`;
1530
1965
  const count = await redis.native.incr(key);
@@ -1538,6 +1973,24 @@ async function startApiServer() {
1538
1973
  logger.warn({ err }, "unsubscribe rate limit unavailable — allowing request");
1539
1974
  }
1540
1975
  }
1976
+ if (isPublicAuth) {
1977
+ const clientIp = getClientIp(req, Boolean(config.TRUST_PROXY));
1978
+ try {
1979
+ const key = `rate-limit:api:auth:${clientIp}`;
1980
+ const count = await redis.native.incr(key);
1981
+ if (count === 1) await redis.native.expire(key, 60);
1982
+ if (count > 10) {
1983
+ res.setHeader("Retry-After", "60");
1984
+ sendJson(res, 429, {
1985
+ error: "too_many_requests",
1986
+ message: "Too many login attempts. Please try again later."
1987
+ });
1988
+ return;
1989
+ }
1990
+ } catch (err) {
1991
+ logger.warn({ err }, "auth rate limit unavailable — allowing request");
1992
+ }
1993
+ }
1541
1994
  const route = router.match(req.method ?? "GET", url.pathname);
1542
1995
  if (!route) {
1543
1996
  sendJson(res, 404, { error: "not_found" });
@@ -1547,7 +2000,8 @@ async function startApiServer() {
1547
2000
  params: route.params,
1548
2001
  query: url.searchParams,
1549
2002
  projectId,
1550
- role: keyRole
2003
+ role: keyRole,
2004
+ isAdmin: isAdminToken
1551
2005
  };
1552
2006
  Promise.resolve(route.handler(req, res, ctx)).catch((err) => {
1553
2007
  if (err instanceof HttpError) {
@@ -1729,4 +2183,4 @@ async function stopApiServer() {
1729
2183
  //#endregion
1730
2184
  export { startApiServer, stopApiServer };
1731
2185
 
1732
- //# sourceMappingURL=main-CFSukWm8.mjs.map
2186
+ //# sourceMappingURL=main-JdOvJ70b.mjs.map