notifkit 0.1.6 → 0.1.8
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 +5 -17
- 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 +3435 -548
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{main-DDbquL89.mjs → main-0FHNtN5g.mjs} +2 -2
- package/dist/{main-DDbquL89.mjs.map → main-0FHNtN5g.mjs.map} +1 -1
- package/dist/{main-DdLzptZE.mjs → main-7KMlWnDx.mjs} +2 -2
- package/dist/{main-DdLzptZE.mjs.map → main-7KMlWnDx.mjs.map} +1 -1
- package/dist/{main-DsDkyBZi.mjs → main-B0BcY_JJ.mjs} +2 -2
- package/dist/{main-DsDkyBZi.mjs.map → main-B0BcY_JJ.mjs.map} +1 -1
- package/dist/{main-Db2f2-sW.mjs → main-BAO4kKce.mjs} +437 -63
- package/dist/main-BAO4kKce.mjs.map +1 -0
- package/dist/{main-DfrrNKp9.mjs → main-BjXgGJBd.mjs} +2 -2
- package/dist/{main-DfrrNKp9.mjs.map → main-BjXgGJBd.mjs.map} +1 -1
- package/dist/{main-BnIX5nfp.mjs → main-BtXITy0k.mjs} +2 -2
- package/dist/{main-BnIX5nfp.mjs.map → main-BtXITy0k.mjs.map} +1 -1
- package/dist/{main-CWGEKpLU.mjs → main-BxqAXTr6.mjs} +2 -2
- package/dist/{main-CWGEKpLU.mjs.map → main-BxqAXTr6.mjs.map} +1 -1
- package/dist/{main-Cwu4iQXc.mjs → main-zCpvA9Rx.mjs} +2 -2
- package/dist/{main-Cwu4iQXc.mjs.map → main-zCpvA9Rx.mjs.map} +1 -1
- package/dist/{src-CpUnBl_M.mjs → src-DkvHYM3y.mjs} +127 -41
- package/dist/src-DkvHYM3y.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/client.ts +111 -40
- package/src/config/index.ts +3 -0
- package/src/contracts/sdk.ts +143 -3
- package/src/db/schema.ts +13 -0
- package/src/repositories/index.ts +82 -1
- package/src/services/api/admin-static.ts +224 -0
- package/src/services/api/handlers.ts +223 -42
- package/src/services/api/main.ts +72 -19
- package/src/services/auth/index.ts +103 -0
- package/dist/main-Db2f2-sW.mjs.map +0 -1
- package/dist/src-CpUnBl_M.mjs.map +0 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { $
|
|
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-DkvHYM3y.mjs";
|
|
2
|
+
import { extname, join, resolve } from "node:path";
|
|
2
3
|
import { z } from "zod";
|
|
3
|
-
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
|
|
4
5
|
import { and, desc, eq, gte, inArray, isNotNull, like, lt, lte, or, sql } from "drizzle-orm";
|
|
5
|
-
import { createServer } from "node:http";
|
|
6
|
+
import { createServer, request } from "node:http";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
6
9
|
//#region src/services/api/http.ts
|
|
7
10
|
async function readRawBody(req) {
|
|
8
11
|
return await new Promise((resolve, reject) => {
|
|
@@ -122,44 +125,122 @@ function split(path) {
|
|
|
122
125
|
return path.split("/").filter((s) => s.length > 0);
|
|
123
126
|
}
|
|
124
127
|
//#endregion
|
|
128
|
+
//#region src/services/auth/index.ts
|
|
129
|
+
const scryptAsync = promisify(scrypt);
|
|
130
|
+
const DEFAULT_SESSION_TTL_SECONDS = 604800;
|
|
131
|
+
const SESSION_PREFIX = "notif:session:";
|
|
132
|
+
/**
|
|
133
|
+
* Hashes a plaintext password using crypto.scrypt with a random 16-byte salt.
|
|
134
|
+
* Returns salt and hash separated by a colon.
|
|
135
|
+
*/
|
|
136
|
+
async function hashPassword(password) {
|
|
137
|
+
const salt = randomBytes(16).toString("hex");
|
|
138
|
+
return `${salt}:${(await scryptAsync(password, salt, 64)).toString("hex")}`;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Verifies a plaintext password against a stored salt:hash string using constant-time comparison.
|
|
142
|
+
*/
|
|
143
|
+
async function verifyPassword(password, storedHash) {
|
|
144
|
+
const [salt, key] = storedHash.split(":");
|
|
145
|
+
if (!salt || !key) return false;
|
|
146
|
+
const keyBuffer = Buffer.from(key, "hex");
|
|
147
|
+
const derivedKey = await scryptAsync(password, salt, keyBuffer.length);
|
|
148
|
+
if (derivedKey.length !== keyBuffer.length) return false;
|
|
149
|
+
return timingSafeEqual(derivedKey, keyBuffer);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Creates a secure session in Redis for an authenticated admin user.
|
|
153
|
+
*/
|
|
154
|
+
async function createAdminSession(redis, user, ttlSeconds = DEFAULT_SESSION_TTL_SECONDS) {
|
|
155
|
+
const token = `nk_sess_${randomBytes(32).toString("hex")}`;
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const session = {
|
|
158
|
+
token,
|
|
159
|
+
adminId: user.id,
|
|
160
|
+
email: user.email,
|
|
161
|
+
username: user.username,
|
|
162
|
+
role: user.role,
|
|
163
|
+
createdAt: now,
|
|
164
|
+
expiresAt: now + ttlSeconds * 1e3
|
|
165
|
+
};
|
|
166
|
+
await redis.set(`${SESSION_PREFIX}${token}`, JSON.stringify(session), "EX", ttlSeconds);
|
|
167
|
+
return session;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Retrieves an admin session from Redis if valid and unexpired.
|
|
171
|
+
*/
|
|
172
|
+
async function getAdminSession(redis, token) {
|
|
173
|
+
if (!token || !token.startsWith("nk_sess_")) return null;
|
|
174
|
+
const raw = await redis.get(`${SESSION_PREFIX}${token}`);
|
|
175
|
+
if (!raw) return null;
|
|
176
|
+
try {
|
|
177
|
+
const session = JSON.parse(raw);
|
|
178
|
+
if (session.expiresAt <= Date.now()) {
|
|
179
|
+
await redis.del(`${SESSION_PREFIX}${token}`);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return session;
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Revokes an active admin session.
|
|
189
|
+
*/
|
|
190
|
+
async function revokeAdminSession(redis, token) {
|
|
191
|
+
if (!token) return;
|
|
192
|
+
await redis.del(`${SESSION_PREFIX}${token}`);
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
125
195
|
//#region src/services/api/handlers.ts
|
|
126
196
|
const IngestEventSchema = z.object({
|
|
127
197
|
name: z.string().min(1),
|
|
128
198
|
properties: z.record(z.string(), z.unknown())
|
|
129
199
|
});
|
|
130
|
-
/** Map an inline user's
|
|
200
|
+
/** Map an inline or user input's contacts and legacy arrays into resolved contact records. */
|
|
131
201
|
function contactsOf(user) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
202
|
+
const list = [];
|
|
203
|
+
if (user.contacts && Array.isArray(user.contacts)) for (const c of user.contacts) list.push({
|
|
204
|
+
channel: c.channel,
|
|
205
|
+
target: c.target,
|
|
206
|
+
label: c.label,
|
|
207
|
+
isPrimary: c.isPrimary,
|
|
208
|
+
enabled: c.enabled,
|
|
209
|
+
preferences: c.preferences
|
|
210
|
+
});
|
|
211
|
+
if (user.email) for (const target of user.email) list.push({
|
|
212
|
+
channel: "email",
|
|
213
|
+
target
|
|
214
|
+
});
|
|
215
|
+
if (user.phone) for (const target of user.phone) list.push({
|
|
216
|
+
channel: "sms",
|
|
217
|
+
target
|
|
218
|
+
});
|
|
219
|
+
if (user.pushToken) for (const target of user.pushToken) list.push({
|
|
220
|
+
channel: "push",
|
|
221
|
+
target
|
|
222
|
+
});
|
|
223
|
+
return list;
|
|
146
224
|
}
|
|
147
225
|
/** Persist user records + their contacts in bulk. Shared by addUser and inline notify. */
|
|
148
226
|
async function persistUsers(deps, users, projectId) {
|
|
149
|
-
const usersList = users.map((u) =>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
227
|
+
const usersList = users.map((u) => {
|
|
228
|
+
const primaryEmail = u.email?.[0] ?? u.contacts?.find((c) => c.channel === "email")?.target ?? null;
|
|
229
|
+
return {
|
|
230
|
+
userId: u.id,
|
|
231
|
+
language: u.language ?? "en",
|
|
232
|
+
timezone: u.timezone ?? "UTC",
|
|
233
|
+
email: primaryEmail,
|
|
234
|
+
segments: u.segments ?? [],
|
|
235
|
+
preferences: u.preferences ?? {}
|
|
236
|
+
};
|
|
237
|
+
});
|
|
157
238
|
const contactsList = [];
|
|
158
239
|
for (const u of users) for (const c of contactsOf(u)) contactsList.push({
|
|
159
240
|
userId: u.id,
|
|
160
241
|
channel: c.channel,
|
|
161
242
|
target: c.target,
|
|
162
|
-
preferences: {}
|
|
243
|
+
preferences: c.preferences ?? {}
|
|
163
244
|
});
|
|
164
245
|
await deps.db.transaction(async (tx) => {
|
|
165
246
|
const txUserRepo = deps.userRepo.constructor.name === "Object" ? deps.userRepo : new deps.userRepo.constructor(tx);
|
|
@@ -204,7 +285,7 @@ function createHandlers(deps) {
|
|
|
204
285
|
if (!await deps.userRepo.updatePartial(ctx.projectId, userId, {
|
|
205
286
|
language: patch.language,
|
|
206
287
|
timezone: patch.timezone,
|
|
207
|
-
email: patch.email?.[0],
|
|
288
|
+
email: patch.email?.[0] ?? patch.contacts?.find((c) => c.channel === "email")?.target,
|
|
208
289
|
segments: patch.segments,
|
|
209
290
|
preferences: patch.preferences
|
|
210
291
|
})) return sendJson(res, 404, {
|
|
@@ -214,7 +295,8 @@ function createHandlers(deps) {
|
|
|
214
295
|
for (const c of contactsOf({
|
|
215
296
|
id: userId,
|
|
216
297
|
...patch
|
|
217
|
-
})) await deps.contactRepo.upsert(ctx.projectId, userId, c.channel, c.target);
|
|
298
|
+
})) if (c.preferences) await deps.contactRepo.upsert(ctx.projectId, userId, c.channel, c.target, c.preferences);
|
|
299
|
+
else await deps.contactRepo.upsert(ctx.projectId, userId, c.channel, c.target);
|
|
218
300
|
logger.info({ userId }, "user updated");
|
|
219
301
|
sendJson(res, 200, { id: userId });
|
|
220
302
|
}
|
|
@@ -229,22 +311,44 @@ function createHandlers(deps) {
|
|
|
229
311
|
}
|
|
230
312
|
async function addContact(req, res, ctx) {
|
|
231
313
|
const userId = ctx.params.id;
|
|
232
|
-
const
|
|
314
|
+
const body = await readJsonBody(req);
|
|
315
|
+
const parsed = BatchAddContactsSchema.safeParse(body);
|
|
233
316
|
if (!parsed.success) return sendValidationError(res, parsed.error);
|
|
234
317
|
if (!await deps.userRepo.findById(ctx.projectId, userId)) return sendJson(res, 404, {
|
|
235
318
|
error: "user_not_found",
|
|
236
319
|
id: userId
|
|
237
320
|
});
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
321
|
+
if (Array.isArray(parsed.data)) {
|
|
322
|
+
const contactsList = parsed.data.map((c) => ({
|
|
323
|
+
userId,
|
|
324
|
+
channel: c.channel,
|
|
325
|
+
target: c.target,
|
|
326
|
+
preferences: c.preferences ?? {}
|
|
327
|
+
}));
|
|
328
|
+
await deps.contactRepo.upsertMany(ctx.projectId, contactsList);
|
|
329
|
+
logger.info({
|
|
330
|
+
userId,
|
|
331
|
+
count: contactsList.length
|
|
332
|
+
}, "contacts batch added");
|
|
333
|
+
sendJson(res, 201, {
|
|
334
|
+
userId,
|
|
335
|
+
contacts: contactsList.map((c) => ({
|
|
336
|
+
channel: c.channel,
|
|
337
|
+
target: c.target
|
|
338
|
+
}))
|
|
339
|
+
});
|
|
340
|
+
} else {
|
|
341
|
+
await deps.contactRepo.upsert(ctx.projectId, userId, parsed.data.channel, parsed.data.target, parsed.data.preferences ?? {});
|
|
342
|
+
logger.info({
|
|
343
|
+
userId,
|
|
344
|
+
channel: parsed.data.channel
|
|
345
|
+
}, "contact added");
|
|
346
|
+
sendJson(res, 201, {
|
|
347
|
+
userId,
|
|
348
|
+
channel: parsed.data.channel,
|
|
349
|
+
target: parsed.data.target
|
|
350
|
+
});
|
|
351
|
+
}
|
|
248
352
|
}
|
|
249
353
|
async function deleteContact(_req, res, ctx) {
|
|
250
354
|
const userId = ctx.params.id;
|
|
@@ -775,17 +879,28 @@ function createHandlers(deps) {
|
|
|
775
879
|
} catch {
|
|
776
880
|
streamDepths[key] = 0;
|
|
777
881
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
882
|
+
let total = 0;
|
|
883
|
+
let delivered = 0;
|
|
884
|
+
if (ctx.projectId) {
|
|
885
|
+
const totalTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(eq(messageLogs.projectId, ctx.projectId));
|
|
886
|
+
const deliveredTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(and(eq(messageLogs.projectId, ctx.projectId), eq(messageLogs.status, "delivered")));
|
|
887
|
+
total = Number(totalTasksRes[0]?.count ?? 0);
|
|
888
|
+
delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
|
889
|
+
} else {
|
|
890
|
+
const totalTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs);
|
|
891
|
+
const deliveredTasksRes = await deps.db.select({ count: sql`count(distinct ${messageLogs.taskId})` }).from(messageLogs).where(eq(messageLogs.status, "delivered"));
|
|
892
|
+
total = Number(totalTasksRes[0]?.count ?? 0);
|
|
893
|
+
delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
|
894
|
+
}
|
|
895
|
+
const failed = streamDepths.DEAD_LETTER || 0;
|
|
896
|
+
const successRate = total > 0 ? Number((delivered / total * 100).toFixed(2)) : 100;
|
|
782
897
|
sendJson(res, 200, {
|
|
783
898
|
streams: streamDepths,
|
|
784
899
|
deliveryStats: {
|
|
785
900
|
total,
|
|
786
901
|
delivered,
|
|
787
|
-
failed
|
|
788
|
-
successRate
|
|
902
|
+
failed,
|
|
903
|
+
successRate
|
|
789
904
|
}
|
|
790
905
|
});
|
|
791
906
|
}
|
|
@@ -1117,7 +1232,88 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
|
|
|
1117
1232
|
await deps.db.delete(suppressions).where(and(eq(suppressions.projectId, ctx.projectId), eq(suppressions.channel, channel), eq(suppressions.target, normaliseTarget(decodeURIComponent(target)))));
|
|
1118
1233
|
sendNoContent(res);
|
|
1119
1234
|
}
|
|
1235
|
+
async function login(req, res) {
|
|
1236
|
+
if (!deps.adminUserRepo) {
|
|
1237
|
+
sendJson(res, 500, {
|
|
1238
|
+
error: "internal_error",
|
|
1239
|
+
message: "Admin user repository not initialized"
|
|
1240
|
+
});
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
const body = await readJsonBody(req);
|
|
1244
|
+
const parsed = z.object({
|
|
1245
|
+
identifier: z.string().min(1, "Identifier (email or username) is required"),
|
|
1246
|
+
password: z.string().min(1, "Password is required")
|
|
1247
|
+
}).safeParse(body);
|
|
1248
|
+
if (!parsed.success) {
|
|
1249
|
+
sendValidationError(res, parsed.error);
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
const { identifier, password } = parsed.data;
|
|
1253
|
+
const user = await deps.adminUserRepo.findByEmailOrUsername(identifier);
|
|
1254
|
+
if (!user) {
|
|
1255
|
+
sendJson(res, 401, {
|
|
1256
|
+
error: "unauthorized",
|
|
1257
|
+
message: "Invalid credentials"
|
|
1258
|
+
});
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (!await verifyPassword(password, user.passwordHash)) {
|
|
1262
|
+
sendJson(res, 401, {
|
|
1263
|
+
error: "unauthorized",
|
|
1264
|
+
message: "Invalid credentials"
|
|
1265
|
+
});
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
const session = await createAdminSession(deps.redis.native, user);
|
|
1269
|
+
sendJson(res, 200, {
|
|
1270
|
+
token: session.token,
|
|
1271
|
+
user: {
|
|
1272
|
+
id: user.id,
|
|
1273
|
+
email: user.email,
|
|
1274
|
+
username: user.username,
|
|
1275
|
+
role: user.role
|
|
1276
|
+
},
|
|
1277
|
+
expiresAt: session.expiresAt
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
async function logout(req, res) {
|
|
1281
|
+
const authHeader = req.headers["authorization"];
|
|
1282
|
+
let token;
|
|
1283
|
+
if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) token = authHeader.slice(7).trim();
|
|
1284
|
+
if (token) await revokeAdminSession(deps.redis.native, token);
|
|
1285
|
+
sendJson(res, 200, { message: "Logged out successfully" });
|
|
1286
|
+
}
|
|
1287
|
+
async function getMe(req, res) {
|
|
1288
|
+
const authHeader = req.headers["authorization"];
|
|
1289
|
+
let token;
|
|
1290
|
+
if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) token = authHeader.slice(7).trim();
|
|
1291
|
+
if (!token) {
|
|
1292
|
+
sendJson(res, 401, {
|
|
1293
|
+
error: "unauthorized",
|
|
1294
|
+
message: "Missing token"
|
|
1295
|
+
});
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
const session = await getAdminSession(deps.redis.native, token);
|
|
1299
|
+
if (!session) {
|
|
1300
|
+
sendJson(res, 401, {
|
|
1301
|
+
error: "unauthorized",
|
|
1302
|
+
message: "Invalid or expired session"
|
|
1303
|
+
});
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
sendJson(res, 200, { user: {
|
|
1307
|
+
id: session.adminId,
|
|
1308
|
+
email: session.email,
|
|
1309
|
+
username: session.username,
|
|
1310
|
+
role: session.role
|
|
1311
|
+
} });
|
|
1312
|
+
}
|
|
1120
1313
|
return {
|
|
1314
|
+
login,
|
|
1315
|
+
logout,
|
|
1316
|
+
getMe,
|
|
1121
1317
|
syncTemplates,
|
|
1122
1318
|
addUser,
|
|
1123
1319
|
updateUser,
|
|
@@ -1167,6 +1363,158 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
|
|
|
1167
1363
|
};
|
|
1168
1364
|
}
|
|
1169
1365
|
//#endregion
|
|
1366
|
+
//#region src/services/api/admin-static.ts
|
|
1367
|
+
const MIME_TYPES = {
|
|
1368
|
+
".html": "text/html; charset=utf-8",
|
|
1369
|
+
".js": "application/javascript; charset=utf-8",
|
|
1370
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
1371
|
+
".css": "text/css; charset=utf-8",
|
|
1372
|
+
".json": "application/json; charset=utf-8",
|
|
1373
|
+
".png": "image/png",
|
|
1374
|
+
".jpg": "image/jpeg",
|
|
1375
|
+
".jpeg": "image/jpeg",
|
|
1376
|
+
".gif": "image/gif",
|
|
1377
|
+
".svg": "image/svg+xml",
|
|
1378
|
+
".ico": "image/x-icon",
|
|
1379
|
+
".woff": "font/woff",
|
|
1380
|
+
".woff2": "font/woff2",
|
|
1381
|
+
".ttf": "font/ttf",
|
|
1382
|
+
".webp": "image/webp",
|
|
1383
|
+
".txt": "text/plain; charset=utf-8",
|
|
1384
|
+
".map": "application/json; charset=utf-8"
|
|
1385
|
+
};
|
|
1386
|
+
const POSSIBLE_DASHBOARD_DIRS = [
|
|
1387
|
+
resolve(process.cwd(), "dashboard", "dist"),
|
|
1388
|
+
resolve(process.cwd(), "dashboard", "out"),
|
|
1389
|
+
resolve(process.cwd(), "dist", "admin"),
|
|
1390
|
+
resolve(process.cwd(), "dist", "dashboard")
|
|
1391
|
+
];
|
|
1392
|
+
function getDashboardDir() {
|
|
1393
|
+
for (const dir of POSSIBLE_DASHBOARD_DIRS) if (existsSync(dir) && existsSync(join(dir, "index.html"))) return dir;
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Proxies request to frontend dev server if running.
|
|
1398
|
+
*/
|
|
1399
|
+
function proxyToDevServer(req, res, targetHost, targetPort) {
|
|
1400
|
+
return new Promise((resolvePromise) => {
|
|
1401
|
+
const proxyReq = request({
|
|
1402
|
+
host: targetHost,
|
|
1403
|
+
port: targetPort,
|
|
1404
|
+
path: req.url,
|
|
1405
|
+
method: req.method,
|
|
1406
|
+
headers: req.headers
|
|
1407
|
+
}, (proxyRes) => {
|
|
1408
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
1409
|
+
proxyRes.pipe(res);
|
|
1410
|
+
resolvePromise(true);
|
|
1411
|
+
});
|
|
1412
|
+
proxyReq.on("error", () => {
|
|
1413
|
+
resolvePromise(false);
|
|
1414
|
+
});
|
|
1415
|
+
if (req.readable) req.pipe(proxyReq);
|
|
1416
|
+
else proxyReq.end();
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Handles incoming HTTP requests for `/admin` and `/admin/*`.
|
|
1421
|
+
* Serves static exported assets from Vite SPA build with index.html fallback,
|
|
1422
|
+
* or proxies to Vite dev server if running in development mode.
|
|
1423
|
+
*/
|
|
1424
|
+
async function handleAdminRequest(req, res, url) {
|
|
1425
|
+
if (url.pathname !== "/admin" && !url.pathname.startsWith("/admin/")) return false;
|
|
1426
|
+
if (url.pathname === "/admin") {
|
|
1427
|
+
res.writeHead(301, { Location: "/admin/" + (url.search || "") });
|
|
1428
|
+
res.end();
|
|
1429
|
+
return true;
|
|
1430
|
+
}
|
|
1431
|
+
if (process.env.NODE_ENV !== "production") {
|
|
1432
|
+
const devProxyUrl = process.env.VITE_DEV_URL || process.env.ADMIN_DEV_URL || process.env.NEXT_DEV_URL;
|
|
1433
|
+
const targetPort = devProxyUrl ? parseInt(new URL(devProxyUrl).port, 10) : 5173;
|
|
1434
|
+
if (await proxyToDevServer(req, res, devProxyUrl ? new URL(devProxyUrl).hostname : "127.0.0.1", targetPort)) return true;
|
|
1435
|
+
}
|
|
1436
|
+
const dashboardDir = getDashboardDir();
|
|
1437
|
+
if (!dashboardDir) {
|
|
1438
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1439
|
+
res.end(`
|
|
1440
|
+
<!DOCTYPE html>
|
|
1441
|
+
<html lang="en">
|
|
1442
|
+
<head>
|
|
1443
|
+
<meta charset="utf-8">
|
|
1444
|
+
<title>Notifkit Admin Dashboard</title>
|
|
1445
|
+
<style>
|
|
1446
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #09090b; color: #f4f4f5; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
|
1447
|
+
.card { background: #18181b; border: 1px solid #27272a; padding: 2rem; border-radius: 0.75rem; max-width: 500px; text-align: center; }
|
|
1448
|
+
h1 { margin-top: 0; color: #fafafa; }
|
|
1449
|
+
code { background: #27272a; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.9em; }
|
|
1450
|
+
</style>
|
|
1451
|
+
</head>
|
|
1452
|
+
<body>
|
|
1453
|
+
<div class="card">
|
|
1454
|
+
<h1>Notifkit Admin Dashboard</h1>
|
|
1455
|
+
<p>The dashboard static bundle has not been built yet.</p>
|
|
1456
|
+
<p>Please build it by running:</p>
|
|
1457
|
+
<p><code>npm run build:dashboard</code></p>
|
|
1458
|
+
<p>or run the dev server with <code>npm --prefix dashboard run dev</code>.</p>
|
|
1459
|
+
</div>
|
|
1460
|
+
</body>
|
|
1461
|
+
</html>
|
|
1462
|
+
`);
|
|
1463
|
+
return true;
|
|
1464
|
+
}
|
|
1465
|
+
let subPath = url.pathname.slice(6);
|
|
1466
|
+
if (subPath.startsWith("/")) subPath = subPath.slice(1);
|
|
1467
|
+
if (!subPath) subPath = "index.html";
|
|
1468
|
+
const normalizedSubPath = subPath.replace(/\/$/, "");
|
|
1469
|
+
let filePath = resolve(dashboardDir, subPath);
|
|
1470
|
+
if (!filePath.startsWith(dashboardDir)) {
|
|
1471
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
1472
|
+
res.end("Forbidden");
|
|
1473
|
+
return true;
|
|
1474
|
+
}
|
|
1475
|
+
let stat = null;
|
|
1476
|
+
if (existsSync(filePath)) {
|
|
1477
|
+
stat = statSync(filePath);
|
|
1478
|
+
if (stat.isDirectory()) {
|
|
1479
|
+
filePath = join(filePath, "index.html");
|
|
1480
|
+
stat = existsSync(filePath) ? statSync(filePath) : null;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (!stat) {
|
|
1484
|
+
const htmlPath = resolve(dashboardDir, `${normalizedSubPath}.html`);
|
|
1485
|
+
if (existsSync(htmlPath)) {
|
|
1486
|
+
filePath = htmlPath;
|
|
1487
|
+
stat = statSync(filePath);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (!stat) {
|
|
1491
|
+
const dirIndexPath = resolve(dashboardDir, normalizedSubPath, "index.html");
|
|
1492
|
+
if (existsSync(dirIndexPath)) {
|
|
1493
|
+
filePath = dirIndexPath;
|
|
1494
|
+
stat = statSync(filePath);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (!stat) {
|
|
1498
|
+
filePath = resolve(dashboardDir, "index.html");
|
|
1499
|
+
if (existsSync(filePath)) stat = statSync(filePath);
|
|
1500
|
+
}
|
|
1501
|
+
if (!stat) {
|
|
1502
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1503
|
+
res.end("Not Found");
|
|
1504
|
+
return true;
|
|
1505
|
+
}
|
|
1506
|
+
const ext = extname(filePath).toLowerCase();
|
|
1507
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
1508
|
+
const cacheControl = filePath.includes("_next") || ext === ".js" || ext === ".css" ? "public, max-age=31536000, immutable" : "public, max-age=0, must-revalidate";
|
|
1509
|
+
res.writeHead(200, {
|
|
1510
|
+
"Content-Type": contentType,
|
|
1511
|
+
"Content-Length": stat.size,
|
|
1512
|
+
"Cache-Control": cacheControl
|
|
1513
|
+
});
|
|
1514
|
+
createReadStream(filePath).pipe(res);
|
|
1515
|
+
return true;
|
|
1516
|
+
}
|
|
1517
|
+
//#endregion
|
|
1170
1518
|
//#region src/services/api/main.ts
|
|
1171
1519
|
/** Pub/sub channel used to drop a cached API key across every API process. */
|
|
1172
1520
|
const API_KEY_INVALIDATION_CHANNEL = "apikey.invalidated";
|
|
@@ -1336,6 +1684,21 @@ async function startApiServer() {
|
|
|
1336
1684
|
logger
|
|
1337
1685
|
})
|
|
1338
1686
|
};
|
|
1687
|
+
const adminUserRepo = new AdminUserRepository(db);
|
|
1688
|
+
if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) try {
|
|
1689
|
+
if (!await adminUserRepo.findByEmailOrUsername(config.ADMIN_EMAIL)) {
|
|
1690
|
+
const passwordHash = await hashPassword(config.ADMIN_PASSWORD);
|
|
1691
|
+
await adminUserRepo.create({
|
|
1692
|
+
email: config.ADMIN_EMAIL,
|
|
1693
|
+
username: config.ADMIN_USERNAME,
|
|
1694
|
+
passwordHash,
|
|
1695
|
+
role: "superadmin"
|
|
1696
|
+
});
|
|
1697
|
+
logger.info({ email: config.ADMIN_EMAIL }, "Bootstrapped initial admin user");
|
|
1698
|
+
}
|
|
1699
|
+
} catch (err) {
|
|
1700
|
+
logger.error({ err }, "Failed to bootstrap default admin user");
|
|
1701
|
+
}
|
|
1339
1702
|
deps = {
|
|
1340
1703
|
logger,
|
|
1341
1704
|
redis,
|
|
@@ -1346,11 +1709,12 @@ async function startApiServer() {
|
|
|
1346
1709
|
projectRepo: new ProjectRepository(db),
|
|
1347
1710
|
workflowRepo: new WorkflowRepository(db),
|
|
1348
1711
|
segmentRepo: new SegmentRepository(db),
|
|
1712
|
+
adminUserRepo,
|
|
1349
1713
|
db
|
|
1350
1714
|
};
|
|
1351
1715
|
h = createHandlers(deps);
|
|
1352
1716
|
router = new Router();
|
|
1353
|
-
router.put("/v1/templates", h.syncTemplates).get("/v1/templates", h.listTemplates).get("/v1/templates/:id", h.getTemplate).delete("/v1/templates/:id", h.deleteTemplate).post("/v1/users", h.addUser).get("/v1/users", h.listUsers).get("/v1/users/:id", h.getUser).get("/v1/users/:id/details", h.getUserDetails).patch("/v1/users/:id", h.updateUser).delete("/v1/users/:id", h.deleteUser).post("/v1/users/:id/contacts", h.addContact).get("/v1/users/:id/contacts", h.getUserContacts).delete("/v1/users/:id/contacts/:channel/:target", h.deleteContact).get("/v1/users/:id/preferences", h.getUserPreferences).patch("/v1/users/:id/preferences", h.updateUserPreferences).post("/v1/notify", h.notify).get("/v1/notifications/scheduled", h.getScheduledMessages).get("/v1/notifications/logs", h.getNotificationLogs).get("/v1/notifications/:taskId", h.getNotificationStatus).delete("/v1/notifications/:taskId", h.cancelNotification).get("/v1/unsubscribe", h.unsubscribePage).post("/v1/unsubscribe", h.unsubscribe).get("/v1/campaigns", h.listCampaigns).get("/v1/campaigns/:campaign/stats", h.getCampaignStats).get("/v1/suppressions", h.listSuppressions).post("/v1/suppressions", h.createSuppression).delete("/v1/suppressions/:channel/:target", h.deleteSuppression).get("/v1/system/health", h.getSystemHealth).get("/v1/system/metrics", h.getSystemMetrics).get("/v1/dlq", h.getDLQMessages).post("/v1/dlq/replay", h.replayDLQMessage).delete("/v1/dlq/:id", h.deleteDLQMessage).post("/v1/workflows", h.createWorkflow).get("/v1/workflows", h.listWorkflows).get("/v1/workflows/instances/:id", h.getWorkflow).delete("/v1/workflows/instances/:id", h.cancelWorkflow).post("/v1/workflows/trigger", h.triggerWorkflow).get("/v1/segments", h.listSegments).post("/v1/events", h.ingestEvent).get("/v1/events/stream", h.getEventsStream).get("/v1/projects", h.listProjects).post("/v1/projects", handleCreateProject).delete("/v1/projects/:id", h.deleteProject).patch("/v1/projects/:id", h.updateProject).post("/v1/projects/:id/keys", h.createProjectKey).get("/v1/projects/:id/keys", h.listProjectKeys).delete("/v1/projects/:id/keys/:keyId", h.deleteProjectKey).get("/health", handleHealth).get("/metrics", handleMetrics).get("/live", handleLive).get("/ready", handleReady);
|
|
1717
|
+
router.post("/v1/auth/login", h.login).post("/v1/auth/logout", h.logout).get("/v1/auth/me", h.getMe).put("/v1/templates", h.syncTemplates).get("/v1/templates", h.listTemplates).get("/v1/templates/:id", h.getTemplate).delete("/v1/templates/:id", h.deleteTemplate).post("/v1/users", h.addUser).get("/v1/users", h.listUsers).get("/v1/users/:id", h.getUser).get("/v1/users/:id/details", h.getUserDetails).patch("/v1/users/:id", h.updateUser).delete("/v1/users/:id", h.deleteUser).post("/v1/users/:id/contacts", h.addContact).get("/v1/users/:id/contacts", h.getUserContacts).delete("/v1/users/:id/contacts/:channel/:target", h.deleteContact).get("/v1/users/:id/preferences", h.getUserPreferences).patch("/v1/users/:id/preferences", h.updateUserPreferences).post("/v1/notify", h.notify).get("/v1/notifications/scheduled", h.getScheduledMessages).get("/v1/notifications/logs", h.getNotificationLogs).get("/v1/notifications/:taskId", h.getNotificationStatus).delete("/v1/notifications/:taskId", h.cancelNotification).get("/v1/unsubscribe", h.unsubscribePage).post("/v1/unsubscribe", h.unsubscribe).get("/v1/campaigns", h.listCampaigns).get("/v1/campaigns/:campaign/stats", h.getCampaignStats).get("/v1/suppressions", h.listSuppressions).post("/v1/suppressions", h.createSuppression).delete("/v1/suppressions/:channel/:target", h.deleteSuppression).get("/v1/system/health", h.getSystemHealth).get("/v1/system/metrics", h.getSystemMetrics).get("/v1/dlq", h.getDLQMessages).post("/v1/dlq/replay", h.replayDLQMessage).delete("/v1/dlq/:id", h.deleteDLQMessage).post("/v1/workflows", h.createWorkflow).get("/v1/workflows", h.listWorkflows).get("/v1/workflows/instances/:id", h.getWorkflow).delete("/v1/workflows/instances/:id", h.cancelWorkflow).post("/v1/workflows/trigger", h.triggerWorkflow).get("/v1/segments", h.listSegments).post("/v1/events", h.ingestEvent).get("/v1/events/stream", h.getEventsStream).get("/v1/projects", h.listProjects).post("/v1/projects", handleCreateProject).delete("/v1/projects/:id", h.deleteProject).patch("/v1/projects/:id", h.updateProject).post("/v1/projects/:id/keys", h.createProjectKey).get("/v1/projects/:id/keys", h.listProjectKeys).delete("/v1/projects/:id/keys/:keyId", h.deleteProjectKey).get("/health", handleHealth).get("/metrics", handleMetrics).get("/live", handleLive).get("/ready", handleReady);
|
|
1354
1718
|
server = createServer((req, res) => {
|
|
1355
1719
|
handleRequest(req, res);
|
|
1356
1720
|
});
|
|
@@ -1367,12 +1731,16 @@ async function startApiServer() {
|
|
|
1367
1731
|
return;
|
|
1368
1732
|
}
|
|
1369
1733
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
1734
|
+
if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) {
|
|
1735
|
+
if (await handleAdminRequest(req, res, url)) return;
|
|
1736
|
+
}
|
|
1370
1737
|
let projectId = void 0;
|
|
1371
1738
|
let projectRateLimitRpm = 600;
|
|
1372
1739
|
let keyRole = "admin";
|
|
1373
1740
|
const isProjectManagement = url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/");
|
|
1374
1741
|
const isPublicUnsubscribe = url.pathname === "/v1/unsubscribe";
|
|
1375
|
-
|
|
1742
|
+
const isPublicAuth = url.pathname === "/v1/auth/login";
|
|
1743
|
+
if (url.pathname.startsWith("/v1/") && !isProjectManagement && !isPublicUnsubscribe && !isPublicAuth) {
|
|
1376
1744
|
let token = extractAuthToken(req);
|
|
1377
1745
|
if (!token && url.searchParams.has("token")) token = url.searchParams.get("token") || void 0;
|
|
1378
1746
|
if (!token) {
|
|
@@ -1383,14 +1751,21 @@ async function startApiServer() {
|
|
|
1383
1751
|
return;
|
|
1384
1752
|
}
|
|
1385
1753
|
let isAdminToken = false;
|
|
1386
|
-
if (
|
|
1754
|
+
if (token.startsWith("nk_sess_")) {
|
|
1755
|
+
const session = await getAdminSession(redis.native, token);
|
|
1756
|
+
if (session) {
|
|
1757
|
+
isAdminToken = true;
|
|
1758
|
+
keyRole = session.role === "superadmin" ? "admin" : session.role;
|
|
1759
|
+
}
|
|
1760
|
+
} else if (config.ADMIN_API_KEY) {
|
|
1387
1761
|
const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
|
|
1388
1762
|
const providedBuffer = Buffer.from(token);
|
|
1389
1763
|
if (expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer)) isAdminToken = true;
|
|
1390
1764
|
}
|
|
1391
1765
|
if (isAdminToken) {
|
|
1392
1766
|
const headerProjectId = req.headers["x-project-id"] || url.searchParams.get("projectId") || void 0;
|
|
1393
|
-
|
|
1767
|
+
const isProjectAgnostic = url.pathname.startsWith("/v1/auth/") || url.pathname.startsWith("/v1/system/") || url.pathname.startsWith("/v1/dlq");
|
|
1768
|
+
if (!headerProjectId && !isProjectAgnostic) {
|
|
1394
1769
|
sendJson(res, 400, {
|
|
1395
1770
|
error: "bad_request",
|
|
1396
1771
|
message: "x-project-id header or projectId query param required when using admin token"
|
|
@@ -1399,7 +1774,7 @@ async function startApiServer() {
|
|
|
1399
1774
|
}
|
|
1400
1775
|
projectId = headerProjectId;
|
|
1401
1776
|
projectRateLimitRpm = 6e3;
|
|
1402
|
-
keyRole = "admin";
|
|
1777
|
+
if (!token.startsWith("nk_sess_")) keyRole = "admin";
|
|
1403
1778
|
} else {
|
|
1404
1779
|
const tokenHash = createHash("sha256").update(token).digest("hex");
|
|
1405
1780
|
const cached = authCache.get(tokenHash);
|
|
@@ -1451,7 +1826,7 @@ async function startApiServer() {
|
|
|
1451
1826
|
end
|
|
1452
1827
|
return -1
|
|
1453
1828
|
`;
|
|
1454
|
-
const rlKey = `rate-limit:api:req:${projectId}`;
|
|
1829
|
+
const rlKey = `rate-limit:api:req:${projectId || "global"}`;
|
|
1455
1830
|
const nowMs = Date.now();
|
|
1456
1831
|
if (await redis.native.eval(LUA_LIMIT, 1, rlKey, nowMs, 6e4, projectRateLimitRpm, randomBytes(4).toString("hex")) === -1) {
|
|
1457
1832
|
if (!res.headersSent) {
|
|
@@ -1465,13 +1840,6 @@ async function startApiServer() {
|
|
|
1465
1840
|
}
|
|
1466
1841
|
} else if (isProjectManagement) {
|
|
1467
1842
|
const token = extractAuthToken(req);
|
|
1468
|
-
if (!config.ADMIN_API_KEY) {
|
|
1469
|
-
sendJson(res, 403, {
|
|
1470
|
-
error: "forbidden",
|
|
1471
|
-
message: "Project management disabled (no ADMIN_API_KEY set)"
|
|
1472
|
-
});
|
|
1473
|
-
return;
|
|
1474
|
-
}
|
|
1475
1843
|
if (!token) {
|
|
1476
1844
|
sendJson(res, 401, {
|
|
1477
1845
|
error: "unauthorized",
|
|
@@ -1479,9 +1847,15 @@ async function startApiServer() {
|
|
|
1479
1847
|
});
|
|
1480
1848
|
return;
|
|
1481
1849
|
}
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1850
|
+
let authorized = false;
|
|
1851
|
+
if (token.startsWith("nk_sess_")) {
|
|
1852
|
+
if (await getAdminSession(redis.native, token)) authorized = true;
|
|
1853
|
+
} else if (config.ADMIN_API_KEY) {
|
|
1854
|
+
const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
|
|
1855
|
+
const providedBuffer = Buffer.from(token);
|
|
1856
|
+
if (expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer)) authorized = true;
|
|
1857
|
+
}
|
|
1858
|
+
if (!authorized) {
|
|
1485
1859
|
sendJson(res, 401, {
|
|
1486
1860
|
error: "unauthorized",
|
|
1487
1861
|
message: "Invalid admin token"
|
|
@@ -1695,4 +2069,4 @@ async function stopApiServer() {
|
|
|
1695
2069
|
//#endregion
|
|
1696
2070
|
export { startApiServer, stopApiServer };
|
|
1697
2071
|
|
|
1698
|
-
//# sourceMappingURL=main-
|
|
2072
|
+
//# sourceMappingURL=main-BAO4kKce.mjs.map
|