notifkit 0.1.7 → 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.
Files changed (39) 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 +433 -8
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +2 -2
  9. package/dist/{main-D6SG3Isk.mjs → main-0FHNtN5g.mjs} +2 -2
  10. package/dist/{main-D6SG3Isk.mjs.map → main-0FHNtN5g.mjs.map} +1 -1
  11. package/dist/{main-pFAfCkVX.mjs → main-7KMlWnDx.mjs} +2 -2
  12. package/dist/{main-pFAfCkVX.mjs.map → main-7KMlWnDx.mjs.map} +1 -1
  13. package/dist/{main-Bgi2wWck.mjs → main-B0BcY_JJ.mjs} +2 -2
  14. package/dist/{main-Bgi2wWck.mjs.map → main-B0BcY_JJ.mjs.map} +1 -1
  15. package/dist/{main-CFSukWm8.mjs → main-BAO4kKce.mjs} +366 -26
  16. package/dist/main-BAO4kKce.mjs.map +1 -0
  17. package/dist/{main-CRPxM-O3.mjs → main-BjXgGJBd.mjs} +2 -2
  18. package/dist/{main-CRPxM-O3.mjs.map → main-BjXgGJBd.mjs.map} +1 -1
  19. package/dist/{main-CjbVdUqa.mjs → main-BtXITy0k.mjs} +2 -2
  20. package/dist/{main-CjbVdUqa.mjs.map → main-BtXITy0k.mjs.map} +1 -1
  21. package/dist/{main-Yorxzw0Z.mjs → main-BxqAXTr6.mjs} +2 -2
  22. package/dist/{main-Yorxzw0Z.mjs.map → main-BxqAXTr6.mjs.map} +1 -1
  23. package/dist/{main-DFMHcN_d.mjs → main-zCpvA9Rx.mjs} +2 -2
  24. package/dist/{main-DFMHcN_d.mjs.map → main-zCpvA9Rx.mjs.map} +1 -1
  25. package/dist/{src-CPMwsUCJ.mjs → src-DkvHYM3y.mjs} +76 -19
  26. package/dist/src-DkvHYM3y.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 +3 -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 +224 -0
  35. package/src/services/api/handlers.ts +129 -12
  36. package/src/services/api/main.ts +72 -19
  37. package/src/services/auth/index.ts +103 -0
  38. package/dist/main-CFSukWm8.mjs.map +0 -1
  39. package/dist/src-CPMwsUCJ.mjs.map +0 -1
@@ -0,0 +1,11 @@
1
+ CREATE TABLE IF NOT EXISTS "admin_users" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "email" varchar NOT NULL,
4
+ "username" varchar,
5
+ "password_hash" varchar NOT NULL,
6
+ "role" varchar DEFAULT 'admin' NOT NULL,
7
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
8
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
9
+ CONSTRAINT "admin_users_email_unique" UNIQUE("email"),
10
+ CONSTRAINT "admin_users_username_unique" UNIQUE("username")
11
+ );
@@ -36,6 +36,13 @@
36
36
  "when": 1788780502388,
37
37
  "tag": "0004_pretty_bruce_banner",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1788781000000,
44
+ "tag": "0005_admin_users",
45
+ "breakpoints": true
39
46
  }
40
47
  ]
41
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notifkit",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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",
@@ -48,23 +48,28 @@
48
48
  "dist",
49
49
  "src",
50
50
  "drizzle",
51
+ "dashboard/dist",
51
52
  "scripts/create-project.mjs",
53
+ "scripts/create-admin.mjs",
52
54
  "README.md",
53
55
  "LICENSE"
54
56
  ],
55
57
  "bin": {
56
- "notifkit-create-project": "./scripts/create-project.mjs"
58
+ "notifkit-create-project": "./scripts/create-project.mjs",
59
+ "notifkit-create-admin": "./scripts/create-admin.mjs"
57
60
  },
58
61
  "scripts": {
59
62
  "build": "tsdown",
60
63
  "dev": "tsdown --watch",
61
64
  "create-project": "node scripts/create-project.mjs",
65
+ "create-admin": "node scripts/create-admin.mjs",
66
+ "build:dashboard": "npm --prefix dashboard run build",
62
67
  "test": "vitest run --coverage",
63
68
  "test:watch": "vitest",
64
69
  "lint": "eslint . --fix",
65
70
  "typecheck": "tsc --noEmit",
66
71
  "prepare": "husky",
67
- "prepublishOnly": "npm run typecheck && npm run build"
72
+ "prepublishOnly": "npm run typecheck && npm run build && npm run build:dashboard"
68
73
  },
69
74
  "dependencies": {
70
75
  "ai": "^7.0.37",
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Create or update an admin user for the Notifkit Dashboard.
4
+ *
5
+ * Usage:
6
+ * ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secret node scripts/create-admin.mjs
7
+ * node scripts/create-admin.mjs admin@example.com secret [username] [role]
8
+ */
9
+
10
+ import { config } from "dotenv";
11
+ import { resolve } from "node:path";
12
+ import postgres from "postgres";
13
+ import { scrypt, randomBytes } from "node:crypto";
14
+ import { promisify } from "node:util";
15
+
16
+ config({ path: resolve(process.cwd(), ".env") });
17
+
18
+ const scryptAsync = promisify(scrypt);
19
+
20
+ async function hashPassword(password) {
21
+ const salt = randomBytes(16).toString("hex");
22
+ const derivedKey = await scryptAsync(password, salt, 64);
23
+ return `${salt}:${derivedKey.toString("hex")}`;
24
+ }
25
+
26
+ const email = process.argv[2] || process.env.ADMIN_EMAIL;
27
+ const password = process.argv[3] || process.env.ADMIN_PASSWORD;
28
+ const username = process.argv[4] || process.env.ADMIN_USERNAME || null;
29
+ const role = process.argv[5] || "admin";
30
+
31
+ if (!email || !password) {
32
+ console.error("Usage: node scripts/create-admin.mjs <email> <password> [username] [role]");
33
+ console.error("Or set ADMIN_EMAIL and ADMIN_PASSWORD in environment.");
34
+ process.exit(1);
35
+ }
36
+
37
+ const databaseUrl =
38
+ process.env.DATABASE_URL || "postgres://platform:platform@localhost:5432/notifkit";
39
+ const sql = postgres(databaseUrl);
40
+
41
+ try {
42
+ const passwordHash = await hashPassword(password);
43
+ const normalizedEmail = email.trim().toLowerCase();
44
+ const normalizedUsername = username ? username.trim() : null;
45
+
46
+ const rows = await sql`
47
+ INSERT INTO admin_users (email, username, password_hash, role)
48
+ VALUES (${normalizedEmail}, ${normalizedUsername}, ${passwordHash}, ${role})
49
+ ON CONFLICT (email) DO UPDATE
50
+ SET password_hash = EXCLUDED.password_hash,
51
+ username = COALESCE(EXCLUDED.username, admin_users.username),
52
+ role = EXCLUDED.role,
53
+ updated_at = NOW()
54
+ RETURNING id, email, username, role, created_at, updated_at
55
+ `;
56
+
57
+ console.log("\nAdmin user successfully saved:\n");
58
+ console.log(` ID: ${rows[0].id}`);
59
+ console.log(` Email: ${rows[0].email}`);
60
+ console.log(` Username: ${rows[0].username || "(none)"}`);
61
+ console.log(` Role: ${rows[0].role}\n`);
62
+ } catch (err) {
63
+ console.error(`Failed to create admin: ${err.message}`);
64
+ process.exit(1);
65
+ } finally {
66
+ await sql.end();
67
+ }
@@ -43,6 +43,9 @@ export const baseConfigSchema = z.object({
43
43
  // Windows puts a trailing space in the variable, which otherwise turns every
44
44
  // admin request into a 401 that reads like a wrong key.
45
45
  ADMIN_API_KEY: z.string().trim().optional(),
46
+ ADMIN_EMAIL: z.string().trim().email().optional(),
47
+ ADMIN_PASSWORD: z.string().trim().min(6).optional(),
48
+ ADMIN_USERNAME: z.string().trim().optional(),
46
49
  WORKER_CONCURRENCY: z.coerce.number().int().min(1).default(10),
47
50
  QUEUE_MAX_LEN: z.coerce.number().int().min(1).default(10000000),
48
51
  DB_MAX_CONNECTIONS: z.coerce.number().int().min(1).default(2),
package/src/db/schema.ts CHANGED
@@ -367,8 +367,21 @@ export const scheduledPayloads = pgTable("scheduled_payloads", {
367
367
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
368
368
  });
369
369
 
370
+ export const adminUsers = pgTable("admin_users", {
371
+ id: uuid("id").primaryKey().defaultRandom(),
372
+ email: varchar("email").notNull().unique(),
373
+ username: varchar("username").unique(),
374
+ passwordHash: varchar("password_hash").notNull(),
375
+ role: varchar("role").notNull().default("admin"),
376
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
377
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
378
+ });
379
+
370
380
  // ─── Generated Zod Schemas ──────────────────────────────────────────────────
371
381
 
382
+ export const insertAdminUserSchema = createInsertSchema(adminUsers);
383
+ export const selectAdminUserSchema = createSelectSchema(adminUsers);
384
+
372
385
  export const insertProjectSchema = createInsertSchema(projects);
373
386
  export const selectProjectSchema = createSelectSchema(projects);
374
387
 
@@ -1,4 +1,4 @@
1
- import { eq, and, sql as drizzleSql, inArray, desc } from "drizzle-orm";
1
+ import { eq, and, or, sql as drizzleSql, inArray, desc } from "drizzle-orm";
2
2
  import type { Db } from "@/index.js";
3
3
  import type { Preferences, ContactChannel } from "@/contracts/index.js";
4
4
  import {
@@ -18,6 +18,7 @@ import {
18
18
  projectApiKeys,
19
19
  suppressions,
20
20
  messageLogs,
21
+ adminUsers,
21
22
  } from "@/db/schema.js";
22
23
 
23
24
  // ─── Domain types ───────────────────────────────────────────────────────────
@@ -1244,3 +1245,83 @@ export class SegmentRepository {
1244
1245
  return (rows as any[]).map((r) => r.segment);
1245
1246
  }
1246
1247
  }
1248
+
1249
+ // ─── AdminUserRepository ───────────────────────────────────────────────────
1250
+
1251
+ export interface AdminUserRecord {
1252
+ id: string;
1253
+ email: string;
1254
+ username: string | null;
1255
+ passwordHash: string;
1256
+ role: string;
1257
+ createdAt: Date;
1258
+ updatedAt: Date;
1259
+ }
1260
+
1261
+ export class AdminUserRepository {
1262
+ constructor(private readonly db: Db) {}
1263
+
1264
+ async findByEmailOrUsername(identifier: string): Promise<AdminUserRecord | null> {
1265
+ const normalized = identifier.trim().toLowerCase();
1266
+ const rows = await this.db
1267
+ .select()
1268
+ .from(adminUsers)
1269
+ .where(or(eq(adminUsers.email, normalized), eq(adminUsers.username, identifier.trim())))
1270
+ .limit(1);
1271
+ return (rows[0] as AdminUserRecord) ?? null;
1272
+ }
1273
+
1274
+ async findById(id: string): Promise<AdminUserRecord | null> {
1275
+ const rows = await this.db.select().from(adminUsers).where(eq(adminUsers.id, id)).limit(1);
1276
+ return (rows[0] as AdminUserRecord) ?? null;
1277
+ }
1278
+
1279
+ async create(data: {
1280
+ email: string;
1281
+ username?: string | null;
1282
+ passwordHash: string;
1283
+ role?: string;
1284
+ }): Promise<AdminUserRecord> {
1285
+ const rows = await this.db
1286
+ .insert(adminUsers)
1287
+ .values({
1288
+ email: data.email.trim().toLowerCase(),
1289
+ username: data.username ? data.username.trim() : null,
1290
+ passwordHash: data.passwordHash,
1291
+ role: data.role || "admin",
1292
+ })
1293
+ .returning();
1294
+ return rows[0] as AdminUserRecord;
1295
+ }
1296
+
1297
+ async updatePassword(id: string, passwordHash: string): Promise<boolean> {
1298
+ const rows = await this.db
1299
+ .update(adminUsers)
1300
+ .set({ passwordHash, updatedAt: new Date() })
1301
+ .where(eq(adminUsers.id, id))
1302
+ .returning();
1303
+ return rows.length > 0;
1304
+ }
1305
+
1306
+ async list(): Promise<Omit<AdminUserRecord, "passwordHash">[]> {
1307
+ const rows = await this.db
1308
+ .select({
1309
+ id: adminUsers.id,
1310
+ email: adminUsers.email,
1311
+ username: adminUsers.username,
1312
+ role: adminUsers.role,
1313
+ createdAt: adminUsers.createdAt,
1314
+ updatedAt: adminUsers.updatedAt,
1315
+ })
1316
+ .from(adminUsers)
1317
+ .orderBy(adminUsers.createdAt);
1318
+ return rows as Omit<AdminUserRecord, "passwordHash">[];
1319
+ }
1320
+
1321
+ async count(): Promise<number> {
1322
+ const rows = await this.db
1323
+ .select({ count: drizzleSql<number>`count(*)::int` })
1324
+ .from(adminUsers);
1325
+ return rows[0]?.count ?? 0;
1326
+ }
1327
+ }
@@ -0,0 +1,224 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { resolve, extname, join } from "node:path";
3
+ import { existsSync, statSync, createReadStream } from "node:fs";
4
+ import { request as httpRequest } from "node:http";
5
+
6
+ const MIME_TYPES: Record<string, string> = {
7
+ ".html": "text/html; charset=utf-8",
8
+ ".js": "application/javascript; charset=utf-8",
9
+ ".mjs": "application/javascript; charset=utf-8",
10
+ ".css": "text/css; charset=utf-8",
11
+ ".json": "application/json; charset=utf-8",
12
+ ".png": "image/png",
13
+ ".jpg": "image/jpeg",
14
+ ".jpeg": "image/jpeg",
15
+ ".gif": "image/gif",
16
+ ".svg": "image/svg+xml",
17
+ ".ico": "image/x-icon",
18
+ ".woff": "font/woff",
19
+ ".woff2": "font/woff2",
20
+ ".ttf": "font/ttf",
21
+ ".webp": "image/webp",
22
+ ".txt": "text/plain; charset=utf-8",
23
+ ".map": "application/json; charset=utf-8",
24
+ };
25
+
26
+ // Possible output directories where built dashboard static files might reside
27
+ const POSSIBLE_DASHBOARD_DIRS = [
28
+ resolve(process.cwd(), "dashboard", "dist"),
29
+ resolve(process.cwd(), "dashboard", "out"),
30
+ resolve(process.cwd(), "dist", "admin"),
31
+ resolve(process.cwd(), "dist", "dashboard"),
32
+ ];
33
+
34
+ function getDashboardDir(): string | null {
35
+ for (const dir of POSSIBLE_DASHBOARD_DIRS) {
36
+ if (existsSync(dir) && existsSync(join(dir, "index.html"))) {
37
+ return dir;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Proxies request to frontend dev server if running.
45
+ */
46
+ function proxyToDevServer(
47
+ req: IncomingMessage,
48
+ res: ServerResponse,
49
+ targetHost: string,
50
+ targetPort: number,
51
+ ): Promise<boolean> {
52
+ return new Promise((resolvePromise) => {
53
+ const proxyReq = httpRequest(
54
+ {
55
+ host: targetHost,
56
+ port: targetPort,
57
+ path: req.url,
58
+ method: req.method,
59
+ headers: req.headers,
60
+ },
61
+ (proxyRes) => {
62
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
63
+ proxyRes.pipe(res);
64
+ resolvePromise(true);
65
+ },
66
+ );
67
+
68
+ proxyReq.on("error", () => {
69
+ resolvePromise(false);
70
+ });
71
+
72
+ if (req.readable) {
73
+ req.pipe(proxyReq);
74
+ } else {
75
+ proxyReq.end();
76
+ }
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Handles incoming HTTP requests for `/admin` and `/admin/*`.
82
+ * Serves static exported assets from Vite SPA build with index.html fallback,
83
+ * or proxies to Vite dev server if running in development mode.
84
+ */
85
+ export async function handleAdminRequest(
86
+ req: IncomingMessage,
87
+ res: ServerResponse,
88
+ url: URL,
89
+ ): Promise<boolean> {
90
+ if (url.pathname !== "/admin" && !url.pathname.startsWith("/admin/")) {
91
+ return false;
92
+ }
93
+
94
+ // Redirect /admin to /admin/
95
+ if (url.pathname === "/admin") {
96
+ res.writeHead(301, { Location: "/admin/" + (url.search || "") });
97
+ res.end();
98
+ return true;
99
+ }
100
+
101
+ // In development, attempt to proxy to local Vite dev server on port 5173 (or VITE_DEV_URL) if active
102
+ if (process.env.NODE_ENV !== "production") {
103
+ const devProxyUrl =
104
+ process.env.VITE_DEV_URL || process.env.ADMIN_DEV_URL || process.env.NEXT_DEV_URL;
105
+ const targetPort = devProxyUrl ? parseInt(new URL(devProxyUrl).port, 10) : 5173;
106
+ const targetHost = devProxyUrl ? new URL(devProxyUrl).hostname : "127.0.0.1";
107
+
108
+ const proxied = await proxyToDevServer(req, res, targetHost, targetPort);
109
+ if (proxied) {
110
+ return true;
111
+ }
112
+ }
113
+
114
+ const dashboardDir = getDashboardDir();
115
+ if (!dashboardDir) {
116
+ // If dashboard build not found, return friendly message
117
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
118
+ res.end(`
119
+ <!DOCTYPE html>
120
+ <html lang="en">
121
+ <head>
122
+ <meta charset="utf-8">
123
+ <title>Notifkit Admin Dashboard</title>
124
+ <style>
125
+ 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; }
126
+ .card { background: #18181b; border: 1px solid #27272a; padding: 2rem; border-radius: 0.75rem; max-width: 500px; text-align: center; }
127
+ h1 { margin-top: 0; color: #fafafa; }
128
+ code { background: #27272a; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.9em; }
129
+ </style>
130
+ </head>
131
+ <body>
132
+ <div class="card">
133
+ <h1>Notifkit Admin Dashboard</h1>
134
+ <p>The dashboard static bundle has not been built yet.</p>
135
+ <p>Please build it by running:</p>
136
+ <p><code>npm run build:dashboard</code></p>
137
+ <p>or run the dev server with <code>npm --prefix dashboard run dev</code>.</p>
138
+ </div>
139
+ </body>
140
+ </html>
141
+ `);
142
+ return true;
143
+ }
144
+
145
+ // Strip /admin prefix to find relative path inside dashboardDir
146
+ let subPath = url.pathname.slice("/admin".length);
147
+ if (subPath.startsWith("/")) {
148
+ subPath = subPath.slice(1);
149
+ }
150
+ if (!subPath) {
151
+ subPath = "index.html";
152
+ }
153
+
154
+ const normalizedSubPath = subPath.replace(/\/$/, "");
155
+ let filePath = resolve(dashboardDir, subPath);
156
+
157
+ // Security check: ensure filePath is inside dashboardDir
158
+ if (!filePath.startsWith(dashboardDir)) {
159
+ res.writeHead(403, { "Content-Type": "text/plain" });
160
+ res.end("Forbidden");
161
+ return true;
162
+ }
163
+
164
+ // Check direct file
165
+ let stat: ReturnType<typeof statSync> | null = null;
166
+ if (existsSync(filePath)) {
167
+ stat = statSync(filePath);
168
+ if (stat.isDirectory()) {
169
+ filePath = join(filePath, "index.html");
170
+ stat = existsSync(filePath) ? statSync(filePath) : null;
171
+ }
172
+ }
173
+
174
+ // Check path.html if direct file not found
175
+ if (!stat) {
176
+ const htmlPath = resolve(dashboardDir, `${normalizedSubPath}.html`);
177
+ if (existsSync(htmlPath)) {
178
+ filePath = htmlPath;
179
+ stat = statSync(filePath);
180
+ }
181
+ }
182
+
183
+ // Check path/index.html
184
+ if (!stat) {
185
+ const dirIndexPath = resolve(dashboardDir, normalizedSubPath, "index.html");
186
+ if (existsSync(dirIndexPath)) {
187
+ filePath = dirIndexPath;
188
+ stat = statSync(filePath);
189
+ }
190
+ }
191
+
192
+ // Fallback to index.html for SPA client-side routing
193
+ if (!stat) {
194
+ filePath = resolve(dashboardDir, "index.html");
195
+ if (existsSync(filePath)) {
196
+ stat = statSync(filePath);
197
+ }
198
+ }
199
+
200
+ if (!stat) {
201
+ res.writeHead(404, { "Content-Type": "text/plain" });
202
+ res.end("Not Found");
203
+ return true;
204
+ }
205
+
206
+ const ext = extname(filePath).toLowerCase();
207
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
208
+
209
+ // Cache static assets (_next/static) aggressively (immutable, 1 year), HTML files no-cache
210
+ const isImmutable = filePath.includes("_next") || ext === ".js" || ext === ".css";
211
+ const cacheControl = isImmutable
212
+ ? "public, max-age=31536000, immutable"
213
+ : "public, max-age=0, must-revalidate";
214
+
215
+ res.writeHead(200, {
216
+ "Content-Type": contentType,
217
+ "Content-Length": stat.size,
218
+ "Cache-Control": cacheControl,
219
+ });
220
+
221
+ const stream = createReadStream(filePath);
222
+ stream.pipe(res);
223
+ return true;
224
+ }
@@ -10,7 +10,14 @@ 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
+ } from "@/services/auth/index.js";
14
21
  import type { Preferences } from "@/contracts/index.js";
15
22
  import {
16
23
  AddUserSchema,
@@ -63,6 +70,7 @@ export interface Deps {
63
70
  projectRepo: ProjectRepository;
64
71
  workflowRepo: WorkflowRepository;
65
72
  segmentRepo: SegmentRepository;
73
+ adminUserRepo?: AdminUserRepository;
66
74
  db: Db;
67
75
  }
68
76
 
@@ -1107,19 +1115,35 @@ export function createHandlers(deps: Deps) {
1107
1115
  }
1108
1116
  }
1109
1117
 
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")));
1118
+ // Scoped to the caller's project if provided, otherwise aggregate across all projects
1119
+ let total = 0;
1120
+ let delivered = 0;
1121
+
1122
+ if (ctx.projectId) {
1123
+ const totalTasksRes = await deps.db
1124
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1125
+ .from(messageLogs)
1126
+ .where(eq(messageLogs.projectId, ctx.projectId));
1127
+ const deliveredTasksRes = await deps.db
1128
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1129
+ .from(messageLogs)
1130
+ .where(and(eq(messageLogs.projectId, ctx.projectId), eq(messageLogs.status, "delivered")));
1131
+
1132
+ total = Number(totalTasksRes[0]?.count ?? 0);
1133
+ delivered = Number(deliveredTasksRes[0]?.count ?? 0);
1134
+ } else {
1135
+ const totalTasksRes = await deps.db
1136
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1137
+ .from(messageLogs);
1138
+ const deliveredTasksRes = await deps.db
1139
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1140
+ .from(messageLogs)
1141
+ .where(eq(messageLogs.status, "delivered"));
1142
+
1143
+ total = Number(totalTasksRes[0]?.count ?? 0);
1144
+ delivered = Number(deliveredTasksRes[0]?.count ?? 0);
1145
+ }
1120
1146
 
1121
- const total = Number(totalTasksRes[0]?.count ?? 0);
1122
- const delivered = Number(deliveredTasksRes[0]?.count ?? 0);
1123
1147
  const failed = streamDepths.DEAD_LETTER || 0;
1124
1148
  const successRate = total > 0 ? Number(((delivered / total) * 100).toFixed(2)) : 100;
1125
1149
 
@@ -1747,7 +1771,100 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
1747
1771
  sendNoContent(res);
1748
1772
  }
1749
1773
 
1774
+ // ── POST /v1/auth/login — login ───────────────────────────────────────────
1775
+ async function login(req: IncomingMessage, res: ServerResponse): Promise<void> {
1776
+ if (!deps.adminUserRepo) {
1777
+ sendJson(res, 500, {
1778
+ error: "internal_error",
1779
+ message: "Admin user repository not initialized",
1780
+ });
1781
+ return;
1782
+ }
1783
+
1784
+ const body = await readJsonBody(req);
1785
+ const parsed = z
1786
+ .object({
1787
+ identifier: z.string().min(1, "Identifier (email or username) is required"),
1788
+ password: z.string().min(1, "Password is required"),
1789
+ })
1790
+ .safeParse(body);
1791
+
1792
+ if (!parsed.success) {
1793
+ sendValidationError(res, parsed.error);
1794
+ return;
1795
+ }
1796
+
1797
+ const { identifier, password } = parsed.data;
1798
+ 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) {
1806
+ sendJson(res, 401, { error: "unauthorized", message: "Invalid credentials" });
1807
+ return;
1808
+ }
1809
+
1810
+ const session = await createAdminSession(deps.redis.native, user);
1811
+ sendJson(res, 200, {
1812
+ token: session.token,
1813
+ user: {
1814
+ id: user.id,
1815
+ email: user.email,
1816
+ username: user.username,
1817
+ role: user.role,
1818
+ },
1819
+ expiresAt: session.expiresAt,
1820
+ });
1821
+ }
1822
+
1823
+ // ── POST /v1/auth/logout — logout ─────────────────────────────────────────
1824
+ async function logout(req: IncomingMessage, res: ServerResponse): Promise<void> {
1825
+ const authHeader = req.headers["authorization"];
1826
+ let token: string | undefined;
1827
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) {
1828
+ token = authHeader.slice(7).trim();
1829
+ }
1830
+ if (token) {
1831
+ await revokeAdminSession(deps.redis.native, token);
1832
+ }
1833
+ sendJson(res, 200, { message: "Logged out successfully" });
1834
+ }
1835
+
1836
+ // ── GET /v1/auth/me — getMe ───────────────────────────────────────────────
1837
+ async function getMe(req: IncomingMessage, res: ServerResponse): Promise<void> {
1838
+ const authHeader = req.headers["authorization"];
1839
+ let token: string | undefined;
1840
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) {
1841
+ token = authHeader.slice(7).trim();
1842
+ }
1843
+ if (!token) {
1844
+ sendJson(res, 401, { error: "unauthorized", message: "Missing token" });
1845
+ return;
1846
+ }
1847
+
1848
+ const session = await getAdminSession(deps.redis.native, token);
1849
+ if (!session) {
1850
+ sendJson(res, 401, { error: "unauthorized", message: "Invalid or expired session" });
1851
+ return;
1852
+ }
1853
+
1854
+ sendJson(res, 200, {
1855
+ user: {
1856
+ id: session.adminId,
1857
+ email: session.email,
1858
+ username: session.username,
1859
+ role: session.role,
1860
+ },
1861
+ });
1862
+ }
1863
+
1750
1864
  return {
1865
+ login,
1866
+ logout,
1867
+ getMe,
1751
1868
  syncTemplates,
1752
1869
  addUser,
1753
1870
  updateUser,