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.
Files changed (40) hide show
  1. package/dashboard/README.md +29 -0
  2. package/dashboard/dist/assets/index-7mvXb4bS.js +310 -0
  3. package/dashboard/dist/assets/index-o7L-f3-c.css +1 -0
  4. package/dashboard/dist/favicon.svg +4 -0
  5. package/dashboard/dist/index.html +14 -0
  6. package/dist/index.d.mts +447 -9
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +2 -2
  9. package/dist/{main-CRPxM-O3.mjs → main-4Wyge9ms.mjs} +2 -2
  10. package/dist/{main-CRPxM-O3.mjs.map → main-4Wyge9ms.mjs.map} +1 -1
  11. package/dist/{main-Bgi2wWck.mjs → main-AO0JaY5z.mjs} +2 -2
  12. package/dist/{main-Bgi2wWck.mjs.map → main-AO0JaY5z.mjs.map} +1 -1
  13. package/dist/{main-DFMHcN_d.mjs → main-BMYSYcv2.mjs} +2 -2
  14. package/dist/{main-DFMHcN_d.mjs.map → main-BMYSYcv2.mjs.map} +1 -1
  15. package/dist/{main-Yorxzw0Z.mjs → main-BPbPnBIG.mjs} +2 -2
  16. package/dist/{main-Yorxzw0Z.mjs.map → main-BPbPnBIG.mjs.map} +1 -1
  17. package/dist/{main-pFAfCkVX.mjs → main-CyeQ_yDt.mjs} +2 -2
  18. package/dist/{main-pFAfCkVX.mjs.map → main-CyeQ_yDt.mjs.map} +1 -1
  19. package/dist/{main-CjbVdUqa.mjs → main-D19Wxs0x.mjs} +2 -2
  20. package/dist/{main-CjbVdUqa.mjs.map → main-D19Wxs0x.mjs.map} +1 -1
  21. package/dist/{main-D6SG3Isk.mjs → main-DSmh1e-V.mjs} +2 -2
  22. package/dist/{main-D6SG3Isk.mjs.map → main-DSmh1e-V.mjs.map} +1 -1
  23. package/dist/{main-CFSukWm8.mjs → main-JdOvJ70b.mjs} +495 -41
  24. package/dist/main-JdOvJ70b.mjs.map +1 -0
  25. package/dist/{src-CPMwsUCJ.mjs → src-CGmXRfsM.mjs} +86 -20
  26. package/dist/src-CGmXRfsM.mjs.map +1 -0
  27. package/drizzle/0005_admin_users.sql +11 -0
  28. package/drizzle/meta/_journal.json +7 -0
  29. package/package.json +8 -3
  30. package/scripts/create-admin.mjs +67 -0
  31. package/src/config/index.ts +12 -0
  32. package/src/db/schema.ts +13 -0
  33. package/src/repositories/index.ts +82 -1
  34. package/src/services/api/admin-static.ts +225 -0
  35. package/src/services/api/handlers.ts +224 -33
  36. package/src/services/api/main.ts +183 -29
  37. package/src/services/api/router.ts +7 -1
  38. package/src/services/auth/index.ts +111 -0
  39. package/dist/main-CFSukWm8.mjs.map +0 -1
  40. package/dist/src-CPMwsUCJ.mjs.map +0 -1
@@ -10,7 +10,15 @@ import type {
10
10
  ProjectRepository,
11
11
  WorkflowRepository,
12
12
  SegmentRepository,
13
+ AdminUserRepository,
13
14
  } from "@/repositories/index.js";
15
+ import {
16
+ verifyPassword,
17
+ createAdminSession,
18
+ revokeAdminSession,
19
+ getAdminSession,
20
+ DUMMY_PASSWORD_HASH,
21
+ } from "@/services/auth/index.js";
14
22
  import type { Preferences } from "@/contracts/index.js";
15
23
  import {
16
24
  AddUserSchema,
@@ -34,7 +42,7 @@ import { readJsonBody, sendJson, sendNoContent, sendValidationError } from "./ht
34
42
  import type { RouteContext } from "./router.js";
35
43
  import { globalEmitter, getPriorityBucket, normaliseTarget } from "@/shared/index.js";
36
44
  import { metrics } from "@/metrics/index.js";
37
- 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";
38
46
  import {
39
47
  messageLogs,
40
48
  workflowDefinitions,
@@ -42,6 +50,7 @@ import {
42
50
  suppressions,
43
51
  users as dbUsers,
44
52
  userTopicPreferences,
53
+ deliveryOutbox,
45
54
  } from "@/db/schema.js";
46
55
  import { readBaseConfig } from "@/config/index.js";
47
56
  import { verifyUnsubscribeToken } from "@/unsubscribe/index.js";
@@ -63,6 +72,7 @@ export interface Deps {
63
72
  projectRepo: ProjectRepository;
64
73
  workflowRepo: WorkflowRepository;
65
74
  segmentRepo: SegmentRepository;
75
+ adminUserRepo?: AdminUserRepository;
66
76
  db: Db;
67
77
  }
68
78
 
@@ -1098,29 +1108,49 @@ export function createHandlers(deps: Deps) {
1098
1108
  };
1099
1109
 
1100
1110
  const streamDepths: Record<string, number> = {};
1101
- for (const [key, realRedisKey] of Object.entries(streamMap)) {
1102
- try {
1103
- const len = await deps.redis.native.xlen(realRedisKey);
1104
- streamDepths[key] = len;
1105
- } catch {
1106
- 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
+ }
1107
1119
  }
1108
1120
  }
1109
1121
 
1110
- // Scoped to the caller's project: an unscoped count reports every tenant's
1111
- // traffic to whoever asks.
1112
- const totalTasksRes = await deps.db
1113
- .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1114
- .from(messageLogs)
1115
- .where(eq(messageLogs.projectId, ctx.projectId!));
1116
- const deliveredTasksRes = await deps.db
1117
- .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1118
- .from(messageLogs)
1119
- .where(and(eq(messageLogs.projectId, ctx.projectId!), eq(messageLogs.status, "delivered")));
1122
+ // Scoped to the caller's project if provided, otherwise aggregate across all projects
1123
+ let total = 0;
1124
+ let delivered = 0;
1125
+
1126
+ if (ctx.projectId) {
1127
+ const totalTasksRes = await deps.db
1128
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1129
+ .from(messageLogs)
1130
+ .where(eq(messageLogs.projectId, ctx.projectId));
1131
+ const deliveredTasksRes = await deps.db
1132
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1133
+ .from(messageLogs)
1134
+ .where(and(eq(messageLogs.projectId, ctx.projectId), eq(messageLogs.status, "delivered")));
1135
+
1136
+ total = Number(totalTasksRes[0]?.count ?? 0);
1137
+ delivered = Number(deliveredTasksRes[0]?.count ?? 0);
1138
+ } else {
1139
+ const totalTasksRes = await deps.db
1140
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1141
+ .from(messageLogs);
1142
+ const deliveredTasksRes = await deps.db
1143
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1144
+ .from(messageLogs)
1145
+ .where(eq(messageLogs.status, "delivered"));
1146
+
1147
+ total = Number(totalTasksRes[0]?.count ?? 0);
1148
+ delivered = Number(deliveredTasksRes[0]?.count ?? 0);
1149
+ }
1120
1150
 
1121
- const total = Number(totalTasksRes[0]?.count ?? 0);
1122
- const delivered = Number(deliveredTasksRes[0]?.count ?? 0);
1123
- 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;
1124
1154
  const successRate = total > 0 ? Number(((delivered / total) * 100).toFixed(2)) : 100;
1125
1155
 
1126
1156
  sendJson(res, 200, {
@@ -1138,7 +1168,7 @@ export function createHandlers(deps: Deps) {
1138
1168
  async function getDLQMessages(
1139
1169
  _req: IncomingMessage,
1140
1170
  res: ServerResponse,
1141
- _ctx: RouteContext,
1171
+ ctx: RouteContext,
1142
1172
  ): Promise<void> {
1143
1173
  try {
1144
1174
  const rawEntries = await deps.redis.native.xrevrange(
@@ -1146,22 +1176,38 @@ export function createHandlers(deps: Deps) {
1146
1176
  "+",
1147
1177
  "-",
1148
1178
  "COUNT",
1149
- "50",
1179
+ "100",
1150
1180
  );
1151
- const messages = (rawEntries || []).map(([id, fields]: [string, string[]]) => {
1181
+ const allMessages = (rawEntries || []).map(([id, fields]: [string, string[]]) => {
1152
1182
  const fieldMap: Record<string, string> = {};
1153
1183
  for (let i = 0; i < fields.length; i += 2) {
1154
1184
  fieldMap[fields[i]!] = fields[i + 1]!;
1155
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
+ }
1156
1194
  return {
1157
1195
  id,
1158
1196
  eventType: fieldMap.eventType || fieldMap.event_type || "unknown",
1159
- payload: fieldMap.payload ? JSON.parse(fieldMap.payload) : fieldMap,
1197
+ payload,
1160
1198
  error: fieldMap.error || fieldMap.reason || "Dead letter payload",
1161
1199
  timestamp: fieldMap.timestamp || new Date().toISOString(),
1162
1200
  };
1163
1201
  });
1164
- 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) });
1165
1211
  } catch {
1166
1212
  sendJson(res, 200, { messages: [] });
1167
1213
  }
@@ -1171,7 +1217,7 @@ export function createHandlers(deps: Deps) {
1171
1217
  async function replayDLQMessage(
1172
1218
  req: IncomingMessage,
1173
1219
  res: ServerResponse,
1174
- _ctx: RouteContext,
1220
+ ctx: RouteContext,
1175
1221
  ): Promise<void> {
1176
1222
  const body = (await readJsonBody(req).catch(() => ({}))) as any;
1177
1223
  const messageId = body?.id;
@@ -1194,6 +1240,21 @@ export function createHandlers(deps: Deps) {
1194
1240
  fieldMap[fields[i]!] = fields[i + 1]!;
1195
1241
  }
1196
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
+
1197
1258
  const priority = fieldMap.priority || "normal";
1198
1259
  const p = getPriorityBucket(priority);
1199
1260
  const targetStream =
@@ -1222,6 +1283,34 @@ export function createHandlers(deps: Deps) {
1222
1283
  if (!messageId) return sendJson(res, 400, { error: "missing_id" });
1223
1284
 
1224
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
+
1225
1314
  await deps.redis.native.xdel(STREAMS.DEAD_LETTER, messageId);
1226
1315
  sendJson(res, 200, { success: true });
1227
1316
  } catch (err: any) {
@@ -1274,12 +1363,25 @@ export function createHandlers(deps: Deps) {
1274
1363
  if (!user) return sendJson(res, 404, { error: "user_not_found", id: userId });
1275
1364
 
1276
1365
  const contacts = await deps.contactRepo.findByUserId(ctx.projectId!, userId);
1277
- const logs = await deps.db
1278
- .select()
1279
- .from(messageLogs)
1280
- .where(eq(messageLogs.projectId, ctx.projectId!))
1281
- .orderBy(desc(messageLogs.timestamp))
1282
- .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
+ }
1283
1385
 
1284
1386
  sendJson(res, 200, { ...user, contacts, logs });
1285
1387
  }
@@ -1740,14 +1842,103 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
1740
1842
  and(
1741
1843
  eq(suppressions.projectId, ctx.projectId!),
1742
1844
  eq(suppressions.channel, channel as any),
1743
- eq(suppressions.target, normaliseTarget(decodeURIComponent(target))),
1845
+ eq(suppressions.target, normaliseTarget(target)),
1744
1846
  ),
1745
1847
  );
1746
1848
 
1747
1849
  sendNoContent(res);
1748
1850
  }
1749
1851
 
1852
+ // ── POST /v1/auth/login — login ───────────────────────────────────────────
1853
+ async function login(req: IncomingMessage, res: ServerResponse): Promise<void> {
1854
+ if (!deps.adminUserRepo) {
1855
+ sendJson(res, 500, {
1856
+ error: "internal_error",
1857
+ message: "Admin user repository not initialized",
1858
+ });
1859
+ return;
1860
+ }
1861
+
1862
+ const body = await readJsonBody(req);
1863
+ const parsed = z
1864
+ .object({
1865
+ identifier: z.string().min(1, "Identifier (email or username) is required"),
1866
+ password: z.string().min(1, "Password is required"),
1867
+ })
1868
+ .safeParse(body);
1869
+
1870
+ if (!parsed.success) {
1871
+ sendValidationError(res, parsed.error);
1872
+ return;
1873
+ }
1874
+
1875
+ const { identifier, password } = parsed.data;
1876
+ const user = await deps.adminUserRepo.findByEmailOrUsername(identifier);
1877
+ const targetHash = user ? user.passwordHash : DUMMY_PASSWORD_HASH;
1878
+ const isValid = await verifyPassword(password, targetHash);
1879
+ if (!user || !isValid) {
1880
+ sendJson(res, 401, { error: "unauthorized", message: "Invalid credentials" });
1881
+ return;
1882
+ }
1883
+
1884
+ const session = await createAdminSession(deps.redis.native, user);
1885
+ sendJson(res, 200, {
1886
+ token: session.token,
1887
+ user: {
1888
+ id: user.id,
1889
+ email: user.email,
1890
+ username: user.username,
1891
+ role: user.role,
1892
+ },
1893
+ expiresAt: session.expiresAt,
1894
+ });
1895
+ }
1896
+
1897
+ // ── POST /v1/auth/logout — logout ─────────────────────────────────────────
1898
+ async function logout(req: IncomingMessage, res: ServerResponse): Promise<void> {
1899
+ const authHeader = req.headers["authorization"];
1900
+ let token: string | undefined;
1901
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) {
1902
+ token = authHeader.slice(7).trim();
1903
+ }
1904
+ if (token) {
1905
+ await revokeAdminSession(deps.redis.native, token);
1906
+ }
1907
+ sendJson(res, 200, { message: "Logged out successfully" });
1908
+ }
1909
+
1910
+ // ── GET /v1/auth/me — getMe ───────────────────────────────────────────────
1911
+ async function getMe(req: IncomingMessage, res: ServerResponse): Promise<void> {
1912
+ const authHeader = req.headers["authorization"];
1913
+ let token: string | undefined;
1914
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) {
1915
+ token = authHeader.slice(7).trim();
1916
+ }
1917
+ if (!token) {
1918
+ sendJson(res, 401, { error: "unauthorized", message: "Missing token" });
1919
+ return;
1920
+ }
1921
+
1922
+ const session = await getAdminSession(deps.redis.native, token);
1923
+ if (!session) {
1924
+ sendJson(res, 401, { error: "unauthorized", message: "Invalid or expired session" });
1925
+ return;
1926
+ }
1927
+
1928
+ sendJson(res, 200, {
1929
+ user: {
1930
+ id: session.adminId,
1931
+ email: session.email,
1932
+ username: session.username,
1933
+ role: session.role,
1934
+ },
1935
+ });
1936
+ }
1937
+
1750
1938
  return {
1939
+ login,
1940
+ logout,
1941
+ getMe,
1751
1942
  syncTemplates,
1752
1943
  addUser,
1753
1944
  updateUser,