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.
- package/dashboard/README.md +29 -0
- package/dashboard/dist/assets/index-7mvXb4bS.js +310 -0
- package/dashboard/dist/assets/index-o7L-f3-c.css +1 -0
- package/dashboard/dist/favicon.svg +4 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/index.d.mts +433 -8
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{main-D6SG3Isk.mjs → main-0FHNtN5g.mjs} +2 -2
- package/dist/{main-D6SG3Isk.mjs.map → main-0FHNtN5g.mjs.map} +1 -1
- package/dist/{main-pFAfCkVX.mjs → main-7KMlWnDx.mjs} +2 -2
- package/dist/{main-pFAfCkVX.mjs.map → main-7KMlWnDx.mjs.map} +1 -1
- package/dist/{main-Bgi2wWck.mjs → main-B0BcY_JJ.mjs} +2 -2
- package/dist/{main-Bgi2wWck.mjs.map → main-B0BcY_JJ.mjs.map} +1 -1
- package/dist/{main-CFSukWm8.mjs → main-BAO4kKce.mjs} +366 -26
- package/dist/main-BAO4kKce.mjs.map +1 -0
- package/dist/{main-CRPxM-O3.mjs → main-BjXgGJBd.mjs} +2 -2
- package/dist/{main-CRPxM-O3.mjs.map → main-BjXgGJBd.mjs.map} +1 -1
- package/dist/{main-CjbVdUqa.mjs → main-BtXITy0k.mjs} +2 -2
- package/dist/{main-CjbVdUqa.mjs.map → main-BtXITy0k.mjs.map} +1 -1
- package/dist/{main-Yorxzw0Z.mjs → main-BxqAXTr6.mjs} +2 -2
- package/dist/{main-Yorxzw0Z.mjs.map → main-BxqAXTr6.mjs.map} +1 -1
- package/dist/{main-DFMHcN_d.mjs → main-zCpvA9Rx.mjs} +2 -2
- package/dist/{main-DFMHcN_d.mjs.map → main-zCpvA9Rx.mjs.map} +1 -1
- package/dist/{src-CPMwsUCJ.mjs → src-DkvHYM3y.mjs} +76 -19
- package/dist/src-DkvHYM3y.mjs.map +1 -0
- package/drizzle/0005_admin_users.sql +11 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +8 -3
- package/scripts/create-admin.mjs +67 -0
- package/src/config/index.ts +3 -0
- package/src/db/schema.ts +13 -0
- package/src/repositories/index.ts +82 -1
- package/src/services/api/admin-static.ts +224 -0
- package/src/services/api/handlers.ts +129 -12
- package/src/services/api/main.ts +72 -19
- package/src/services/auth/index.ts +103 -0
- package/dist/main-CFSukWm8.mjs.map +0 -1
- package/dist/src-CPMwsUCJ.mjs.map +0 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
822
|
-
successRate
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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-
|
|
2072
|
+
//# sourceMappingURL=main-BAO4kKce.mjs.map
|