notifkit 0.1.8 → 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.
Files changed (30) hide show
  1. package/dist/index.d.mts +17 -4
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +1 -1
  4. package/dist/{main-BjXgGJBd.mjs → main-4Wyge9ms.mjs} +2 -2
  5. package/dist/{main-BjXgGJBd.mjs.map → main-4Wyge9ms.mjs.map} +1 -1
  6. package/dist/{main-B0BcY_JJ.mjs → main-AO0JaY5z.mjs} +2 -2
  7. package/dist/{main-B0BcY_JJ.mjs.map → main-AO0JaY5z.mjs.map} +1 -1
  8. package/dist/{main-zCpvA9Rx.mjs → main-BMYSYcv2.mjs} +2 -2
  9. package/dist/{main-zCpvA9Rx.mjs.map → main-BMYSYcv2.mjs.map} +1 -1
  10. package/dist/{main-BxqAXTr6.mjs → main-BPbPnBIG.mjs} +2 -2
  11. package/dist/{main-BxqAXTr6.mjs.map → main-BPbPnBIG.mjs.map} +1 -1
  12. package/dist/{main-7KMlWnDx.mjs → main-CyeQ_yDt.mjs} +2 -2
  13. package/dist/{main-7KMlWnDx.mjs.map → main-CyeQ_yDt.mjs.map} +1 -1
  14. package/dist/{main-BtXITy0k.mjs → main-D19Wxs0x.mjs} +2 -2
  15. package/dist/{main-BtXITy0k.mjs.map → main-D19Wxs0x.mjs.map} +1 -1
  16. package/dist/{main-0FHNtN5g.mjs → main-DSmh1e-V.mjs} +2 -2
  17. package/dist/{main-0FHNtN5g.mjs.map → main-DSmh1e-V.mjs.map} +1 -1
  18. package/dist/{main-BAO4kKce.mjs → main-JdOvJ70b.mjs} +142 -28
  19. package/dist/main-JdOvJ70b.mjs.map +1 -0
  20. package/dist/{src-DkvHYM3y.mjs → src-CGmXRfsM.mjs} +27 -18
  21. package/dist/src-CGmXRfsM.mjs.map +1 -0
  22. package/package.json +1 -1
  23. package/src/config/index.ts +9 -0
  24. package/src/services/api/admin-static.ts +3 -2
  25. package/src/services/api/handlers.ts +102 -28
  26. package/src/services/api/main.ts +112 -11
  27. package/src/services/api/router.ts +7 -1
  28. package/src/services/auth/index.ts +8 -0
  29. package/dist/main-BAO4kKce.mjs.map +0 -1
  30. 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.8",
3
+ "version": "0.1.9",
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",
@@ -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>;
@@ -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
- if (!filePath.startsWith(dashboardDir)) {
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
- for (const [key, realRedisKey] of Object.entries(streamMap)) {
1110
- try {
1111
- const len = await deps.redis.native.xlen(realRedisKey);
1112
- streamDepths[key] = len;
1113
- } catch {
1114
- streamDepths[key] = 0;
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
- const failed = streamDepths.DEAD_LETTER || 0;
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
- _ctx: RouteContext,
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
- "50",
1179
+ "100",
1174
1180
  );
1175
- const messages = (rawEntries || []).map(([id, fields]: [string, string[]]) => {
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: fieldMap.payload ? JSON.parse(fieldMap.payload) : fieldMap,
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
- sendJson(res, 200, { messages });
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
- _ctx: RouteContext,
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 logs = await deps.db
1302
- .select()
1303
- .from(messageLogs)
1304
- .where(eq(messageLogs.projectId, ctx.projectId!))
1305
- .orderBy(desc(messageLogs.timestamp))
1306
- .limit(50);
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(decodeURIComponent(target))),
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
- if (!user) {
1800
- sendJson(res, 401, { error: "unauthorized", message: "Invalid credentials" });
1801
- return;
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
  }
@@ -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(_req: IncomingMessage, res: ServerResponse): Promise<void> {
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
- // Add CORS headers for browser clients (like the Next.js dashboard)
284
- res.setHeader("Access-Control-Allow-Origin", "*");
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 = { params: route.params, query: url.searchParams, projectId, role: keyRole };
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
- params[seg.slice(1)] = decodeURIComponent(part);
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;