notifkit 0.1.8 → 0.1.10
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/README.md +66 -72
- package/dist/index.d.mts +22 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{main-BAO4kKce.mjs → main-39DtKrX4.mjs} +142 -28
- package/dist/main-39DtKrX4.mjs.map +1 -0
- package/dist/{main-B0BcY_JJ.mjs → main-B3iQrl3t.mjs} +2 -2
- package/dist/{main-B0BcY_JJ.mjs.map → main-B3iQrl3t.mjs.map} +1 -1
- package/dist/{main-7KMlWnDx.mjs → main-BM8DH3v3.mjs} +2 -2
- package/dist/{main-7KMlWnDx.mjs.map → main-BM8DH3v3.mjs.map} +1 -1
- package/dist/{main-BtXITy0k.mjs → main-BgmT1MPe.mjs} +2 -2
- package/dist/{main-BtXITy0k.mjs.map → main-BgmT1MPe.mjs.map} +1 -1
- package/dist/{main-BxqAXTr6.mjs → main-BiFiOX9D.mjs} +2 -2
- package/dist/{main-BxqAXTr6.mjs.map → main-BiFiOX9D.mjs.map} +1 -1
- package/dist/{main-0FHNtN5g.mjs → main-C9s-2ArH.mjs} +2 -2
- package/dist/{main-0FHNtN5g.mjs.map → main-C9s-2ArH.mjs.map} +1 -1
- package/dist/{main-zCpvA9Rx.mjs → main-Cqo3rSb5.mjs} +2 -2
- package/dist/{main-zCpvA9Rx.mjs.map → main-Cqo3rSb5.mjs.map} +1 -1
- package/dist/{main-BjXgGJBd.mjs → main-Dz2sUh3c.mjs} +2 -2
- package/dist/{main-BjXgGJBd.mjs.map → main-Dz2sUh3c.mjs.map} +1 -1
- package/dist/{src-DkvHYM3y.mjs → src-QiTu5e_a.mjs} +30 -18
- package/dist/src-QiTu5e_a.mjs.map +1 -0
- package/package.json +1 -1
- package/scripts/create-admin.mjs +8 -11
- package/src/config/index.ts +9 -0
- package/src/server.ts +10 -0
- package/src/services/api/admin-static.ts +3 -2
- package/src/services/api/handlers.ts +102 -28
- package/src/services/api/main.ts +112 -11
- package/src/services/api/router.ts +7 -1
- package/src/services/auth/index.ts +8 -0
- package/dist/main-BAO4kKce.mjs.map +0 -1
- package/dist/src-DkvHYM3y.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
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-
|
|
2
|
-
import { extname, join, resolve } from "node:path";
|
|
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-QiTu5e_a.mjs";
|
|
2
|
+
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { and, desc, eq, gte, inArray, isNotNull, like, lt, lte, or, sql } from "drizzle-orm";
|
|
@@ -107,7 +107,12 @@ var Router = class {
|
|
|
107
107
|
for (let i = 0; i < route.segments.length; i++) {
|
|
108
108
|
const seg = route.segments[i];
|
|
109
109
|
const part = parts[i];
|
|
110
|
-
if (seg.startsWith(":"))
|
|
110
|
+
if (seg.startsWith(":")) try {
|
|
111
|
+
params[seg.slice(1)] = decodeURIComponent(part);
|
|
112
|
+
} catch {
|
|
113
|
+
ok = false;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
111
116
|
else if (seg !== part) {
|
|
112
117
|
ok = false;
|
|
113
118
|
break;
|
|
@@ -130,6 +135,12 @@ const scryptAsync = promisify(scrypt);
|
|
|
130
135
|
const DEFAULT_SESSION_TTL_SECONDS = 604800;
|
|
131
136
|
const SESSION_PREFIX = "notif:session:";
|
|
132
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
|
+
/**
|
|
133
144
|
* Hashes a plaintext password using crypto.scrypt with a random 16-byte salt.
|
|
134
145
|
* Returns salt and hash separated by a colon.
|
|
135
146
|
*/
|
|
@@ -874,7 +885,7 @@ function createHandlers(deps) {
|
|
|
874
885
|
DEAD_LETTER: STREAMS.DEAD_LETTER
|
|
875
886
|
};
|
|
876
887
|
const streamDepths = {};
|
|
877
|
-
for (const [key, realRedisKey] of Object.entries(streamMap)) try {
|
|
888
|
+
if (ctx.isAdmin !== false) for (const [key, realRedisKey] of Object.entries(streamMap)) try {
|
|
878
889
|
streamDepths[key] = await deps.redis.native.xlen(realRedisKey);
|
|
879
890
|
} catch {
|
|
880
891
|
streamDepths[key] = 0;
|
|
@@ -892,7 +903,7 @@ function createHandlers(deps) {
|
|
|
892
903
|
total = Number(totalTasksRes[0]?.count ?? 0);
|
|
893
904
|
delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
|
894
905
|
}
|
|
895
|
-
const failed = streamDepths.DEAD_LETTER || 0;
|
|
906
|
+
const failed = ctx.projectId ? Math.max(0, total - delivered) : streamDepths.DEAD_LETTER || 0;
|
|
896
907
|
const successRate = total > 0 ? Number((delivered / total * 100).toFixed(2)) : 100;
|
|
897
908
|
sendJson(res, 200, {
|
|
898
909
|
streams: streamDepths,
|
|
@@ -904,24 +915,33 @@ function createHandlers(deps) {
|
|
|
904
915
|
}
|
|
905
916
|
});
|
|
906
917
|
}
|
|
907
|
-
async function getDLQMessages(_req, res,
|
|
918
|
+
async function getDLQMessages(_req, res, ctx) {
|
|
908
919
|
try {
|
|
909
|
-
|
|
920
|
+
const allMessages = (await deps.redis.native.xrevrange(STREAMS.DEAD_LETTER, "+", "-", "COUNT", "100") || []).map(([id, fields]) => {
|
|
910
921
|
const fieldMap = {};
|
|
911
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
|
+
}
|
|
912
929
|
return {
|
|
913
930
|
id,
|
|
914
931
|
eventType: fieldMap.eventType || fieldMap.event_type || "unknown",
|
|
915
|
-
payload
|
|
932
|
+
payload,
|
|
916
933
|
error: fieldMap.error || fieldMap.reason || "Dead letter payload",
|
|
917
934
|
timestamp: fieldMap.timestamp || (/* @__PURE__ */ new Date()).toISOString()
|
|
918
935
|
};
|
|
919
|
-
})
|
|
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) });
|
|
920
940
|
} catch {
|
|
921
941
|
sendJson(res, 200, { messages: [] });
|
|
922
942
|
}
|
|
923
943
|
}
|
|
924
|
-
async function replayDLQMessage(req, res,
|
|
944
|
+
async function replayDLQMessage(req, res, ctx) {
|
|
925
945
|
const messageId = (await readJsonBody(req).catch(() => ({})))?.id;
|
|
926
946
|
if (!messageId) {
|
|
927
947
|
sendJson(res, 400, { error: "missing_message_id" });
|
|
@@ -936,6 +956,16 @@ function createHandlers(deps) {
|
|
|
936
956
|
const fields = rawEntries[0][1];
|
|
937
957
|
const fieldMap = {};
|
|
938
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
|
+
}
|
|
939
969
|
const priority = fieldMap.priority || "normal";
|
|
940
970
|
const p = getPriorityBucket(priority);
|
|
941
971
|
const targetStream = STREAMS[`INBOUND_${p.toUpperCase()}`] || STREAMS.INBOUND_NORMAL;
|
|
@@ -958,6 +988,24 @@ function createHandlers(deps) {
|
|
|
958
988
|
const messageId = ctx.params.id;
|
|
959
989
|
if (!messageId) return sendJson(res, 400, { error: "missing_id" });
|
|
960
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
|
+
}
|
|
961
1009
|
await deps.redis.native.xdel(STREAMS.DEAD_LETTER, messageId);
|
|
962
1010
|
sendJson(res, 200, { success: true });
|
|
963
1011
|
} catch (err) {
|
|
@@ -991,7 +1039,13 @@ function createHandlers(deps) {
|
|
|
991
1039
|
id: userId
|
|
992
1040
|
});
|
|
993
1041
|
const contacts = await deps.contactRepo.findByUserId(ctx.projectId, userId);
|
|
994
|
-
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
|
+
}
|
|
995
1049
|
sendJson(res, 200, {
|
|
996
1050
|
...user,
|
|
997
1051
|
contacts,
|
|
@@ -1229,7 +1283,7 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
|
|
|
1229
1283
|
async function deleteSuppression(_req, res, ctx) {
|
|
1230
1284
|
const { channel, target } = ctx.params;
|
|
1231
1285
|
if (!channel || !target) return sendJson(res, 400, { error: "missing_channel_or_target" });
|
|
1232
|
-
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))));
|
|
1233
1287
|
sendNoContent(res);
|
|
1234
1288
|
}
|
|
1235
1289
|
async function login(req, res) {
|
|
@@ -1251,14 +1305,8 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
|
|
|
1251
1305
|
}
|
|
1252
1306
|
const { identifier, password } = parsed.data;
|
|
1253
1307
|
const user = await deps.adminUserRepo.findByEmailOrUsername(identifier);
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
error: "unauthorized",
|
|
1257
|
-
message: "Invalid credentials"
|
|
1258
|
-
});
|
|
1259
|
-
return;
|
|
1260
|
-
}
|
|
1261
|
-
if (!await verifyPassword(password, user.passwordHash)) {
|
|
1308
|
+
const isValid = await verifyPassword(password, user ? user.passwordHash : DUMMY_PASSWORD_HASH);
|
|
1309
|
+
if (!user || !isValid) {
|
|
1262
1310
|
sendJson(res, 401, {
|
|
1263
1311
|
error: "unauthorized",
|
|
1264
1312
|
message: "Invalid credentials"
|
|
@@ -1467,7 +1515,8 @@ async function handleAdminRequest(req, res, url) {
|
|
|
1467
1515
|
if (!subPath) subPath = "index.html";
|
|
1468
1516
|
const normalizedSubPath = subPath.replace(/\/$/, "");
|
|
1469
1517
|
let filePath = resolve(dashboardDir, subPath);
|
|
1470
|
-
|
|
1518
|
+
const rel = relative(dashboardDir, filePath);
|
|
1519
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
1471
1520
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
1472
1521
|
res.end("Forbidden");
|
|
1473
1522
|
return true;
|
|
@@ -1537,6 +1586,19 @@ function extractAuthToken(req) {
|
|
|
1537
1586
|
const apiKeyHeader = req.headers["x-api-key"];
|
|
1538
1587
|
if (typeof apiKeyHeader === "string") return apiKeyHeader.trim();
|
|
1539
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
|
+
}
|
|
1540
1602
|
async function handleCreateProject(req, res) {
|
|
1541
1603
|
const parsed = z.object({ name: z.string().min(1) }).safeParse(await readJsonBody(req));
|
|
1542
1604
|
if (!parsed.success) {
|
|
@@ -1614,7 +1676,28 @@ async function handleHealth(_req, res) {
|
|
|
1614
1676
|
};
|
|
1615
1677
|
sendJson(res, statusCode, response);
|
|
1616
1678
|
}
|
|
1617
|
-
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
|
+
}
|
|
1618
1701
|
const registry = getMetricsRegistry();
|
|
1619
1702
|
res.writeHead(200, { "Content-Type": registry.contentType });
|
|
1620
1703
|
res.end(await registry.metrics());
|
|
@@ -1645,6 +1728,10 @@ async function startApiServer() {
|
|
|
1645
1728
|
name: "api",
|
|
1646
1729
|
level: config.LOG_LEVEL
|
|
1647
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
|
+
}
|
|
1648
1735
|
redis = new RedisClient({
|
|
1649
1736
|
url: config.REDIS_URL,
|
|
1650
1737
|
name: "api",
|
|
@@ -1722,7 +1809,15 @@ async function startApiServer() {
|
|
|
1722
1809
|
server.headersTimeout = 1e4;
|
|
1723
1810
|
server.keepAliveTimeout = 5e3;
|
|
1724
1811
|
async function handleRequest(req, res) {
|
|
1725
|
-
|
|
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", "*");
|
|
1726
1821
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
1727
1822
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key, x-project-id");
|
|
1728
1823
|
if (req.method === "OPTIONS") {
|
|
@@ -1730,13 +1825,13 @@ async function startApiServer() {
|
|
|
1730
1825
|
res.end();
|
|
1731
1826
|
return;
|
|
1732
1827
|
}
|
|
1733
|
-
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
1734
1828
|
if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) {
|
|
1735
1829
|
if (await handleAdminRequest(req, res, url)) return;
|
|
1736
1830
|
}
|
|
1737
1831
|
let projectId = void 0;
|
|
1738
1832
|
let projectRateLimitRpm = 600;
|
|
1739
1833
|
let keyRole = "admin";
|
|
1834
|
+
let isAdminToken = false;
|
|
1740
1835
|
const isProjectManagement = url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/");
|
|
1741
1836
|
const isPublicUnsubscribe = url.pathname === "/v1/unsubscribe";
|
|
1742
1837
|
const isPublicAuth = url.pathname === "/v1/auth/login";
|
|
@@ -1750,7 +1845,6 @@ async function startApiServer() {
|
|
|
1750
1845
|
});
|
|
1751
1846
|
return;
|
|
1752
1847
|
}
|
|
1753
|
-
let isAdminToken = false;
|
|
1754
1848
|
if (token.startsWith("nk_sess_")) {
|
|
1755
1849
|
const session = await getAdminSession(redis.native, token);
|
|
1756
1850
|
if (session) {
|
|
@@ -1862,9 +1956,10 @@ async function startApiServer() {
|
|
|
1862
1956
|
});
|
|
1863
1957
|
return;
|
|
1864
1958
|
}
|
|
1959
|
+
isAdminToken = true;
|
|
1865
1960
|
}
|
|
1866
1961
|
if (isPublicUnsubscribe) {
|
|
1867
|
-
const clientIp = req
|
|
1962
|
+
const clientIp = getClientIp(req, Boolean(config.TRUST_PROXY));
|
|
1868
1963
|
try {
|
|
1869
1964
|
const key = `rate-limit:api:unsub:${clientIp}`;
|
|
1870
1965
|
const count = await redis.native.incr(key);
|
|
@@ -1878,6 +1973,24 @@ async function startApiServer() {
|
|
|
1878
1973
|
logger.warn({ err }, "unsubscribe rate limit unavailable — allowing request");
|
|
1879
1974
|
}
|
|
1880
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
|
+
}
|
|
1881
1994
|
const route = router.match(req.method ?? "GET", url.pathname);
|
|
1882
1995
|
if (!route) {
|
|
1883
1996
|
sendJson(res, 404, { error: "not_found" });
|
|
@@ -1887,7 +2000,8 @@ async function startApiServer() {
|
|
|
1887
2000
|
params: route.params,
|
|
1888
2001
|
query: url.searchParams,
|
|
1889
2002
|
projectId,
|
|
1890
|
-
role: keyRole
|
|
2003
|
+
role: keyRole,
|
|
2004
|
+
isAdmin: isAdminToken
|
|
1891
2005
|
};
|
|
1892
2006
|
Promise.resolve(route.handler(req, res, ctx)).catch((err) => {
|
|
1893
2007
|
if (err instanceof HttpError) {
|
|
@@ -2069,4 +2183,4 @@ async function stopApiServer() {
|
|
|
2069
2183
|
//#endregion
|
|
2070
2184
|
export { startApiServer, stopApiServer };
|
|
2071
2185
|
|
|
2072
|
-
//# sourceMappingURL=main-
|
|
2186
|
+
//# sourceMappingURL=main-39DtKrX4.mjs.map
|