notifkit 0.1.6 → 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 (42) hide show
  1. package/README.md +5 -17
  2. package/dashboard/README.md +29 -0
  3. package/dashboard/dist/assets/index-7mvXb4bS.js +310 -0
  4. package/dashboard/dist/assets/index-o7L-f3-c.css +1 -0
  5. package/dashboard/dist/favicon.svg +4 -0
  6. package/dashboard/dist/index.html +14 -0
  7. package/dist/index.d.mts +3435 -548
  8. package/dist/index.d.mts.map +1 -1
  9. package/dist/index.mjs +2 -2
  10. package/dist/{main-DDbquL89.mjs → main-0FHNtN5g.mjs} +2 -2
  11. package/dist/{main-DDbquL89.mjs.map → main-0FHNtN5g.mjs.map} +1 -1
  12. package/dist/{main-DdLzptZE.mjs → main-7KMlWnDx.mjs} +2 -2
  13. package/dist/{main-DdLzptZE.mjs.map → main-7KMlWnDx.mjs.map} +1 -1
  14. package/dist/{main-DsDkyBZi.mjs → main-B0BcY_JJ.mjs} +2 -2
  15. package/dist/{main-DsDkyBZi.mjs.map → main-B0BcY_JJ.mjs.map} +1 -1
  16. package/dist/{main-Db2f2-sW.mjs → main-BAO4kKce.mjs} +437 -63
  17. package/dist/main-BAO4kKce.mjs.map +1 -0
  18. package/dist/{main-DfrrNKp9.mjs → main-BjXgGJBd.mjs} +2 -2
  19. package/dist/{main-DfrrNKp9.mjs.map → main-BjXgGJBd.mjs.map} +1 -1
  20. package/dist/{main-BnIX5nfp.mjs → main-BtXITy0k.mjs} +2 -2
  21. package/dist/{main-BnIX5nfp.mjs.map → main-BtXITy0k.mjs.map} +1 -1
  22. package/dist/{main-CWGEKpLU.mjs → main-BxqAXTr6.mjs} +2 -2
  23. package/dist/{main-CWGEKpLU.mjs.map → main-BxqAXTr6.mjs.map} +1 -1
  24. package/dist/{main-Cwu4iQXc.mjs → main-zCpvA9Rx.mjs} +2 -2
  25. package/dist/{main-Cwu4iQXc.mjs.map → main-zCpvA9Rx.mjs.map} +1 -1
  26. package/dist/{src-CpUnBl_M.mjs → src-DkvHYM3y.mjs} +127 -41
  27. package/dist/src-DkvHYM3y.mjs.map +1 -0
  28. package/drizzle/0005_admin_users.sql +11 -0
  29. package/drizzle/meta/_journal.json +7 -0
  30. package/package.json +8 -3
  31. package/scripts/create-admin.mjs +67 -0
  32. package/src/client.ts +111 -40
  33. package/src/config/index.ts +3 -0
  34. package/src/contracts/sdk.ts +143 -3
  35. package/src/db/schema.ts +13 -0
  36. package/src/repositories/index.ts +82 -1
  37. package/src/services/api/admin-static.ts +224 -0
  38. package/src/services/api/handlers.ts +223 -42
  39. package/src/services/api/main.ts +72 -19
  40. package/src/services/auth/index.ts +103 -0
  41. package/dist/main-Db2f2-sW.mjs.map +0 -1
  42. package/dist/src-CpUnBl_M.mjs.map +0 -1
@@ -1,8 +1,11 @@
1
- import { $t as PUBSUB_CHANNELS, C as ProjectRepository, D as WorkflowRepository, E as UserRepository, Ft as CreateWorkflowSchema, G as normaliseTarget, Gt as UpdateUserSchema, Jt as buildStreamEvent, K as LRUCache, Mt as AddContactSchema, Nt as AddUserSchema, Pt as ContactChannelSchema, Q as metrics, Rt as NotifyRequestSchema, T as TemplateRepository, Ut as TriggerWorkflowSchema, Vt as SyncTemplatesSchema, W as getPriorityBucket, X as StreamProducer, Z as getMetricsRegistry, ct as projectApiKeys, dt as suppressions, en as STREAMS, et as createLogger, ft as userTopicPreferences, hn as readBaseConfig, it as createDatabase, k as RedisClient, lt as projects, mt as workflowDefinitions, p as verifyUnsubscribeToken, pn as loadEnv, pt as users, q as globalEmitter, st as messageLogs, ut as scheduledPayloads, w as SegmentRepository, x as ContactRepository, zt as PreferencesSchema } from "./src-CpUnBl_M.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,44 +125,122 @@ 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),
128
198
  properties: z.record(z.string(), z.unknown())
129
199
  });
130
- /** Map an inline user's contact arrays into (channel, target) pairs. */
200
+ /** Map an inline or user input's contacts and legacy arrays into resolved contact records. */
131
201
  function contactsOf(user) {
132
- return [
133
- ...(user.email ?? []).map((target) => ({
134
- channel: "email",
135
- target
136
- })),
137
- ...(user.phone ?? []).map((target) => ({
138
- channel: "sms",
139
- target
140
- })),
141
- ...(user.pushToken ?? []).map((target) => ({
142
- channel: "push",
143
- target
144
- }))
145
- ];
202
+ const list = [];
203
+ if (user.contacts && Array.isArray(user.contacts)) for (const c of user.contacts) list.push({
204
+ channel: c.channel,
205
+ target: c.target,
206
+ label: c.label,
207
+ isPrimary: c.isPrimary,
208
+ enabled: c.enabled,
209
+ preferences: c.preferences
210
+ });
211
+ if (user.email) for (const target of user.email) list.push({
212
+ channel: "email",
213
+ target
214
+ });
215
+ if (user.phone) for (const target of user.phone) list.push({
216
+ channel: "sms",
217
+ target
218
+ });
219
+ if (user.pushToken) for (const target of user.pushToken) list.push({
220
+ channel: "push",
221
+ target
222
+ });
223
+ return list;
146
224
  }
147
225
  /** Persist user records + their contacts in bulk. Shared by addUser and inline notify. */
148
226
  async function persistUsers(deps, users, projectId) {
149
- const usersList = users.map((u) => ({
150
- userId: u.id,
151
- language: u.language ?? "en",
152
- timezone: u.timezone ?? "UTC",
153
- email: u.email?.[0] ?? null,
154
- segments: u.segments ?? [],
155
- preferences: u.preferences ?? {}
156
- }));
227
+ const usersList = users.map((u) => {
228
+ const primaryEmail = u.email?.[0] ?? u.contacts?.find((c) => c.channel === "email")?.target ?? null;
229
+ return {
230
+ userId: u.id,
231
+ language: u.language ?? "en",
232
+ timezone: u.timezone ?? "UTC",
233
+ email: primaryEmail,
234
+ segments: u.segments ?? [],
235
+ preferences: u.preferences ?? {}
236
+ };
237
+ });
157
238
  const contactsList = [];
158
239
  for (const u of users) for (const c of contactsOf(u)) contactsList.push({
159
240
  userId: u.id,
160
241
  channel: c.channel,
161
242
  target: c.target,
162
- preferences: {}
243
+ preferences: c.preferences ?? {}
163
244
  });
164
245
  await deps.db.transaction(async (tx) => {
165
246
  const txUserRepo = deps.userRepo.constructor.name === "Object" ? deps.userRepo : new deps.userRepo.constructor(tx);
@@ -204,7 +285,7 @@ function createHandlers(deps) {
204
285
  if (!await deps.userRepo.updatePartial(ctx.projectId, userId, {
205
286
  language: patch.language,
206
287
  timezone: patch.timezone,
207
- email: patch.email?.[0],
288
+ email: patch.email?.[0] ?? patch.contacts?.find((c) => c.channel === "email")?.target,
208
289
  segments: patch.segments,
209
290
  preferences: patch.preferences
210
291
  })) return sendJson(res, 404, {
@@ -214,7 +295,8 @@ function createHandlers(deps) {
214
295
  for (const c of contactsOf({
215
296
  id: userId,
216
297
  ...patch
217
- })) await deps.contactRepo.upsert(ctx.projectId, userId, c.channel, c.target);
298
+ })) if (c.preferences) await deps.contactRepo.upsert(ctx.projectId, userId, c.channel, c.target, c.preferences);
299
+ else await deps.contactRepo.upsert(ctx.projectId, userId, c.channel, c.target);
218
300
  logger.info({ userId }, "user updated");
219
301
  sendJson(res, 200, { id: userId });
220
302
  }
@@ -229,22 +311,44 @@ function createHandlers(deps) {
229
311
  }
230
312
  async function addContact(req, res, ctx) {
231
313
  const userId = ctx.params.id;
232
- const parsed = AddContactSchema.safeParse(await readJsonBody(req));
314
+ const body = await readJsonBody(req);
315
+ const parsed = BatchAddContactsSchema.safeParse(body);
233
316
  if (!parsed.success) return sendValidationError(res, parsed.error);
234
317
  if (!await deps.userRepo.findById(ctx.projectId, userId)) return sendJson(res, 404, {
235
318
  error: "user_not_found",
236
319
  id: userId
237
320
  });
238
- await deps.contactRepo.upsert(ctx.projectId, userId, parsed.data.channel, parsed.data.target, parsed.data.preferences ?? {});
239
- logger.info({
240
- userId,
241
- channel: parsed.data.channel
242
- }, "contact added");
243
- sendJson(res, 201, {
244
- userId,
245
- channel: parsed.data.channel,
246
- target: parsed.data.target
247
- });
321
+ if (Array.isArray(parsed.data)) {
322
+ const contactsList = parsed.data.map((c) => ({
323
+ userId,
324
+ channel: c.channel,
325
+ target: c.target,
326
+ preferences: c.preferences ?? {}
327
+ }));
328
+ await deps.contactRepo.upsertMany(ctx.projectId, contactsList);
329
+ logger.info({
330
+ userId,
331
+ count: contactsList.length
332
+ }, "contacts batch added");
333
+ sendJson(res, 201, {
334
+ userId,
335
+ contacts: contactsList.map((c) => ({
336
+ channel: c.channel,
337
+ target: c.target
338
+ }))
339
+ });
340
+ } else {
341
+ await deps.contactRepo.upsert(ctx.projectId, userId, parsed.data.channel, parsed.data.target, parsed.data.preferences ?? {});
342
+ logger.info({
343
+ userId,
344
+ channel: parsed.data.channel
345
+ }, "contact added");
346
+ sendJson(res, 201, {
347
+ userId,
348
+ channel: parsed.data.channel,
349
+ target: parsed.data.target
350
+ });
351
+ }
248
352
  }
249
353
  async function deleteContact(_req, res, ctx) {
250
354
  const userId = ctx.params.id;
@@ -775,17 +879,28 @@ function createHandlers(deps) {
775
879
  } catch {
776
880
  streamDepths[key] = 0;
777
881
  }
778
- const totalTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(eq(messageLogs.projectId, ctx.projectId));
779
- 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")));
780
- const total = Number(totalTasksRes[0]?.count ?? 0);
781
- 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;
782
897
  sendJson(res, 200, {
783
898
  streams: streamDepths,
784
899
  deliveryStats: {
785
900
  total,
786
901
  delivered,
787
- failed: streamDepths.DEAD_LETTER || 0,
788
- successRate: total > 0 ? Number((delivered / total * 100).toFixed(2)) : 100
902
+ failed,
903
+ successRate
789
904
  }
790
905
  });
791
906
  }
@@ -1117,7 +1232,88 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
1117
1232
  await deps.db.delete(suppressions).where(and(eq(suppressions.projectId, ctx.projectId), eq(suppressions.channel, channel), eq(suppressions.target, normaliseTarget(decodeURIComponent(target)))));
1118
1233
  sendNoContent(res);
1119
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
+ }
1120
1313
  return {
1314
+ login,
1315
+ logout,
1316
+ getMe,
1121
1317
  syncTemplates,
1122
1318
  addUser,
1123
1319
  updateUser,
@@ -1167,6 +1363,158 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
1167
1363
  };
1168
1364
  }
1169
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
1170
1518
  //#region src/services/api/main.ts
1171
1519
  /** Pub/sub channel used to drop a cached API key across every API process. */
1172
1520
  const API_KEY_INVALIDATION_CHANNEL = "apikey.invalidated";
@@ -1336,6 +1684,21 @@ async function startApiServer() {
1336
1684
  logger
1337
1685
  })
1338
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
+ }
1339
1702
  deps = {
1340
1703
  logger,
1341
1704
  redis,
@@ -1346,11 +1709,12 @@ async function startApiServer() {
1346
1709
  projectRepo: new ProjectRepository(db),
1347
1710
  workflowRepo: new WorkflowRepository(db),
1348
1711
  segmentRepo: new SegmentRepository(db),
1712
+ adminUserRepo,
1349
1713
  db
1350
1714
  };
1351
1715
  h = createHandlers(deps);
1352
1716
  router = new Router();
1353
- 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);
1354
1718
  server = createServer((req, res) => {
1355
1719
  handleRequest(req, res);
1356
1720
  });
@@ -1367,12 +1731,16 @@ async function startApiServer() {
1367
1731
  return;
1368
1732
  }
1369
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
+ }
1370
1737
  let projectId = void 0;
1371
1738
  let projectRateLimitRpm = 600;
1372
1739
  let keyRole = "admin";
1373
1740
  const isProjectManagement = url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/");
1374
1741
  const isPublicUnsubscribe = url.pathname === "/v1/unsubscribe";
1375
- if (url.pathname.startsWith("/v1/") && !isProjectManagement && !isPublicUnsubscribe) {
1742
+ const isPublicAuth = url.pathname === "/v1/auth/login";
1743
+ if (url.pathname.startsWith("/v1/") && !isProjectManagement && !isPublicUnsubscribe && !isPublicAuth) {
1376
1744
  let token = extractAuthToken(req);
1377
1745
  if (!token && url.searchParams.has("token")) token = url.searchParams.get("token") || void 0;
1378
1746
  if (!token) {
@@ -1383,14 +1751,21 @@ async function startApiServer() {
1383
1751
  return;
1384
1752
  }
1385
1753
  let isAdminToken = false;
1386
- 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) {
1387
1761
  const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1388
1762
  const providedBuffer = Buffer.from(token);
1389
1763
  if (expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer)) isAdminToken = true;
1390
1764
  }
1391
1765
  if (isAdminToken) {
1392
1766
  const headerProjectId = req.headers["x-project-id"] || url.searchParams.get("projectId") || void 0;
1393
- if (!headerProjectId) {
1767
+ const isProjectAgnostic = url.pathname.startsWith("/v1/auth/") || url.pathname.startsWith("/v1/system/") || url.pathname.startsWith("/v1/dlq");
1768
+ if (!headerProjectId && !isProjectAgnostic) {
1394
1769
  sendJson(res, 400, {
1395
1770
  error: "bad_request",
1396
1771
  message: "x-project-id header or projectId query param required when using admin token"
@@ -1399,7 +1774,7 @@ async function startApiServer() {
1399
1774
  }
1400
1775
  projectId = headerProjectId;
1401
1776
  projectRateLimitRpm = 6e3;
1402
- keyRole = "admin";
1777
+ if (!token.startsWith("nk_sess_")) keyRole = "admin";
1403
1778
  } else {
1404
1779
  const tokenHash = createHash("sha256").update(token).digest("hex");
1405
1780
  const cached = authCache.get(tokenHash);
@@ -1451,7 +1826,7 @@ async function startApiServer() {
1451
1826
  end
1452
1827
  return -1
1453
1828
  `;
1454
- const rlKey = `rate-limit:api:req:${projectId}`;
1829
+ const rlKey = `rate-limit:api:req:${projectId || "global"}`;
1455
1830
  const nowMs = Date.now();
1456
1831
  if (await redis.native.eval(LUA_LIMIT, 1, rlKey, nowMs, 6e4, projectRateLimitRpm, randomBytes(4).toString("hex")) === -1) {
1457
1832
  if (!res.headersSent) {
@@ -1465,13 +1840,6 @@ async function startApiServer() {
1465
1840
  }
1466
1841
  } else if (isProjectManagement) {
1467
1842
  const token = extractAuthToken(req);
1468
- if (!config.ADMIN_API_KEY) {
1469
- sendJson(res, 403, {
1470
- error: "forbidden",
1471
- message: "Project management disabled (no ADMIN_API_KEY set)"
1472
- });
1473
- return;
1474
- }
1475
1843
  if (!token) {
1476
1844
  sendJson(res, 401, {
1477
1845
  error: "unauthorized",
@@ -1479,9 +1847,15 @@ async function startApiServer() {
1479
1847
  });
1480
1848
  return;
1481
1849
  }
1482
- const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
1483
- const providedBuffer = Buffer.from(token);
1484
- 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) {
1485
1859
  sendJson(res, 401, {
1486
1860
  error: "unauthorized",
1487
1861
  message: "Invalid admin token"
@@ -1695,4 +2069,4 @@ async function stopApiServer() {
1695
2069
  //#endregion
1696
2070
  export { startApiServer, stopApiServer };
1697
2071
 
1698
- //# sourceMappingURL=main-Db2f2-sW.mjs.map
2072
+ //# sourceMappingURL=main-BAO4kKce.mjs.map