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.
- 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 +447 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{main-CRPxM-O3.mjs → main-4Wyge9ms.mjs} +2 -2
- package/dist/{main-CRPxM-O3.mjs.map → main-4Wyge9ms.mjs.map} +1 -1
- package/dist/{main-Bgi2wWck.mjs → main-AO0JaY5z.mjs} +2 -2
- package/dist/{main-Bgi2wWck.mjs.map → main-AO0JaY5z.mjs.map} +1 -1
- package/dist/{main-DFMHcN_d.mjs → main-BMYSYcv2.mjs} +2 -2
- package/dist/{main-DFMHcN_d.mjs.map → main-BMYSYcv2.mjs.map} +1 -1
- package/dist/{main-Yorxzw0Z.mjs → main-BPbPnBIG.mjs} +2 -2
- package/dist/{main-Yorxzw0Z.mjs.map → main-BPbPnBIG.mjs.map} +1 -1
- package/dist/{main-pFAfCkVX.mjs → main-CyeQ_yDt.mjs} +2 -2
- package/dist/{main-pFAfCkVX.mjs.map → main-CyeQ_yDt.mjs.map} +1 -1
- package/dist/{main-CjbVdUqa.mjs → main-D19Wxs0x.mjs} +2 -2
- package/dist/{main-CjbVdUqa.mjs.map → main-D19Wxs0x.mjs.map} +1 -1
- package/dist/{main-D6SG3Isk.mjs → main-DSmh1e-V.mjs} +2 -2
- package/dist/{main-D6SG3Isk.mjs.map → main-DSmh1e-V.mjs.map} +1 -1
- package/dist/{main-CFSukWm8.mjs → main-JdOvJ70b.mjs} +495 -41
- package/dist/main-JdOvJ70b.mjs.map +1 -0
- package/dist/{src-CPMwsUCJ.mjs → src-CGmXRfsM.mjs} +86 -20
- package/dist/src-CGmXRfsM.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 +12 -0
- package/src/db/schema.ts +13 -0
- package/src/repositories/index.ts +82 -1
- package/src/services/api/admin-static.ts +225 -0
- package/src/services/api/handlers.ts +224 -33
- package/src/services/api/main.ts +183 -29
- package/src/services/api/router.ts +7 -1
- package/src/services/auth/index.ts +111 -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, 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(":"))
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
822
|
-
successRate
|
|
913
|
+
failed,
|
|
914
|
+
successRate
|
|
823
915
|
}
|
|
824
916
|
});
|
|
825
917
|
}
|
|
826
|
-
async function getDLQMessages(_req, res,
|
|
918
|
+
async function getDLQMessages(_req, res, ctx) {
|
|
827
919
|
try {
|
|
828
|
-
|
|
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
|
|
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,
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
|
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-
|
|
2186
|
+
//# sourceMappingURL=main-JdOvJ70b.mjs.map
|