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
|
@@ -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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
1179
|
+
"100",
|
|
1150
1180
|
);
|
|
1151
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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(
|
|
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,
|