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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "notifkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Self-hosted notification infrastructure. One call delivers to email, SMS, push, and webhook — routed by preference, quiet hours, and consent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "devkitshq",
|
package/scripts/create-admin.mjs
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* Create or update an admin user for the Notifkit Dashboard.
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secret
|
|
7
|
-
*
|
|
6
|
+
* ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secret npx notifkit-create-admin
|
|
7
|
+
* npx notifkit-create-admin admin@example.com secret [role]
|
|
8
|
+
* node scripts/create-admin.mjs admin@example.com secret [role]
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { config } from "dotenv";
|
|
@@ -25,11 +26,10 @@ async function hashPassword(password) {
|
|
|
25
26
|
|
|
26
27
|
const email = process.argv[2] || process.env.ADMIN_EMAIL;
|
|
27
28
|
const password = process.argv[3] || process.env.ADMIN_PASSWORD;
|
|
28
|
-
const
|
|
29
|
-
const role = process.argv[5] || "admin";
|
|
29
|
+
const role = process.argv[4] || "admin";
|
|
30
30
|
|
|
31
31
|
if (!email || !password) {
|
|
32
|
-
console.error("Usage:
|
|
32
|
+
console.error("Usage: npx notifkit-create-admin <email> <password> [role]");
|
|
33
33
|
console.error("Or set ADMIN_EMAIL and ADMIN_PASSWORD in environment.");
|
|
34
34
|
process.exit(1);
|
|
35
35
|
}
|
|
@@ -41,23 +41,20 @@ const sql = postgres(databaseUrl);
|
|
|
41
41
|
try {
|
|
42
42
|
const passwordHash = await hashPassword(password);
|
|
43
43
|
const normalizedEmail = email.trim().toLowerCase();
|
|
44
|
-
const normalizedUsername = username ? username.trim() : null;
|
|
45
44
|
|
|
46
45
|
const rows = await sql`
|
|
47
|
-
INSERT INTO admin_users (email,
|
|
48
|
-
VALUES (${normalizedEmail}, ${
|
|
46
|
+
INSERT INTO admin_users (email, password_hash, role)
|
|
47
|
+
VALUES (${normalizedEmail}, ${passwordHash}, ${role})
|
|
49
48
|
ON CONFLICT (email) DO UPDATE
|
|
50
49
|
SET password_hash = EXCLUDED.password_hash,
|
|
51
|
-
username = COALESCE(EXCLUDED.username, admin_users.username),
|
|
52
50
|
role = EXCLUDED.role,
|
|
53
51
|
updated_at = NOW()
|
|
54
|
-
RETURNING id, email,
|
|
52
|
+
RETURNING id, email, role, created_at, updated_at
|
|
55
53
|
`;
|
|
56
54
|
|
|
57
55
|
console.log("\nAdmin user successfully saved:\n");
|
|
58
56
|
console.log(` ID: ${rows[0].id}`);
|
|
59
57
|
console.log(` Email: ${rows[0].email}`);
|
|
60
|
-
console.log(` Username: ${rows[0].username || "(none)"}`);
|
|
61
58
|
console.log(` Role: ${rows[0].role}\n`);
|
|
62
59
|
} catch (err) {
|
|
63
60
|
console.error(`Failed to create admin: ${err.message}`);
|
package/src/config/index.ts
CHANGED
|
@@ -65,6 +65,15 @@ export const baseConfigSchema = z.object({
|
|
|
65
65
|
* instead, which costs far more than the key ever protected.
|
|
66
66
|
*/
|
|
67
67
|
UNSUBSCRIBE_SECRET: z.string().min(16).optional(),
|
|
68
|
+
/**
|
|
69
|
+
* When true, trusts X-Forwarded-For headers from reverse proxies for client IP resolution.
|
|
70
|
+
* Defaults to false to prevent client-spoofed headers from bypassing rate limits.
|
|
71
|
+
*/
|
|
72
|
+
TRUST_PROXY: z.coerce.boolean().default(false),
|
|
73
|
+
/**
|
|
74
|
+
* Comma-separated list of allowed origins for CORS on session-authenticated admin routes.
|
|
75
|
+
*/
|
|
76
|
+
CORS_ORIGIN: z.string().optional(),
|
|
68
77
|
});
|
|
69
78
|
|
|
70
79
|
export type BaseConfig = z.infer<typeof baseConfigSchema>;
|
package/src/server.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface NotifkitOptions {
|
|
|
10
10
|
port?: number;
|
|
11
11
|
logLevel?: "fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent";
|
|
12
12
|
nodeEnv?: "development" | "test" | "production";
|
|
13
|
+
adminUser?: {
|
|
14
|
+
email: string;
|
|
15
|
+
password: string;
|
|
16
|
+
username?: string;
|
|
17
|
+
};
|
|
13
18
|
services: (
|
|
14
19
|
"api" | "delivery" | "engine" | "enricher" | "scheduler" | "ai" | "workflow" | "events" | "all"
|
|
15
20
|
)[];
|
|
@@ -69,6 +74,11 @@ export class NotifkitServer extends EventEmitter {
|
|
|
69
74
|
if (this.options.port) process.env.PORT = String(this.options.port);
|
|
70
75
|
if (this.options.logLevel) process.env.LOG_LEVEL = this.options.logLevel;
|
|
71
76
|
if (this.options.nodeEnv) process.env.NODE_ENV = this.options.nodeEnv;
|
|
77
|
+
if (this.options.adminUser?.email) process.env.ADMIN_EMAIL = this.options.adminUser.email;
|
|
78
|
+
if (this.options.adminUser?.password)
|
|
79
|
+
process.env.ADMIN_PASSWORD = this.options.adminUser.password;
|
|
80
|
+
if (this.options.adminUser?.username)
|
|
81
|
+
process.env.ADMIN_USERNAME = this.options.adminUser.username;
|
|
72
82
|
if (this.options.workerConcurrency)
|
|
73
83
|
process.env.WORKER_CONCURRENCY = String(this.options.workerConcurrency);
|
|
74
84
|
if (this.options.redisOptions?.maxQueueLength)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import { resolve, extname, join } from "node:path";
|
|
2
|
+
import { resolve, extname, join, relative, isAbsolute } from "node:path";
|
|
3
3
|
import { existsSync, statSync, createReadStream } from "node:fs";
|
|
4
4
|
import { request as httpRequest } from "node:http";
|
|
5
5
|
|
|
@@ -155,7 +155,8 @@ export async function handleAdminRequest(
|
|
|
155
155
|
let filePath = resolve(dashboardDir, subPath);
|
|
156
156
|
|
|
157
157
|
// Security check: ensure filePath is inside dashboardDir
|
|
158
|
-
|
|
158
|
+
const rel = relative(dashboardDir, filePath);
|
|
159
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
159
160
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
160
161
|
res.end("Forbidden");
|
|
161
162
|
return true;
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
createAdminSession,
|
|
18
18
|
revokeAdminSession,
|
|
19
19
|
getAdminSession,
|
|
20
|
+
DUMMY_PASSWORD_HASH,
|
|
20
21
|
} from "@/services/auth/index.js";
|
|
21
22
|
import type { Preferences } from "@/contracts/index.js";
|
|
22
23
|
import {
|
|
@@ -41,7 +42,7 @@ import { readJsonBody, sendJson, sendNoContent, sendValidationError } from "./ht
|
|
|
41
42
|
import type { RouteContext } from "./router.js";
|
|
42
43
|
import { globalEmitter, getPriorityBucket, normaliseTarget } from "@/shared/index.js";
|
|
43
44
|
import { metrics } from "@/metrics/index.js";
|
|
44
|
-
import { eq, desc, and, lt, lte, gte, sql, like, or, isNotNull } from "drizzle-orm";
|
|
45
|
+
import { eq, desc, and, lt, lte, gte, sql, like, or, isNotNull, inArray } from "drizzle-orm";
|
|
45
46
|
import {
|
|
46
47
|
messageLogs,
|
|
47
48
|
workflowDefinitions,
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
suppressions,
|
|
50
51
|
users as dbUsers,
|
|
51
52
|
userTopicPreferences,
|
|
53
|
+
deliveryOutbox,
|
|
52
54
|
} from "@/db/schema.js";
|
|
53
55
|
import { readBaseConfig } from "@/config/index.js";
|
|
54
56
|
import { verifyUnsubscribeToken } from "@/unsubscribe/index.js";
|
|
@@ -1106,12 +1108,14 @@ export function createHandlers(deps: Deps) {
|
|
|
1106
1108
|
};
|
|
1107
1109
|
|
|
1108
1110
|
const streamDepths: Record<string, number> = {};
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1111
|
+
if (ctx.isAdmin !== false) {
|
|
1112
|
+
for (const [key, realRedisKey] of Object.entries(streamMap)) {
|
|
1113
|
+
try {
|
|
1114
|
+
const len = await deps.redis.native.xlen(realRedisKey);
|
|
1115
|
+
streamDepths[key] = len;
|
|
1116
|
+
} catch {
|
|
1117
|
+
streamDepths[key] = 0;
|
|
1118
|
+
}
|
|
1115
1119
|
}
|
|
1116
1120
|
}
|
|
1117
1121
|
|
|
@@ -1144,7 +1148,9 @@ export function createHandlers(deps: Deps) {
|
|
|
1144
1148
|
delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
|
1145
1149
|
}
|
|
1146
1150
|
|
|
1147
|
-
|
|
1151
|
+
// Project-scoped calls compute failure against their own task count;
|
|
1152
|
+
// cluster-wide administrative calls read the global dead letter queue length.
|
|
1153
|
+
const failed = ctx.projectId ? Math.max(0, total - delivered) : streamDepths.DEAD_LETTER || 0;
|
|
1148
1154
|
const successRate = total > 0 ? Number(((delivered / total) * 100).toFixed(2)) : 100;
|
|
1149
1155
|
|
|
1150
1156
|
sendJson(res, 200, {
|
|
@@ -1162,7 +1168,7 @@ export function createHandlers(deps: Deps) {
|
|
|
1162
1168
|
async function getDLQMessages(
|
|
1163
1169
|
_req: IncomingMessage,
|
|
1164
1170
|
res: ServerResponse,
|
|
1165
|
-
|
|
1171
|
+
ctx: RouteContext,
|
|
1166
1172
|
): Promise<void> {
|
|
1167
1173
|
try {
|
|
1168
1174
|
const rawEntries = await deps.redis.native.xrevrange(
|
|
@@ -1170,22 +1176,38 @@ export function createHandlers(deps: Deps) {
|
|
|
1170
1176
|
"+",
|
|
1171
1177
|
"-",
|
|
1172
1178
|
"COUNT",
|
|
1173
|
-
"
|
|
1179
|
+
"100",
|
|
1174
1180
|
);
|
|
1175
|
-
const
|
|
1181
|
+
const allMessages = (rawEntries || []).map(([id, fields]: [string, string[]]) => {
|
|
1176
1182
|
const fieldMap: Record<string, string> = {};
|
|
1177
1183
|
for (let i = 0; i < fields.length; i += 2) {
|
|
1178
1184
|
fieldMap[fields[i]!] = fields[i + 1]!;
|
|
1179
1185
|
}
|
|
1186
|
+
let payload: any = fieldMap;
|
|
1187
|
+
if (fieldMap.payload) {
|
|
1188
|
+
try {
|
|
1189
|
+
payload = JSON.parse(fieldMap.payload);
|
|
1190
|
+
} catch {
|
|
1191
|
+
payload = fieldMap.payload;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1180
1194
|
return {
|
|
1181
1195
|
id,
|
|
1182
1196
|
eventType: fieldMap.eventType || fieldMap.event_type || "unknown",
|
|
1183
|
-
payload
|
|
1197
|
+
payload,
|
|
1184
1198
|
error: fieldMap.error || fieldMap.reason || "Dead letter payload",
|
|
1185
1199
|
timestamp: fieldMap.timestamp || new Date().toISOString(),
|
|
1186
1200
|
};
|
|
1187
1201
|
});
|
|
1188
|
-
|
|
1202
|
+
|
|
1203
|
+
const messages = ctx.projectId
|
|
1204
|
+
? allMessages.filter((m) => {
|
|
1205
|
+
const pId = m.payload?.projectId ?? m.payload?.project_id ?? (m as any).projectId;
|
|
1206
|
+
return pId === ctx.projectId;
|
|
1207
|
+
})
|
|
1208
|
+
: allMessages;
|
|
1209
|
+
|
|
1210
|
+
sendJson(res, 200, { messages: messages.slice(0, 50) });
|
|
1189
1211
|
} catch {
|
|
1190
1212
|
sendJson(res, 200, { messages: [] });
|
|
1191
1213
|
}
|
|
@@ -1195,7 +1217,7 @@ export function createHandlers(deps: Deps) {
|
|
|
1195
1217
|
async function replayDLQMessage(
|
|
1196
1218
|
req: IncomingMessage,
|
|
1197
1219
|
res: ServerResponse,
|
|
1198
|
-
|
|
1220
|
+
ctx: RouteContext,
|
|
1199
1221
|
): Promise<void> {
|
|
1200
1222
|
const body = (await readJsonBody(req).catch(() => ({}))) as any;
|
|
1201
1223
|
const messageId = body?.id;
|
|
@@ -1218,6 +1240,21 @@ export function createHandlers(deps: Deps) {
|
|
|
1218
1240
|
fieldMap[fields[i]!] = fields[i + 1]!;
|
|
1219
1241
|
}
|
|
1220
1242
|
|
|
1243
|
+
let payload: any = null;
|
|
1244
|
+
if (fieldMap.payload) {
|
|
1245
|
+
try {
|
|
1246
|
+
payload = JSON.parse(fieldMap.payload);
|
|
1247
|
+
} catch {}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (ctx.projectId) {
|
|
1251
|
+
const pId = payload?.projectId ?? payload?.project_id ?? (fieldMap as any).projectId;
|
|
1252
|
+
if (pId !== ctx.projectId) {
|
|
1253
|
+
sendJson(res, 404, { error: "dlq_message_not_found" });
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1221
1258
|
const priority = fieldMap.priority || "normal";
|
|
1222
1259
|
const p = getPriorityBucket(priority);
|
|
1223
1260
|
const targetStream =
|
|
@@ -1246,6 +1283,34 @@ export function createHandlers(deps: Deps) {
|
|
|
1246
1283
|
if (!messageId) return sendJson(res, 400, { error: "missing_id" });
|
|
1247
1284
|
|
|
1248
1285
|
try {
|
|
1286
|
+
if (ctx.projectId) {
|
|
1287
|
+
const rawEntries = await deps.redis.native.xrange(
|
|
1288
|
+
STREAMS.DEAD_LETTER,
|
|
1289
|
+
messageId,
|
|
1290
|
+
messageId,
|
|
1291
|
+
);
|
|
1292
|
+
if (!rawEntries || rawEntries.length === 0 || !rawEntries[0]) {
|
|
1293
|
+
sendJson(res, 404, { error: "dlq_message_not_found" });
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const fields = rawEntries[0][1];
|
|
1297
|
+
const fieldMap: Record<string, string> = {};
|
|
1298
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
1299
|
+
fieldMap[fields[i]!] = fields[i + 1]!;
|
|
1300
|
+
}
|
|
1301
|
+
let payload: any = null;
|
|
1302
|
+
if (fieldMap.payload) {
|
|
1303
|
+
try {
|
|
1304
|
+
payload = JSON.parse(fieldMap.payload);
|
|
1305
|
+
} catch {}
|
|
1306
|
+
}
|
|
1307
|
+
const pId = payload?.projectId ?? payload?.project_id ?? (fieldMap as any).projectId;
|
|
1308
|
+
if (pId !== ctx.projectId) {
|
|
1309
|
+
sendJson(res, 404, { error: "dlq_message_not_found" });
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1249
1314
|
await deps.redis.native.xdel(STREAMS.DEAD_LETTER, messageId);
|
|
1250
1315
|
sendJson(res, 200, { success: true });
|
|
1251
1316
|
} catch (err: any) {
|
|
@@ -1298,12 +1363,25 @@ export function createHandlers(deps: Deps) {
|
|
|
1298
1363
|
if (!user) return sendJson(res, 404, { error: "user_not_found", id: userId });
|
|
1299
1364
|
|
|
1300
1365
|
const contacts = await deps.contactRepo.findByUserId(ctx.projectId!, userId);
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1366
|
+
const contactTargets = contacts.map((c) => c.target);
|
|
1367
|
+
let logs: any[] = [];
|
|
1368
|
+
if (contactTargets.length > 0) {
|
|
1369
|
+
const outboxRows = await deps.db
|
|
1370
|
+
.select({ taskId: deliveryOutbox.taskId })
|
|
1371
|
+
.from(deliveryOutbox)
|
|
1372
|
+
.where(inArray(deliveryOutbox.destination, contactTargets));
|
|
1373
|
+
const taskIds = Array.from(new Set(outboxRows.map((r: any) => r.taskId)));
|
|
1374
|
+
if (taskIds.length > 0) {
|
|
1375
|
+
logs = await deps.db
|
|
1376
|
+
.select()
|
|
1377
|
+
.from(messageLogs)
|
|
1378
|
+
.where(
|
|
1379
|
+
and(eq(messageLogs.projectId, ctx.projectId!), inArray(messageLogs.taskId, taskIds)),
|
|
1380
|
+
)
|
|
1381
|
+
.orderBy(desc(messageLogs.timestamp))
|
|
1382
|
+
.limit(50);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1307
1385
|
|
|
1308
1386
|
sendJson(res, 200, { ...user, contacts, logs });
|
|
1309
1387
|
}
|
|
@@ -1764,7 +1842,7 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
|
|
|
1764
1842
|
and(
|
|
1765
1843
|
eq(suppressions.projectId, ctx.projectId!),
|
|
1766
1844
|
eq(suppressions.channel, channel as any),
|
|
1767
|
-
eq(suppressions.target, normaliseTarget(
|
|
1845
|
+
eq(suppressions.target, normaliseTarget(target)),
|
|
1768
1846
|
),
|
|
1769
1847
|
);
|
|
1770
1848
|
|
|
@@ -1796,13 +1874,9 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
|
|
|
1796
1874
|
|
|
1797
1875
|
const { identifier, password } = parsed.data;
|
|
1798
1876
|
const user = await deps.adminUserRepo.findByEmailOrUsername(identifier);
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
const isValid = await verifyPassword(password, user.passwordHash);
|
|
1805
|
-
if (!isValid) {
|
|
1877
|
+
const targetHash = user ? user.passwordHash : DUMMY_PASSWORD_HASH;
|
|
1878
|
+
const isValid = await verifyPassword(password, targetHash);
|
|
1879
|
+
if (!user || !isValid) {
|
|
1806
1880
|
sendJson(res, 401, { error: "unauthorized", message: "Invalid credentials" });
|
|
1807
1881
|
return;
|
|
1808
1882
|
}
|
package/src/services/api/main.ts
CHANGED
|
@@ -61,6 +61,20 @@ export function extractAuthToken(req: Pick<IncomingMessage, "headers">): string
|
|
|
61
61
|
return undefined;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Resolves client IP address, respecting reverse proxy headers only when TRUST_PROXY is enabled.
|
|
66
|
+
*/
|
|
67
|
+
export function getClientIp(req: IncomingMessage, trustProxy: boolean): string {
|
|
68
|
+
if (trustProxy) {
|
|
69
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
70
|
+
if (typeof forwarded === "string") {
|
|
71
|
+
const first = forwarded.split(",")[0]?.trim();
|
|
72
|
+
if (first) return first;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return req.socket.remoteAddress || "unknown";
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
async function handleCreateProject(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
65
79
|
const parsed = z.object({ name: z.string().min(1) }).safeParse(await readJsonBody(req));
|
|
66
80
|
if (!parsed.success) {
|
|
@@ -130,7 +144,42 @@ async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise
|
|
|
130
144
|
sendJson(res, statusCode, response);
|
|
131
145
|
}
|
|
132
146
|
|
|
133
|
-
async function handleMetrics(
|
|
147
|
+
async function handleMetrics(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
148
|
+
const remoteIp = req.socket.remoteAddress ?? "";
|
|
149
|
+
const isLoopback =
|
|
150
|
+
remoteIp === "127.0.0.1" ||
|
|
151
|
+
remoteIp === "::1" ||
|
|
152
|
+
remoteIp === "::ffff:127.0.0.1" ||
|
|
153
|
+
remoteIp === "localhost";
|
|
154
|
+
|
|
155
|
+
let authorized = isLoopback;
|
|
156
|
+
if (!authorized) {
|
|
157
|
+
const token = extractAuthToken(req);
|
|
158
|
+
if (token) {
|
|
159
|
+
if (token.startsWith("nk_sess_") && redis?.native) {
|
|
160
|
+
const session = await getAdminSession(redis.native, token);
|
|
161
|
+
if (session) authorized = true;
|
|
162
|
+
} else if (config.ADMIN_API_KEY) {
|
|
163
|
+
const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
|
|
164
|
+
const providedBuffer = Buffer.from(token);
|
|
165
|
+
if (
|
|
166
|
+
expectedBuffer.length === providedBuffer.length &&
|
|
167
|
+
timingSafeEqual(expectedBuffer, providedBuffer)
|
|
168
|
+
) {
|
|
169
|
+
authorized = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!authorized) {
|
|
176
|
+
sendJson(res, 401, {
|
|
177
|
+
error: "unauthorized",
|
|
178
|
+
message: "Metrics endpoint requires admin credentials",
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
134
183
|
const registry = getMetricsRegistry();
|
|
135
184
|
res.writeHead(200, { "Content-Type": registry.contentType });
|
|
136
185
|
res.end(await registry.metrics());
|
|
@@ -167,6 +216,15 @@ let server: ReturnType<typeof createServer>;
|
|
|
167
216
|
|
|
168
217
|
export async function startApiServer() {
|
|
169
218
|
logger = createLogger({ name: "api", level: config.LOG_LEVEL });
|
|
219
|
+
|
|
220
|
+
if (config.NODE_ENV === "production" && !config.UNSUBSCRIBE_SECRET) {
|
|
221
|
+
logger.fatal(
|
|
222
|
+
"UNSUBSCRIBE_SECRET is required in production. Without it, all unsubscribe links " +
|
|
223
|
+
"return 400 and opt-outs are silently dropped. Set a random string of 32+ characters and restart.",
|
|
224
|
+
);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
170
228
|
redis = new RedisClient({ url: config.REDIS_URL, name: "api", logger });
|
|
171
229
|
const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: "api", logger });
|
|
172
230
|
sql = dbData.sql;
|
|
@@ -280,8 +338,28 @@ export async function startApiServer() {
|
|
|
280
338
|
server.keepAliveTimeout = 5_000;
|
|
281
339
|
|
|
282
340
|
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
283
|
-
|
|
284
|
-
|
|
341
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
342
|
+
const origin = req.headers["origin"] as string | undefined;
|
|
343
|
+
const allowedOrigins = (config.CORS_ORIGIN ?? "")
|
|
344
|
+
.split(",")
|
|
345
|
+
.map((s) => s.trim())
|
|
346
|
+
.filter(Boolean);
|
|
347
|
+
const isSessionRoute =
|
|
348
|
+
url.pathname.startsWith("/v1/auth/") ||
|
|
349
|
+
url.pathname === "/v1/projects" ||
|
|
350
|
+
url.pathname.startsWith("/v1/projects/") ||
|
|
351
|
+
url.pathname.startsWith("/v1/system/") ||
|
|
352
|
+
url.pathname.startsWith("/v1/dlq");
|
|
353
|
+
|
|
354
|
+
if (isSessionRoute) {
|
|
355
|
+
if (origin && allowedOrigins.includes(origin)) {
|
|
356
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
357
|
+
res.setHeader("Vary", "Origin");
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
361
|
+
}
|
|
362
|
+
|
|
285
363
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
286
364
|
res.setHeader(
|
|
287
365
|
"Access-Control-Allow-Headers",
|
|
@@ -294,8 +372,6 @@ export async function startApiServer() {
|
|
|
294
372
|
return;
|
|
295
373
|
}
|
|
296
374
|
|
|
297
|
-
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
298
|
-
|
|
299
375
|
if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) {
|
|
300
376
|
const handled = await handleAdminRequest(req, res, url);
|
|
301
377
|
if (handled) return;
|
|
@@ -304,6 +380,7 @@ export async function startApiServer() {
|
|
|
304
380
|
let projectId: string | undefined = undefined;
|
|
305
381
|
let projectRateLimitRpm = 600;
|
|
306
382
|
let keyRole: "admin" | "read_only" = "admin";
|
|
383
|
+
let isAdminToken = false;
|
|
307
384
|
|
|
308
385
|
const isProjectManagement =
|
|
309
386
|
url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/");
|
|
@@ -330,7 +407,6 @@ export async function startApiServer() {
|
|
|
330
407
|
return;
|
|
331
408
|
}
|
|
332
409
|
|
|
333
|
-
let isAdminToken = false;
|
|
334
410
|
if (token.startsWith("nk_sess_")) {
|
|
335
411
|
const session = await getAdminSession(redis.native, token);
|
|
336
412
|
if (session) {
|
|
@@ -472,6 +548,7 @@ export async function startApiServer() {
|
|
|
472
548
|
sendJson(res, 401, { error: "unauthorized", message: "Invalid admin token" });
|
|
473
549
|
return;
|
|
474
550
|
}
|
|
551
|
+
isAdminToken = true;
|
|
475
552
|
}
|
|
476
553
|
|
|
477
554
|
// The unsubscribe routes skip the block above, and with it the per-project
|
|
@@ -479,10 +556,7 @@ export async function startApiServer() {
|
|
|
479
556
|
// an open endpoint cannot be used to hammer the process. Generous, because
|
|
480
557
|
// a shared corporate egress IP can legitimately produce a burst.
|
|
481
558
|
if (isPublicUnsubscribe) {
|
|
482
|
-
const clientIp =
|
|
483
|
-
(req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ||
|
|
484
|
-
req.socket.remoteAddress ||
|
|
485
|
-
"unknown";
|
|
559
|
+
const clientIp = getClientIp(req, Boolean(config.TRUST_PROXY));
|
|
486
560
|
try {
|
|
487
561
|
const key = `rate-limit:api:unsub:${clientIp}`;
|
|
488
562
|
const count = await redis.native.incr(key);
|
|
@@ -499,6 +573,27 @@ export async function startApiServer() {
|
|
|
499
573
|
}
|
|
500
574
|
}
|
|
501
575
|
|
|
576
|
+
// The login route is unauthenticated and CPU-intensive due to password hashing (scrypt).
|
|
577
|
+
// Throttle login attempts per IP to prevent credential brute-forcing and CPU exhaustion.
|
|
578
|
+
if (isPublicAuth) {
|
|
579
|
+
const clientIp = getClientIp(req, Boolean(config.TRUST_PROXY));
|
|
580
|
+
try {
|
|
581
|
+
const key = `rate-limit:api:auth:${clientIp}`;
|
|
582
|
+
const count = await redis.native.incr(key);
|
|
583
|
+
if (count === 1) await redis.native.expire(key, 60);
|
|
584
|
+
if (count > 10) {
|
|
585
|
+
res.setHeader("Retry-After", "60");
|
|
586
|
+
sendJson(res, 429, {
|
|
587
|
+
error: "too_many_requests",
|
|
588
|
+
message: "Too many login attempts. Please try again later.",
|
|
589
|
+
});
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
} catch (err) {
|
|
593
|
+
logger.warn({ err }, "auth rate limit unavailable — allowing request");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
502
597
|
const route = router.match(req.method ?? "GET", url.pathname);
|
|
503
598
|
|
|
504
599
|
if (!route) {
|
|
@@ -506,7 +601,13 @@ export async function startApiServer() {
|
|
|
506
601
|
return;
|
|
507
602
|
}
|
|
508
603
|
|
|
509
|
-
const ctx = {
|
|
604
|
+
const ctx = {
|
|
605
|
+
params: route.params,
|
|
606
|
+
query: url.searchParams,
|
|
607
|
+
projectId,
|
|
608
|
+
role: keyRole,
|
|
609
|
+
isAdmin: isAdminToken,
|
|
610
|
+
};
|
|
510
611
|
|
|
511
612
|
void Promise.resolve(route.handler(req, res, ctx)).catch((err: unknown) => {
|
|
512
613
|
if (err instanceof HttpError) {
|
|
@@ -5,6 +5,7 @@ export interface RouteContext {
|
|
|
5
5
|
query: URLSearchParams;
|
|
6
6
|
projectId?: string;
|
|
7
7
|
role?: "admin" | "read_only";
|
|
8
|
+
isAdmin?: boolean;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export type RouteHandler = (
|
|
@@ -65,7 +66,12 @@ export class Router {
|
|
|
65
66
|
const seg = route.segments[i]!;
|
|
66
67
|
const part = parts[i]!;
|
|
67
68
|
if (seg.startsWith(":")) {
|
|
68
|
-
|
|
69
|
+
try {
|
|
70
|
+
params[seg.slice(1)] = decodeURIComponent(part);
|
|
71
|
+
} catch {
|
|
72
|
+
ok = false;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
69
75
|
} else if (seg !== part) {
|
|
70
76
|
ok = false;
|
|
71
77
|
break;
|
|
@@ -8,6 +8,14 @@ const scryptAsync = promisify(scrypt);
|
|
|
8
8
|
const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
|
|
9
9
|
const SESSION_PREFIX = "notif:session:";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Pre-computed dummy hash conforming to salt(16 bytes hex):hash(64 bytes hex).
|
|
13
|
+
* Used during authentication failure to equalize execution time and prevent
|
|
14
|
+
* user enumeration via scrypt timing side-channels.
|
|
15
|
+
*/
|
|
16
|
+
export const DUMMY_PASSWORD_HASH =
|
|
17
|
+
"00000000000000000000000000000000:00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
|
|
18
|
+
|
|
11
19
|
export interface AdminSession {
|
|
12
20
|
token: string;
|
|
13
21
|
adminId: string;
|