@kyro-cms/admin 0.1.2 → 0.1.3
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/package.json +17 -6
- package/src/components/Admin.tsx +50 -1
- package/src/components/LoginPage.tsx +223 -0
- package/src/components/layout/Sidebar.tsx +35 -0
- package/src/index.ts +35 -0
- package/src/middleware.ts +2 -0
- package/src/pages/api/auth/register.ts +133 -0
- package/src/styles/main.css +148 -0
- package/.astro/content.d.ts +0 -154
- package/.astro/settings.json +0 -5
- package/.astro/types.d.ts +0 -2
- package/astro.config.mjs +0 -28
- package/bun.lock +0 -1374
- package/dist/client/_astro/AdminLayout.DkDpng53.css +0 -1
- package/dist/client/_astro/AutoForm.3eJCmCJp.js +0 -1
- package/dist/client/_astro/client.DyczpTbx.js +0 -9
- package/dist/client/_astro/index.B02hbnpo.js +0 -1
- package/dist/client/fonts/Serotiva-Black.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Bold.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Medium.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Regular.woff2 +0 -0
- package/dist/client/fonts/Serotiva-SemiBold.woff2 +0 -0
- package/dist/server/chunks/AdminLayout_D-_JeUqC.mjs +0 -26
- package/dist/server/chunks/_id__BzI_o0qT.mjs +0 -50
- package/dist/server/chunks/_id__Cd-jOuY3.mjs +0 -238
- package/dist/server/chunks/_id__DvbD--iR.mjs +0 -992
- package/dist/server/chunks/_id__vpVaEo16.mjs +0 -128
- package/dist/server/chunks/_virtual_astro_server-island-manifest_CQQ1F5PF.mjs +0 -7
- package/dist/server/chunks/_virtual_astro_session-driver_Bk3Q189E.mjs +0 -4
- package/dist/server/chunks/astro-component_Dbx3T2Nh.mjs +0 -37
- package/dist/server/chunks/audit-logs_DrnUMRvY.mjs +0 -74
- package/dist/server/chunks/config_CPXslElD.mjs +0 -4221
- package/dist/server/chunks/dataStore_Dl7cA2Qp.mjs +0 -89
- package/dist/server/chunks/index_CVqOkerS.mjs +0 -2960
- package/dist/server/chunks/index_CX8SQ4BF.mjs +0 -55
- package/dist/server/chunks/index_CYofDU51.mjs +0 -58
- package/dist/server/chunks/index_DdNRhuaM.mjs +0 -55
- package/dist/server/chunks/index_DupPvtIF.mjs +0 -42
- package/dist/server/chunks/index_YTS_M-B9.mjs +0 -263
- package/dist/server/chunks/index_YeCzuVps.mjs +0 -53
- package/dist/server/chunks/login_DLyqMRO8.mjs +0 -93
- package/dist/server/chunks/logout_CSbt5wea.mjs +0 -50
- package/dist/server/chunks/me_C04jlYhH.mjs +0 -41
- package/dist/server/chunks/new_BbQ9b55M.mjs +0 -92
- package/dist/server/chunks/node_9bvTewss.mjs +0 -1014
- package/dist/server/chunks/noop-entrypoint_BOlrdqWF.mjs +0 -3
- package/dist/server/chunks/sequence_9cl7AJy-.mjs +0 -2503
- package/dist/server/chunks/server_peBx9VXG.mjs +0 -8117
- package/dist/server/chunks/sharp_pmJ7nHES.mjs +0 -142
- package/dist/server/chunks/users_Dzddy_YR.mjs +0 -137
- package/dist/server/entry.mjs +0 -5
- package/dist/server/virtual_astro_middleware.mjs +0 -48
- package/public/fonts/Serotiva-Black.woff2 +0 -0
- package/public/fonts/Serotiva-Bold.woff2 +0 -0
- package/public/fonts/Serotiva-Medium.woff2 +0 -0
- package/public/fonts/Serotiva-Regular.woff2 +0 -0
- package/public/fonts/Serotiva-SemiBold.woff2 +0 -0
- package/tsconfig.json +0 -12
|
@@ -1,2960 +0,0 @@
|
|
|
1
|
-
import Redis2 from 'ioredis';
|
|
2
|
-
import bcrypt from 'bcryptjs';
|
|
3
|
-
import { randomBytes } from 'crypto';
|
|
4
|
-
import nodemailer from 'nodemailer';
|
|
5
|
-
import 'hono';
|
|
6
|
-
import 'ws';
|
|
7
|
-
import { pgTable, timestamp, jsonb, integer, boolean, uuid, varchar, uniqueIndex, index, text } from 'drizzle-orm/pg-core';
|
|
8
|
-
import 'postgres';
|
|
9
|
-
import { z } from 'zod';
|
|
10
|
-
export { z } from 'zod';
|
|
11
|
-
import 'bcrypt';
|
|
12
|
-
import jwt from 'jsonwebtoken';
|
|
13
|
-
|
|
14
|
-
// src/auth/bootstrap.ts
|
|
15
|
-
var DEFAULT_PREFIX = "kyro:auth:";
|
|
16
|
-
var DEFAULT_TOKEN_EXPIRATION = 86400;
|
|
17
|
-
var DEFAULT_REFRESH_EXPIRATION = 604800;
|
|
18
|
-
var RedisAuthAdapter = class {
|
|
19
|
-
redis;
|
|
20
|
-
prefix;
|
|
21
|
-
tokenExpiration;
|
|
22
|
-
refreshExpiration;
|
|
23
|
-
constructor(options = {}) {
|
|
24
|
-
const url = options.url || `redis://${options.host || "localhost"}:${options.port || 6379}`;
|
|
25
|
-
this.redis = new Redis2(url, {
|
|
26
|
-
password: options.password,
|
|
27
|
-
db: options.db,
|
|
28
|
-
lazyConnect: true,
|
|
29
|
-
tls: options.tls ? {} : void 0
|
|
30
|
-
});
|
|
31
|
-
this.prefix = options.keyPrefix || DEFAULT_PREFIX;
|
|
32
|
-
this.tokenExpiration = options.tokenExpiration || DEFAULT_TOKEN_EXPIRATION;
|
|
33
|
-
this.refreshExpiration = options.refreshTokenExpiration || DEFAULT_REFRESH_EXPIRATION;
|
|
34
|
-
}
|
|
35
|
-
async connect() {
|
|
36
|
-
await this.redis.connect();
|
|
37
|
-
}
|
|
38
|
-
async disconnect() {
|
|
39
|
-
await this.redis.quit();
|
|
40
|
-
}
|
|
41
|
-
userKey(userId) {
|
|
42
|
-
return `${this.prefix}users:${userId}`;
|
|
43
|
-
}
|
|
44
|
-
sessionKey(sessionId) {
|
|
45
|
-
return `${this.prefix}sessions:${sessionId}`;
|
|
46
|
-
}
|
|
47
|
-
refreshKey(token) {
|
|
48
|
-
return `${this.prefix}refresh:${token}`;
|
|
49
|
-
}
|
|
50
|
-
userByEmailKey(email) {
|
|
51
|
-
return `${this.prefix}users:email:${email.toLowerCase()}`;
|
|
52
|
-
}
|
|
53
|
-
passwordHistoryKey(userId) {
|
|
54
|
-
return `${this.prefix}users:${userId}:password_history`;
|
|
55
|
-
}
|
|
56
|
-
async createUser(data) {
|
|
57
|
-
const userId = randomBytes(16).toString("hex");
|
|
58
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
59
|
-
const user = {
|
|
60
|
-
id: userId,
|
|
61
|
-
email: data.email.toLowerCase(),
|
|
62
|
-
passwordHash: data.passwordHash,
|
|
63
|
-
role: data.role || "customer",
|
|
64
|
-
tenantId: data.tenantId,
|
|
65
|
-
createdAt: now,
|
|
66
|
-
updatedAt: now
|
|
67
|
-
};
|
|
68
|
-
const pipeline = this.redis.pipeline();
|
|
69
|
-
pipeline.hset(this.userKey(userId), this.userToHash(user));
|
|
70
|
-
pipeline.set(this.userByEmailKey(data.email), userId);
|
|
71
|
-
await pipeline.exec();
|
|
72
|
-
return user;
|
|
73
|
-
}
|
|
74
|
-
async findUserByEmail(email) {
|
|
75
|
-
const userId = await this.redis.get(
|
|
76
|
-
this.userByEmailKey(email.toLowerCase())
|
|
77
|
-
);
|
|
78
|
-
if (!userId) return null;
|
|
79
|
-
return this.findUserById(userId);
|
|
80
|
-
}
|
|
81
|
-
async findUserById(userId) {
|
|
82
|
-
const data = await this.redis.hgetall(this.userKey(userId));
|
|
83
|
-
if (!data || Object.keys(data).length === 0) return null;
|
|
84
|
-
return this.hashToUser(data);
|
|
85
|
-
}
|
|
86
|
-
async updateUser(userId, data) {
|
|
87
|
-
const existing = await this.findUserById(userId);
|
|
88
|
-
if (!existing) return null;
|
|
89
|
-
const updated = {
|
|
90
|
-
...existing,
|
|
91
|
-
...data,
|
|
92
|
-
id: userId,
|
|
93
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
94
|
-
};
|
|
95
|
-
if (data.email && data.email !== existing.email) {
|
|
96
|
-
const pipeline = this.redis.pipeline();
|
|
97
|
-
pipeline.del(this.userByEmailKey(existing.email));
|
|
98
|
-
pipeline.set(this.userByEmailKey(data.email), userId);
|
|
99
|
-
await pipeline.exec();
|
|
100
|
-
}
|
|
101
|
-
await this.redis.hset(this.userKey(userId), this.userToHash(updated));
|
|
102
|
-
return updated;
|
|
103
|
-
}
|
|
104
|
-
async deleteUser(userId) {
|
|
105
|
-
const user = await this.findUserById(userId);
|
|
106
|
-
if (!user) return false;
|
|
107
|
-
const pipeline = this.redis.pipeline();
|
|
108
|
-
pipeline.del(this.userKey(userId));
|
|
109
|
-
pipeline.del(this.userByEmailKey(user.email));
|
|
110
|
-
pipeline.del(this.passwordHistoryKey(userId));
|
|
111
|
-
await pipeline.exec();
|
|
112
|
-
return true;
|
|
113
|
-
}
|
|
114
|
-
async hashPassword(password) {
|
|
115
|
-
return bcrypt.hash(password, 12);
|
|
116
|
-
}
|
|
117
|
-
async verifyPassword(password, hash) {
|
|
118
|
-
return bcrypt.compare(password, hash);
|
|
119
|
-
}
|
|
120
|
-
async createSession(userId, data = {}) {
|
|
121
|
-
const sessionId = randomBytes(32).toString("hex");
|
|
122
|
-
const token = randomBytes(32).toString("base64url");
|
|
123
|
-
const refreshToken = randomBytes(32).toString("base64url");
|
|
124
|
-
const now = /* @__PURE__ */ new Date();
|
|
125
|
-
const session = {
|
|
126
|
-
id: sessionId,
|
|
127
|
-
userId,
|
|
128
|
-
token,
|
|
129
|
-
refreshToken,
|
|
130
|
-
expiresAt: new Date(
|
|
131
|
-
now.getTime() + this.tokenExpiration * 1e3
|
|
132
|
-
).toISOString(),
|
|
133
|
-
createdAt: now.toISOString(),
|
|
134
|
-
ipAddress: data.ipAddress,
|
|
135
|
-
userAgent: data.userAgent
|
|
136
|
-
};
|
|
137
|
-
const pipeline = this.redis.pipeline();
|
|
138
|
-
pipeline.hset(this.sessionKey(sessionId), this.sessionToHash(session));
|
|
139
|
-
pipeline.setex(
|
|
140
|
-
this.refreshKey(refreshToken),
|
|
141
|
-
this.refreshExpiration,
|
|
142
|
-
sessionId
|
|
143
|
-
);
|
|
144
|
-
await pipeline.exec();
|
|
145
|
-
return session;
|
|
146
|
-
}
|
|
147
|
-
async findSessionByToken(token) {
|
|
148
|
-
const data = await this.redis.hgetall(this.sessionKey(token));
|
|
149
|
-
if (!data || Object.keys(data).length === 0) return null;
|
|
150
|
-
return this.hashToSession(data);
|
|
151
|
-
}
|
|
152
|
-
async deleteSession(sessionId) {
|
|
153
|
-
const session = await this.redis.hgetall(this.sessionKey(sessionId));
|
|
154
|
-
if (!session || Object.keys(session).length === 0) return false;
|
|
155
|
-
const pipeline = this.redis.pipeline();
|
|
156
|
-
pipeline.del(this.sessionKey(sessionId));
|
|
157
|
-
if (session.refreshToken) {
|
|
158
|
-
pipeline.del(this.refreshKey(session.refreshToken));
|
|
159
|
-
}
|
|
160
|
-
await pipeline.exec();
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
async deleteUserSessions(userId) {
|
|
164
|
-
const pattern = `${this.prefix}sessions:*`;
|
|
165
|
-
let cursor = "0";
|
|
166
|
-
let deleted = 0;
|
|
167
|
-
do {
|
|
168
|
-
const [nextCursor, keys] = await this.redis.scan(
|
|
169
|
-
cursor,
|
|
170
|
-
"MATCH",
|
|
171
|
-
pattern,
|
|
172
|
-
"COUNT",
|
|
173
|
-
100
|
|
174
|
-
);
|
|
175
|
-
cursor = nextCursor;
|
|
176
|
-
for (const key of keys) {
|
|
177
|
-
const sessionData = await this.redis.hgetall(key);
|
|
178
|
-
if (sessionData.userId === userId) {
|
|
179
|
-
const sessionId = key.replace(`${this.prefix}sessions:`, "");
|
|
180
|
-
await this.deleteSession(sessionId);
|
|
181
|
-
deleted++;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
} while (cursor !== "0");
|
|
185
|
-
return deleted;
|
|
186
|
-
}
|
|
187
|
-
async addPasswordToHistory(userId, passwordHash) {
|
|
188
|
-
await this.redis.lpush(this.passwordHistoryKey(userId), passwordHash);
|
|
189
|
-
await this.redis.ltrim(this.passwordHistoryKey(userId), 0, 4);
|
|
190
|
-
}
|
|
191
|
-
async getPasswordHistory(userId, count = 5) {
|
|
192
|
-
return this.redis.lrange(this.passwordHistoryKey(userId), 0, count - 1);
|
|
193
|
-
}
|
|
194
|
-
async isPasswordInHistory(password, userId, historyCount = 5) {
|
|
195
|
-
const history = await this.getPasswordHistory(userId, historyCount);
|
|
196
|
-
for (const hash of history) {
|
|
197
|
-
if (await this.verifyPassword(password, hash)) {
|
|
198
|
-
return true;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
|
-
userToHash(user) {
|
|
204
|
-
const hash = {
|
|
205
|
-
id: user.id,
|
|
206
|
-
email: user.email,
|
|
207
|
-
passwordHash: user.passwordHash || "",
|
|
208
|
-
role: user.role,
|
|
209
|
-
createdAt: user.createdAt,
|
|
210
|
-
updatedAt: user.updatedAt
|
|
211
|
-
};
|
|
212
|
-
if (user.tenantId) hash.tenantId = user.tenantId;
|
|
213
|
-
if (user.emailVerified !== void 0)
|
|
214
|
-
hash.emailVerified = String(user.emailVerified);
|
|
215
|
-
if (user.locked !== void 0) hash.locked = String(user.locked);
|
|
216
|
-
if (user.lastLogin) hash.lastLogin = user.lastLogin;
|
|
217
|
-
if (user.failedLoginAttempts !== void 0)
|
|
218
|
-
hash.failedLoginAttempts = String(user.failedLoginAttempts);
|
|
219
|
-
return hash;
|
|
220
|
-
}
|
|
221
|
-
hashToUser(hash) {
|
|
222
|
-
return {
|
|
223
|
-
id: hash.id,
|
|
224
|
-
email: hash.email,
|
|
225
|
-
passwordHash: hash.passwordHash,
|
|
226
|
-
role: hash.role,
|
|
227
|
-
tenantId: hash.tenantId,
|
|
228
|
-
createdAt: hash.createdAt,
|
|
229
|
-
updatedAt: hash.updatedAt,
|
|
230
|
-
emailVerified: hash.emailVerified === "true",
|
|
231
|
-
locked: hash.locked === "true",
|
|
232
|
-
lastLogin: hash.lastLogin,
|
|
233
|
-
failedLoginAttempts: hash.failedLoginAttempts ? parseInt(hash.failedLoginAttempts, 10) : 0
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
sessionToHash(session) {
|
|
237
|
-
const hash = {
|
|
238
|
-
id: session.id,
|
|
239
|
-
userId: session.userId,
|
|
240
|
-
token: session.token,
|
|
241
|
-
expiresAt: session.expiresAt,
|
|
242
|
-
createdAt: session.createdAt
|
|
243
|
-
};
|
|
244
|
-
if (session.refreshToken) hash.refreshToken = session.refreshToken;
|
|
245
|
-
if (session.ipAddress) hash.ipAddress = session.ipAddress;
|
|
246
|
-
if (session.userAgent) hash.userAgent = session.userAgent;
|
|
247
|
-
return hash;
|
|
248
|
-
}
|
|
249
|
-
hashToSession(hash) {
|
|
250
|
-
return {
|
|
251
|
-
id: hash.id,
|
|
252
|
-
userId: hash.userId,
|
|
253
|
-
token: hash.token,
|
|
254
|
-
refreshToken: hash.refreshToken,
|
|
255
|
-
expiresAt: hash.expiresAt,
|
|
256
|
-
createdAt: hash.createdAt,
|
|
257
|
-
ipAddress: hash.ipAddress,
|
|
258
|
-
userAgent: hash.userAgent
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
var defaultTemplates = {
|
|
263
|
-
verifyEmail: (link, userName = "User") => ({
|
|
264
|
-
subject: "Verify your email address",
|
|
265
|
-
html: `
|
|
266
|
-
<!DOCTYPE html>
|
|
267
|
-
<html>
|
|
268
|
-
<head>
|
|
269
|
-
<meta charset="utf-8">
|
|
270
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
271
|
-
<title>Verify Email</title>
|
|
272
|
-
<style>
|
|
273
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
274
|
-
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
275
|
-
.button { display: inline-block; padding: 12px 24px; background: #0b1222; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; }
|
|
276
|
-
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
|
277
|
-
</style>
|
|
278
|
-
</head>
|
|
279
|
-
<body>
|
|
280
|
-
<div class="container">
|
|
281
|
-
<h1>Welcome, ${userName}!</h1>
|
|
282
|
-
<p>Please verify your email address by clicking the button below:</p>
|
|
283
|
-
<p style="text-align: center; margin: 30px 0;">
|
|
284
|
-
<a href="${link}" class="button">Verify Email</a>
|
|
285
|
-
</p>
|
|
286
|
-
<p>Or copy and paste this link into your browser:</p>
|
|
287
|
-
<p style="word-break: break-all; color: #666;">${link}</p>
|
|
288
|
-
<p>This link will expire in 24 hours.</p>
|
|
289
|
-
<div class="footer">
|
|
290
|
-
<p>If you didn't create an account, you can safely ignore this email.</p>
|
|
291
|
-
</div>
|
|
292
|
-
</div>
|
|
293
|
-
</body>
|
|
294
|
-
</html>
|
|
295
|
-
`,
|
|
296
|
-
text: `Welcome ${userName}!
|
|
297
|
-
|
|
298
|
-
Please verify your email by clicking this link: ${link}
|
|
299
|
-
|
|
300
|
-
This link will expire in 24 hours.
|
|
301
|
-
|
|
302
|
-
If you didn't create an account, you can safely ignore this email.`
|
|
303
|
-
}),
|
|
304
|
-
resetPassword: (link, userName = "User") => ({
|
|
305
|
-
subject: "Reset your password",
|
|
306
|
-
html: `
|
|
307
|
-
<!DOCTYPE html>
|
|
308
|
-
<html>
|
|
309
|
-
<head>
|
|
310
|
-
<meta charset="utf-8">
|
|
311
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
312
|
-
<title>Reset Password</title>
|
|
313
|
-
<style>
|
|
314
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
315
|
-
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
316
|
-
.button { display: inline-block; padding: 12px 24px; background: #dc2626; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; }
|
|
317
|
-
.warning { background: #fef3c7; border: 1px solid #f59e0b; padding: 12px; border-radius: 6px; margin: 20px 0; }
|
|
318
|
-
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
|
319
|
-
</style>
|
|
320
|
-
</head>
|
|
321
|
-
<body>
|
|
322
|
-
<div class="container">
|
|
323
|
-
<h1>Password Reset Request</h1>
|
|
324
|
-
<p>Hello ${userName},</p>
|
|
325
|
-
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
|
326
|
-
<p style="text-align: center; margin: 30px 0;">
|
|
327
|
-
<a href="${link}" class="button">Reset Password</a>
|
|
328
|
-
</p>
|
|
329
|
-
<p>Or copy and paste this link into your browser:</p>
|
|
330
|
-
<p style="word-break: break-all; color: #666;">${link}</p>
|
|
331
|
-
<div class="warning">
|
|
332
|
-
<strong>\u26A0\uFE0F Important:</strong> This link will expire in 1 hour. If you didn't request a password reset, please ignore this email or contact support if you have concerns.
|
|
333
|
-
</div>
|
|
334
|
-
<div class="footer">
|
|
335
|
-
<p>For security reasons, please don't share this email with anyone.</p>
|
|
336
|
-
</div>
|
|
337
|
-
</div>
|
|
338
|
-
</body>
|
|
339
|
-
</html>
|
|
340
|
-
`,
|
|
341
|
-
text: `Password Reset Request
|
|
342
|
-
|
|
343
|
-
Hello ${userName},
|
|
344
|
-
|
|
345
|
-
We received a request to reset your password. Click this link to create a new password: ${link}
|
|
346
|
-
|
|
347
|
-
This link will expire in 1 hour.
|
|
348
|
-
|
|
349
|
-
If you didn't request a password reset, please ignore this email.`
|
|
350
|
-
}),
|
|
351
|
-
welcome: (userName = "User") => ({
|
|
352
|
-
subject: "Welcome to Kyro CMS",
|
|
353
|
-
html: `
|
|
354
|
-
<!DOCTYPE html>
|
|
355
|
-
<html>
|
|
356
|
-
<head>
|
|
357
|
-
<meta charset="utf-8">
|
|
358
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
359
|
-
<title>Welcome</title>
|
|
360
|
-
<style>
|
|
361
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
362
|
-
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
363
|
-
.button { display: inline-block; padding: 12px 24px; background: #0b1222; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; }
|
|
364
|
-
</style>
|
|
365
|
-
</head>
|
|
366
|
-
<body>
|
|
367
|
-
<div class="container">
|
|
368
|
-
<h1>Welcome to Kyro CMS, ${userName}!</h1>
|
|
369
|
-
<p>Your account has been created successfully.</p>
|
|
370
|
-
<p>You can now:</p>
|
|
371
|
-
<ul>
|
|
372
|
-
<li>Manage your content collections</li>
|
|
373
|
-
<li>Upload and organize media</li>
|
|
374
|
-
<li>Configure settings</li>
|
|
375
|
-
<li>And much more...</li>
|
|
376
|
-
</ul>
|
|
377
|
-
<p style="text-align: center; margin: 30px 0;">
|
|
378
|
-
<a href="#" class="button">Get Started</a>
|
|
379
|
-
</p>
|
|
380
|
-
<p>If you have any questions, feel free to reach out to our support team.</p>
|
|
381
|
-
</div>
|
|
382
|
-
</body>
|
|
383
|
-
</html>
|
|
384
|
-
`,
|
|
385
|
-
text: `Welcome to Kyro CMS, ${userName}!
|
|
386
|
-
|
|
387
|
-
Your account has been created successfully.
|
|
388
|
-
|
|
389
|
-
You can now:
|
|
390
|
-
- Manage your content collections
|
|
391
|
-
- Upload and organize media
|
|
392
|
-
- Configure settings
|
|
393
|
-
- And much more...
|
|
394
|
-
|
|
395
|
-
Get started by logging into your dashboard.`
|
|
396
|
-
}),
|
|
397
|
-
accountLocked: (attempts, duration, userName = "User") => ({
|
|
398
|
-
subject: "Account Security Alert - Account Locked",
|
|
399
|
-
html: `
|
|
400
|
-
<!DOCTYPE html>
|
|
401
|
-
<html>
|
|
402
|
-
<head>
|
|
403
|
-
<meta charset="utf-8">
|
|
404
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
405
|
-
<title>Account Locked</title>
|
|
406
|
-
<style>
|
|
407
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
408
|
-
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
409
|
-
.alert { background: #fef2f2; border: 1px solid #ef4444; padding: 16px; border-radius: 8px; margin: 20px 0; }
|
|
410
|
-
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
|
411
|
-
</style>
|
|
412
|
-
</head>
|
|
413
|
-
<body>
|
|
414
|
-
<div class="container">
|
|
415
|
-
<h1>Account Security Alert</h1>
|
|
416
|
-
<p>Hello ${userName},</p>
|
|
417
|
-
<div class="alert">
|
|
418
|
-
<p><strong>\u26A0\uFE0F Your account has been temporarily locked due to multiple failed login attempts.</strong></p>
|
|
419
|
-
<p>Failed attempts: ${attempts}</p>
|
|
420
|
-
<p>Lockout duration: ${Math.round(duration / 6e4)} minutes</p>
|
|
421
|
-
</div>
|
|
422
|
-
<p>Your account will automatically unlock after the lockout period expires.</p>
|
|
423
|
-
<p>If this wasn't you, we recommend:</p>
|
|
424
|
-
<ul>
|
|
425
|
-
<li>Using a strong, unique password</li>
|
|
426
|
-
<li>Enabling two-factor authentication (coming soon)</li>
|
|
427
|
-
<li>Reviewing your recent account activity</li>
|
|
428
|
-
</ul>
|
|
429
|
-
<div class="footer">
|
|
430
|
-
<p>If you need immediate assistance, please contact support.</p>
|
|
431
|
-
</div>
|
|
432
|
-
</div>
|
|
433
|
-
</body>
|
|
434
|
-
</html>
|
|
435
|
-
`,
|
|
436
|
-
text: `Account Security Alert
|
|
437
|
-
|
|
438
|
-
Hello ${userName},
|
|
439
|
-
|
|
440
|
-
Your account has been temporarily locked due to multiple failed login attempts (${attempts}).
|
|
441
|
-
|
|
442
|
-
Lockout duration: ${Math.round(duration / 6e4)} minutes
|
|
443
|
-
|
|
444
|
-
Your account will automatically unlock after this period.
|
|
445
|
-
|
|
446
|
-
If this wasn't you, we recommend using a strong, unique password.`
|
|
447
|
-
}),
|
|
448
|
-
passwordChanged: (userName = "User") => ({
|
|
449
|
-
subject: "Your password has been changed",
|
|
450
|
-
html: `
|
|
451
|
-
<!DOCTYPE html>
|
|
452
|
-
<html>
|
|
453
|
-
<head>
|
|
454
|
-
<meta charset="utf-8">
|
|
455
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
456
|
-
<title>Password Changed</title>
|
|
457
|
-
<style>
|
|
458
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
459
|
-
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
460
|
-
.info { background: #f0fdf4; border: 1px solid #22c55e; padding: 12px; border-radius: 6px; margin: 20px 0; }
|
|
461
|
-
</style>
|
|
462
|
-
</head>
|
|
463
|
-
<body>
|
|
464
|
-
<div class="container">
|
|
465
|
-
<h1>Password Changed</h1>
|
|
466
|
-
<p>Hello ${userName},</p>
|
|
467
|
-
<div class="info">
|
|
468
|
-
<p>Your password was recently changed.</p>
|
|
469
|
-
</div>
|
|
470
|
-
<p>If you did this, you can safely ignore this email.</p>
|
|
471
|
-
<p><strong>If you didn't change your password</strong>, please contact our support team immediately as your account may have been compromised.</p>
|
|
472
|
-
</div>
|
|
473
|
-
</body>
|
|
474
|
-
</html>
|
|
475
|
-
`,
|
|
476
|
-
text: `Password Changed
|
|
477
|
-
|
|
478
|
-
Hello ${userName},
|
|
479
|
-
|
|
480
|
-
Your password was recently changed.
|
|
481
|
-
|
|
482
|
-
If you did this, you can safely ignore this email.
|
|
483
|
-
|
|
484
|
-
If you didn't change your password, please contact support immediately.`
|
|
485
|
-
}),
|
|
486
|
-
newLogin: (location, time, userName = "User") => ({
|
|
487
|
-
subject: "New login to your account",
|
|
488
|
-
html: `
|
|
489
|
-
<!DOCTYPE html>
|
|
490
|
-
<html>
|
|
491
|
-
<head>
|
|
492
|
-
<meta charset="utf-8">
|
|
493
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
494
|
-
<title>New Login</title>
|
|
495
|
-
<style>
|
|
496
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
497
|
-
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
498
|
-
.info-box { background: #f8fafc; border: 1px solid #e2e8f0; padding: 16px; border-radius: 8px; margin: 20px 0; }
|
|
499
|
-
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
|
500
|
-
</style>
|
|
501
|
-
</head>
|
|
502
|
-
<body>
|
|
503
|
-
<div class="container">
|
|
504
|
-
<h1>New Login Detected</h1>
|
|
505
|
-
<p>Hello ${userName},</p>
|
|
506
|
-
<p>We detected a new login to your account:</p>
|
|
507
|
-
<div class="info-box">
|
|
508
|
-
<p><strong>Location:</strong> ${location}</p>
|
|
509
|
-
<p><strong>Time:</strong> ${time}</p>
|
|
510
|
-
</div>
|
|
511
|
-
<p><strong>If this was you</strong>, no action is needed.</p>
|
|
512
|
-
<p><strong>If this wasn't you</strong>, your account may be compromised. Please:</p>
|
|
513
|
-
<ol>
|
|
514
|
-
<li>Change your password immediately</li>
|
|
515
|
-
<li>Review your recent account activity</li>
|
|
516
|
-
<li>Contact support if needed</li>
|
|
517
|
-
</ol>
|
|
518
|
-
<div class="footer">
|
|
519
|
-
<p>This is an automated security notification.</p>
|
|
520
|
-
</div>
|
|
521
|
-
</div>
|
|
522
|
-
</body>
|
|
523
|
-
</html>
|
|
524
|
-
`,
|
|
525
|
-
text: `New Login Detected
|
|
526
|
-
|
|
527
|
-
Hello ${userName},
|
|
528
|
-
|
|
529
|
-
We detected a new login to your account:
|
|
530
|
-
|
|
531
|
-
Location: ${location}
|
|
532
|
-
Time: ${time}
|
|
533
|
-
|
|
534
|
-
If this wasn't you, please change your password immediately and contact support.`
|
|
535
|
-
})
|
|
536
|
-
};
|
|
537
|
-
var EmailTransport = class _EmailTransport {
|
|
538
|
-
transporter;
|
|
539
|
-
from;
|
|
540
|
-
fromName;
|
|
541
|
-
templates;
|
|
542
|
-
constructor(config, templates) {
|
|
543
|
-
this.transporter = nodemailer.createTransport({
|
|
544
|
-
host: config.host,
|
|
545
|
-
port: config.port,
|
|
546
|
-
secure: config.secure,
|
|
547
|
-
auth: config.auth
|
|
548
|
-
});
|
|
549
|
-
this.from = config.from;
|
|
550
|
-
this.fromName = config.fromName || "Kyro CMS";
|
|
551
|
-
this.templates = { ...defaultTemplates, ...templates };
|
|
552
|
-
}
|
|
553
|
-
async send(options) {
|
|
554
|
-
return this.transporter.sendMail({
|
|
555
|
-
from: `"${this.fromName}" <${this.from}>`,
|
|
556
|
-
to: Array.isArray(options.to) ? options.to.join(", ") : options.to,
|
|
557
|
-
subject: options.subject,
|
|
558
|
-
html: options.html,
|
|
559
|
-
text: options.text
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
getTemplates() {
|
|
563
|
-
return this.templates;
|
|
564
|
-
}
|
|
565
|
-
async verifyConnection() {
|
|
566
|
-
try {
|
|
567
|
-
await this.transporter.verify();
|
|
568
|
-
return true;
|
|
569
|
-
} catch {
|
|
570
|
-
return false;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
static fromEnv() {
|
|
574
|
-
const host = process.env.SMTP_HOST;
|
|
575
|
-
const port = parseInt(process.env.SMTP_PORT || "587", 10);
|
|
576
|
-
const secure = process.env.SMTP_SECURE === "true";
|
|
577
|
-
const user = process.env.SMTP_USER;
|
|
578
|
-
const pass = process.env.SMTP_PASS;
|
|
579
|
-
const from = process.env.SMTP_FROM || process.env.DEFAULT_FROM || "noreply@example.com";
|
|
580
|
-
const fromName = process.env.SMTP_FROM_NAME || "Kyro CMS";
|
|
581
|
-
if (!host || !user || !pass) {
|
|
582
|
-
return null;
|
|
583
|
-
}
|
|
584
|
-
return new _EmailTransport({
|
|
585
|
-
host,
|
|
586
|
-
port,
|
|
587
|
-
secure,
|
|
588
|
-
auth: { user, pass },
|
|
589
|
-
from,
|
|
590
|
-
fromName
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
};
|
|
594
|
-
|
|
595
|
-
// src/auth/security/password-policy.ts
|
|
596
|
-
var DEFAULT_PASSWORD_POLICY = {
|
|
597
|
-
minLength: 12,
|
|
598
|
-
requireUppercase: true,
|
|
599
|
-
requireLowercase: true,
|
|
600
|
-
requireNumbers: true,
|
|
601
|
-
requireSpecialChars: true,
|
|
602
|
-
preventReuse: 5,
|
|
603
|
-
maxLength: 128
|
|
604
|
-
};
|
|
605
|
-
var PasswordPolicy = class {
|
|
606
|
-
config;
|
|
607
|
-
constructor(config = {}) {
|
|
608
|
-
this.config = { ...DEFAULT_PASSWORD_POLICY, ...config };
|
|
609
|
-
}
|
|
610
|
-
validate(password) {
|
|
611
|
-
const errors = [];
|
|
612
|
-
if (this.config.maxLength && password.length > this.config.maxLength) {
|
|
613
|
-
errors.push(
|
|
614
|
-
`Password must not exceed ${this.config.maxLength} characters`
|
|
615
|
-
);
|
|
616
|
-
}
|
|
617
|
-
if (password.length < this.config.minLength) {
|
|
618
|
-
errors.push(
|
|
619
|
-
`Password must be at least ${this.config.minLength} characters`
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
if (this.config.requireUppercase && !/[A-Z]/.test(password)) {
|
|
623
|
-
errors.push("Password must contain at least one uppercase letter");
|
|
624
|
-
}
|
|
625
|
-
if (this.config.requireLowercase && !/[a-z]/.test(password)) {
|
|
626
|
-
errors.push("Password must contain at least one lowercase letter");
|
|
627
|
-
}
|
|
628
|
-
if (this.config.requireNumbers && !/[0-9]/.test(password)) {
|
|
629
|
-
errors.push("Password must contain at least one number");
|
|
630
|
-
}
|
|
631
|
-
if (this.config.requireSpecialChars && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
|
632
|
-
errors.push("Password must contain at least one special character");
|
|
633
|
-
}
|
|
634
|
-
const commonPasswords = [
|
|
635
|
-
"password",
|
|
636
|
-
"123456",
|
|
637
|
-
"12345678",
|
|
638
|
-
"qwerty",
|
|
639
|
-
"abc123",
|
|
640
|
-
"monkey",
|
|
641
|
-
"1234567",
|
|
642
|
-
"letmein",
|
|
643
|
-
"trustno1",
|
|
644
|
-
"dragon",
|
|
645
|
-
"baseball",
|
|
646
|
-
"iloveyou",
|
|
647
|
-
"master",
|
|
648
|
-
"sunshine",
|
|
649
|
-
"ashley",
|
|
650
|
-
"football",
|
|
651
|
-
"password1",
|
|
652
|
-
"shadow",
|
|
653
|
-
"123123",
|
|
654
|
-
"654321"
|
|
655
|
-
];
|
|
656
|
-
if (commonPasswords.includes(password.toLowerCase())) {
|
|
657
|
-
errors.push(
|
|
658
|
-
"This password is too common. Please choose a more secure password"
|
|
659
|
-
);
|
|
660
|
-
}
|
|
661
|
-
if (/^[a-zA-Z]+$/.test(password) || /^[0-9]+$/.test(password)) {
|
|
662
|
-
errors.push(
|
|
663
|
-
"Password must contain a mix of letters, numbers, and/or special characters"
|
|
664
|
-
);
|
|
665
|
-
}
|
|
666
|
-
if (/(.)\1{2,}/.test(password)) {
|
|
667
|
-
errors.push(
|
|
668
|
-
"Password must not contain more than 2 consecutive identical characters"
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
if (/^(012|123|234|345|456|567|678|789|890|098|987|876|765|654|543|432|321|210)+$/i.test(
|
|
672
|
-
password
|
|
673
|
-
)) {
|
|
674
|
-
errors.push("Password must not contain sequential numbers or letters");
|
|
675
|
-
}
|
|
676
|
-
return {
|
|
677
|
-
valid: errors.length === 0,
|
|
678
|
-
errors
|
|
679
|
-
};
|
|
680
|
-
}
|
|
681
|
-
async checkReuse(passwordHash, history, verifyFn) {
|
|
682
|
-
return {
|
|
683
|
-
valid: true,
|
|
684
|
-
errors: []
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
async isInHistory(password, history, verifyFn) {
|
|
688
|
-
for (const hash of history) {
|
|
689
|
-
if (await verifyFn(password, hash)) {
|
|
690
|
-
return true;
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
return false;
|
|
694
|
-
}
|
|
695
|
-
generatePassword(length = 16) {
|
|
696
|
-
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
697
|
-
const lowercase = "abcdefghijklmnopqrstuvwxyz";
|
|
698
|
-
const numbers = "0123456789";
|
|
699
|
-
const special = "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
|
700
|
-
let password = "";
|
|
701
|
-
password += uppercase[Math.floor(Math.random() * uppercase.length)];
|
|
702
|
-
password += lowercase[Math.floor(Math.random() * lowercase.length)];
|
|
703
|
-
password += numbers[Math.floor(Math.random() * numbers.length)];
|
|
704
|
-
password += special[Math.floor(Math.random() * special.length)];
|
|
705
|
-
const allChars = uppercase + lowercase + numbers + special;
|
|
706
|
-
for (let i = password.length; i < length; i++) {
|
|
707
|
-
password += allChars[Math.floor(Math.random() * allChars.length)];
|
|
708
|
-
}
|
|
709
|
-
return password.split("").sort(() => Math.random() - 0.5).join("");
|
|
710
|
-
}
|
|
711
|
-
getStrength(password) {
|
|
712
|
-
let score = 0;
|
|
713
|
-
const feedback = [];
|
|
714
|
-
if (password.length >= 8) score += 1;
|
|
715
|
-
if (password.length >= 12) score += 1;
|
|
716
|
-
if (password.length >= 16) score += 1;
|
|
717
|
-
if (/[a-z]/.test(password)) score += 1;
|
|
718
|
-
if (/[A-Z]/.test(password)) score += 1;
|
|
719
|
-
if (/[0-9]/.test(password)) score += 1;
|
|
720
|
-
if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) score += 1;
|
|
721
|
-
if (password.length > 8) score += 1;
|
|
722
|
-
if (password.length > 12) score += 1;
|
|
723
|
-
const uniqueChars = new Set(password).size;
|
|
724
|
-
if (uniqueChars > 6) score += 1;
|
|
725
|
-
if (uniqueChars > 10) score += 1;
|
|
726
|
-
let label;
|
|
727
|
-
if (score <= 3) {
|
|
728
|
-
label = "Weak";
|
|
729
|
-
feedback.push("Add more characters");
|
|
730
|
-
feedback.push("Include uppercase and lowercase letters");
|
|
731
|
-
} else if (score <= 5) {
|
|
732
|
-
label = "Fair";
|
|
733
|
-
feedback.push("Add special characters");
|
|
734
|
-
feedback.push("Consider making it longer");
|
|
735
|
-
} else if (score <= 7) {
|
|
736
|
-
label = "Good";
|
|
737
|
-
feedback.push("Consider making it longer for extra security");
|
|
738
|
-
} else {
|
|
739
|
-
label = "Strong";
|
|
740
|
-
}
|
|
741
|
-
return { score, label, feedback };
|
|
742
|
-
}
|
|
743
|
-
setConfig(config) {
|
|
744
|
-
this.config = { ...this.config, ...config };
|
|
745
|
-
}
|
|
746
|
-
getConfig() {
|
|
747
|
-
return { ...this.config };
|
|
748
|
-
}
|
|
749
|
-
};
|
|
750
|
-
|
|
751
|
-
var __defProp = Object.defineProperty;
|
|
752
|
-
var __export = (target, all) => {
|
|
753
|
-
for (var name in all)
|
|
754
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
755
|
-
};
|
|
756
|
-
|
|
757
|
-
// src/database/drizzle/schema/auth.ts
|
|
758
|
-
var auth_exports = {};
|
|
759
|
-
__export(auth_exports, {
|
|
760
|
-
apiKeys: () => apiKeys,
|
|
761
|
-
auditLogs: () => auditLogs,
|
|
762
|
-
emailVerifications: () => emailVerifications,
|
|
763
|
-
lockouts: () => lockouts,
|
|
764
|
-
passwordHistory: () => passwordHistory,
|
|
765
|
-
passwordResets: () => passwordResets,
|
|
766
|
-
permissions: () => permissions,
|
|
767
|
-
roles: () => roles,
|
|
768
|
-
sessions: () => sessions,
|
|
769
|
-
tenants: () => tenants,
|
|
770
|
-
users: () => users
|
|
771
|
-
});
|
|
772
|
-
var users = pgTable(
|
|
773
|
-
"users",
|
|
774
|
-
{
|
|
775
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
776
|
-
email: varchar("email", { length: 255 }).notNull(),
|
|
777
|
-
passwordHash: varchar("password_hash", { length: 255 }),
|
|
778
|
-
role: varchar("role", { length: 50 }).notNull().default("customer"),
|
|
779
|
-
tenantId: uuid("tenant_id"),
|
|
780
|
-
emailVerified: boolean("email_verified").default(false),
|
|
781
|
-
locked: boolean("locked").default(false),
|
|
782
|
-
lastLogin: timestamp("last_login"),
|
|
783
|
-
failedLoginAttempts: integer("failed_login_attempts").default(0),
|
|
784
|
-
metadata: jsonb("metadata").$type(),
|
|
785
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
786
|
-
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
787
|
-
},
|
|
788
|
-
(table) => [
|
|
789
|
-
uniqueIndex("users_email_idx").on(table.email),
|
|
790
|
-
index("users_tenant_idx").on(table.tenantId),
|
|
791
|
-
index("users_role_idx").on(table.role)
|
|
792
|
-
]
|
|
793
|
-
);
|
|
794
|
-
var roles = pgTable(
|
|
795
|
-
"roles",
|
|
796
|
-
{
|
|
797
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
798
|
-
name: varchar("name", { length: 100 }).notNull().unique(),
|
|
799
|
-
level: integer("level").notNull().default(0),
|
|
800
|
-
inherits: text("inherits").array(),
|
|
801
|
-
description: text("description"),
|
|
802
|
-
permissions: jsonb("permissions").$type().default([]),
|
|
803
|
-
isSystem: boolean("is_system").default(false),
|
|
804
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
805
|
-
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
806
|
-
},
|
|
807
|
-
(table) => [index("roles_level_idx").on(table.level)]
|
|
808
|
-
);
|
|
809
|
-
var permissions = pgTable(
|
|
810
|
-
"permissions",
|
|
811
|
-
{
|
|
812
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
813
|
-
roleId: uuid("role_id").references(() => roles.id, { onDelete: "cascade" }),
|
|
814
|
-
resource: varchar("resource", { length: 100 }).notNull(),
|
|
815
|
-
action: varchar("action", { length: 50 }).notNull(),
|
|
816
|
-
conditions: jsonb("conditions").$type(),
|
|
817
|
-
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
818
|
-
},
|
|
819
|
-
(table) => [
|
|
820
|
-
index("permissions_role_idx").on(table.roleId),
|
|
821
|
-
index("permissions_resource_idx").on(table.resource)
|
|
822
|
-
]
|
|
823
|
-
);
|
|
824
|
-
var sessions = pgTable(
|
|
825
|
-
"sessions",
|
|
826
|
-
{
|
|
827
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
828
|
-
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
829
|
-
token: varchar("token", { length: 512 }).notNull().unique(),
|
|
830
|
-
refreshToken: varchar("refresh_token", { length: 512 }),
|
|
831
|
-
ipAddress: varchar("ip_address", { length: 45 }),
|
|
832
|
-
userAgent: text("user_agent"),
|
|
833
|
-
expiresAt: timestamp("expires_at").notNull(),
|
|
834
|
-
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
835
|
-
},
|
|
836
|
-
(table) => [
|
|
837
|
-
index("sessions_user_idx").on(table.userId),
|
|
838
|
-
index("sessions_token_idx").on(table.token),
|
|
839
|
-
index("sessions_expires_idx").on(table.expiresAt)
|
|
840
|
-
]
|
|
841
|
-
);
|
|
842
|
-
var auditLogs = pgTable(
|
|
843
|
-
"audit_logs",
|
|
844
|
-
{
|
|
845
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
846
|
-
action: varchar("action", { length: 100 }).notNull(),
|
|
847
|
-
userId: uuid("user_id").references(() => users.id, {
|
|
848
|
-
onDelete: "set null"
|
|
849
|
-
}),
|
|
850
|
-
userEmail: varchar("user_email", { length: 255 }),
|
|
851
|
-
role: varchar("role", { length: 50 }),
|
|
852
|
-
resource: varchar("resource", { length: 100 }).notNull(),
|
|
853
|
-
resourceId: uuid("resource_id"),
|
|
854
|
-
changes: jsonb("changes").$type(),
|
|
855
|
-
ipAddress: varchar("ip_address", { length: 45 }),
|
|
856
|
-
userAgent: text("user_agent"),
|
|
857
|
-
success: boolean("success").notNull().default(true),
|
|
858
|
-
error: text("error"),
|
|
859
|
-
metadata: jsonb("metadata").$type(),
|
|
860
|
-
timestamp: timestamp("timestamp").defaultNow().notNull()
|
|
861
|
-
},
|
|
862
|
-
(table) => [
|
|
863
|
-
index("audit_logs_user_idx").on(table.userId),
|
|
864
|
-
index("audit_logs_action_idx").on(table.action),
|
|
865
|
-
index("audit_logs_resource_idx").on(table.resource),
|
|
866
|
-
index("audit_logs_timestamp_idx").on(table.timestamp)
|
|
867
|
-
]
|
|
868
|
-
);
|
|
869
|
-
var tenants = pgTable(
|
|
870
|
-
"tenants",
|
|
871
|
-
{
|
|
872
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
873
|
-
name: varchar("name", { length: 255 }).notNull(),
|
|
874
|
-
slug: varchar("slug", { length: 100 }).notNull().unique(),
|
|
875
|
-
settings: jsonb("settings").$type().default({}),
|
|
876
|
-
isActive: boolean("is_active").default(true),
|
|
877
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
878
|
-
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
879
|
-
},
|
|
880
|
-
(table) => [uniqueIndex("tenants_slug_idx").on(table.slug)]
|
|
881
|
-
);
|
|
882
|
-
var apiKeys = pgTable(
|
|
883
|
-
"api_keys",
|
|
884
|
-
{
|
|
885
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
886
|
-
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
887
|
-
name: varchar("name", { length: 255 }).notNull(),
|
|
888
|
-
key: varchar("key", { length: 64 }).notNull().unique(),
|
|
889
|
-
keyPrefix: varchar("key_prefix", { length: 8 }).notNull(),
|
|
890
|
-
permissions: jsonb("permissions").$type().default([]),
|
|
891
|
-
lastUsedAt: timestamp("last_used_at"),
|
|
892
|
-
expiresAt: timestamp("expires_at"),
|
|
893
|
-
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
894
|
-
},
|
|
895
|
-
(table) => [
|
|
896
|
-
index("api_keys_user_idx").on(table.userId),
|
|
897
|
-
index("api_keys_key_idx").on(table.key)
|
|
898
|
-
]
|
|
899
|
-
);
|
|
900
|
-
var emailVerifications = pgTable(
|
|
901
|
-
"email_verifications",
|
|
902
|
-
{
|
|
903
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
904
|
-
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
905
|
-
token: varchar("token", { length: 64 }).notNull().unique(),
|
|
906
|
-
expiresAt: timestamp("expires_at").notNull(),
|
|
907
|
-
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
908
|
-
},
|
|
909
|
-
(table) => [
|
|
910
|
-
index("email_verifications_token_idx").on(table.token),
|
|
911
|
-
index("email_verifications_user_idx").on(table.userId)
|
|
912
|
-
]
|
|
913
|
-
);
|
|
914
|
-
var passwordResets = pgTable(
|
|
915
|
-
"password_resets",
|
|
916
|
-
{
|
|
917
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
918
|
-
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
919
|
-
token: varchar("token", { length: 64 }).notNull().unique(),
|
|
920
|
-
expiresAt: timestamp("expires_at").notNull(),
|
|
921
|
-
usedAt: timestamp("used_at"),
|
|
922
|
-
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
923
|
-
},
|
|
924
|
-
(table) => [
|
|
925
|
-
index("password_resets_token_idx").on(table.token),
|
|
926
|
-
index("password_resets_user_idx").on(table.userId)
|
|
927
|
-
]
|
|
928
|
-
);
|
|
929
|
-
var passwordHistory = pgTable(
|
|
930
|
-
"password_history",
|
|
931
|
-
{
|
|
932
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
933
|
-
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
934
|
-
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
|
935
|
-
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
936
|
-
},
|
|
937
|
-
(table) => [index("password_history_user_idx").on(table.userId)]
|
|
938
|
-
);
|
|
939
|
-
var lockouts = pgTable(
|
|
940
|
-
"lockouts",
|
|
941
|
-
{
|
|
942
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
943
|
-
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
944
|
-
ipAddress: varchar("ip_address", { length: 45 }),
|
|
945
|
-
reason: varchar("reason", { length: 255 }),
|
|
946
|
-
lockedUntil: timestamp("locked_until").notNull(),
|
|
947
|
-
releasedAt: timestamp("released_at"),
|
|
948
|
-
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
949
|
-
},
|
|
950
|
-
(table) => [
|
|
951
|
-
index("lockouts_user_idx").on(table.userId),
|
|
952
|
-
index("lockouts_ip_idx").on(table.ipAddress),
|
|
953
|
-
index("lockouts_locked_until_idx").on(table.lockedUntil)
|
|
954
|
-
]
|
|
955
|
-
);
|
|
956
|
-
|
|
957
|
-
// src/registry/validator.ts
|
|
958
|
-
var ConfigValidationError = class extends Error {
|
|
959
|
-
errors;
|
|
960
|
-
constructor(errors) {
|
|
961
|
-
super(`Configuration validation failed:
|
|
962
|
-
${errors.join("\n")}`);
|
|
963
|
-
this.name = "ConfigValidationError";
|
|
964
|
-
this.errors = errors;
|
|
965
|
-
}
|
|
966
|
-
};
|
|
967
|
-
function validateCollection(config) {
|
|
968
|
-
const errors = [];
|
|
969
|
-
if (!config.slug) {
|
|
970
|
-
errors.push(`Collection is missing a "slug" property`);
|
|
971
|
-
} else if (!/^[a-z][a-z0-9_-]*$/.test(config.slug)) {
|
|
972
|
-
errors.push(`Collection slug "${config.slug}" must be lowercase alphanumeric with dashes`);
|
|
973
|
-
}
|
|
974
|
-
if (!config.fields || config.fields.length === 0) {
|
|
975
|
-
errors.push(`Collection "${config.slug}" has no fields defined`);
|
|
976
|
-
} else {
|
|
977
|
-
const fieldErrors = validateFields(config.fields, config.slug);
|
|
978
|
-
errors.push(...fieldErrors);
|
|
979
|
-
}
|
|
980
|
-
if (config.access) {
|
|
981
|
-
for (const [action, handler] of Object.entries(config.access)) {
|
|
982
|
-
if (typeof handler !== "boolean" && typeof handler !== "function") {
|
|
983
|
-
errors.push(`Collection "${config.slug}" has invalid access.${action} (must be boolean or function)`);
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
if (config.admin?.useAsTitle) {
|
|
988
|
-
const fieldExists = config.fields.some((f) => f.name === config.admin.useAsTitle);
|
|
989
|
-
if (!fieldExists) {
|
|
990
|
-
errors.push(`Collection "${config.slug}" admin.useAsTitle references unknown field "${config.admin.useAsTitle}"`);
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
if (config.admin?.defaultColumns) {
|
|
994
|
-
for (const col of config.admin.defaultColumns) {
|
|
995
|
-
const fieldExists = config.fields.some((f) => f.name === col);
|
|
996
|
-
if (!fieldExists) {
|
|
997
|
-
errors.push(`Collection "${config.slug}" admin.defaultColumns references unknown field "${col}"`);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
if (config.upload) {
|
|
1002
|
-
if (config.upload.fileSize && config.upload.fileSize <= 0) {
|
|
1003
|
-
errors.push(`Collection "${config.slug}" upload.fileSize must be positive`);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
if (config.versions) {
|
|
1007
|
-
if (config.versions.maxPerDoc && config.versions.maxPerDoc <= 0) {
|
|
1008
|
-
errors.push(`Collection "${config.slug}" versions.maxPerDoc must be positive`);
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
if (config.auth) {
|
|
1012
|
-
const hasEmailField = config.fields.some((f) => f.name === "email");
|
|
1013
|
-
const hasPasswordField = config.fields.some((f) => f.name === "password");
|
|
1014
|
-
if (!hasEmailField) {
|
|
1015
|
-
errors.push(`Collection "${config.slug}" with auth enabled requires an "email" field`);
|
|
1016
|
-
}
|
|
1017
|
-
if (!hasPasswordField) {
|
|
1018
|
-
errors.push(`Collection "${config.slug}" with auth enabled requires a "password" field`);
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
return errors;
|
|
1022
|
-
}
|
|
1023
|
-
function validateGlobal(config) {
|
|
1024
|
-
const errors = [];
|
|
1025
|
-
if (!config.slug) {
|
|
1026
|
-
errors.push(`Global is missing a "slug" property`);
|
|
1027
|
-
} else if (!/^[a-z][a-z0-9_-]*$/.test(config.slug)) {
|
|
1028
|
-
errors.push(`Global slug "${config.slug}" must be lowercase alphanumeric with dashes`);
|
|
1029
|
-
}
|
|
1030
|
-
if (!config.fields || config.fields.length === 0) {
|
|
1031
|
-
errors.push(`Global "${config.slug}" has no fields defined`);
|
|
1032
|
-
} else {
|
|
1033
|
-
const fieldErrors = validateFields(config.fields, `global:${config.slug}`);
|
|
1034
|
-
errors.push(...fieldErrors);
|
|
1035
|
-
}
|
|
1036
|
-
return errors;
|
|
1037
|
-
}
|
|
1038
|
-
function validateFields(fields, context) {
|
|
1039
|
-
const errors = [];
|
|
1040
|
-
const fieldNames = /* @__PURE__ */ new Set();
|
|
1041
|
-
for (let i = 0; i < fields.length; i++) {
|
|
1042
|
-
const field = fields[i];
|
|
1043
|
-
if (field.type === "row" || field.type === "collapsible" || field.type === "tabs") {
|
|
1044
|
-
if ("fields" in field && field.fields) {
|
|
1045
|
-
const nestedErrors = validateFields(field.fields, context);
|
|
1046
|
-
errors.push(...nestedErrors);
|
|
1047
|
-
} else if ("tabs" in field) {
|
|
1048
|
-
for (const tab of field.tabs) {
|
|
1049
|
-
const tabErrors = validateFields(tab.fields, context);
|
|
1050
|
-
errors.push(...tabErrors);
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
continue;
|
|
1054
|
-
}
|
|
1055
|
-
const fieldName = field.name;
|
|
1056
|
-
if (!fieldName) {
|
|
1057
|
-
errors.push(`${context}: Field at index ${i} is missing a "name" property`);
|
|
1058
|
-
continue;
|
|
1059
|
-
}
|
|
1060
|
-
if (fieldNames.has(fieldName)) {
|
|
1061
|
-
errors.push(`${context}: Duplicate field name "${fieldName}"`);
|
|
1062
|
-
}
|
|
1063
|
-
fieldNames.add(fieldName);
|
|
1064
|
-
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(fieldName)) {
|
|
1065
|
-
errors.push(`${context}: Field name "${fieldName}" must be alphanumeric with underscores`);
|
|
1066
|
-
}
|
|
1067
|
-
if (!field.type) {
|
|
1068
|
-
errors.push(`${context}: Field "${fieldName}" is missing a "type" property`);
|
|
1069
|
-
continue;
|
|
1070
|
-
}
|
|
1071
|
-
switch (field.type) {
|
|
1072
|
-
case "relationship":
|
|
1073
|
-
if (!field.relationTo) {
|
|
1074
|
-
errors.push(`${context}: Relationship field "${fieldName}" is missing "relationTo"`);
|
|
1075
|
-
}
|
|
1076
|
-
break;
|
|
1077
|
-
case "array":
|
|
1078
|
-
if (!field.fields || field.fields.length === 0) {
|
|
1079
|
-
errors.push(`${context}: Array field "${fieldName}" has no fields defined`);
|
|
1080
|
-
} else {
|
|
1081
|
-
const arrayErrors = validateFields(field.fields, `${context}.${fieldName}`);
|
|
1082
|
-
errors.push(...arrayErrors);
|
|
1083
|
-
}
|
|
1084
|
-
break;
|
|
1085
|
-
case "group":
|
|
1086
|
-
if (!field.fields || field.fields.length === 0) {
|
|
1087
|
-
errors.push(`${context}: Group field "${fieldName}" has no fields defined`);
|
|
1088
|
-
} else {
|
|
1089
|
-
const groupErrors = validateFields(field.fields, `${context}.${fieldName}`);
|
|
1090
|
-
errors.push(...groupErrors);
|
|
1091
|
-
}
|
|
1092
|
-
break;
|
|
1093
|
-
case "blocks":
|
|
1094
|
-
if (!field.blocks || field.blocks.length === 0) {
|
|
1095
|
-
errors.push(`${context}: Blocks field "${fieldName}" has no blocks defined`);
|
|
1096
|
-
} else {
|
|
1097
|
-
const blockErrors = validateBlocks(field.blocks, `${context}.${fieldName}`);
|
|
1098
|
-
errors.push(...blockErrors);
|
|
1099
|
-
}
|
|
1100
|
-
break;
|
|
1101
|
-
case "select":
|
|
1102
|
-
case "radio":
|
|
1103
|
-
if (!field.options || field.options.length === 0) {
|
|
1104
|
-
errors.push(`${context}: ${field.type} field "${fieldName}" has no options defined`);
|
|
1105
|
-
} else {
|
|
1106
|
-
const values = field.options.map((o) => o.value);
|
|
1107
|
-
const uniqueValues = new Set(values);
|
|
1108
|
-
if (values.length !== uniqueValues.size) {
|
|
1109
|
-
errors.push(`${context}: ${field.type} field "${fieldName}" has duplicate option values`);
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
break;
|
|
1113
|
-
case "upload":
|
|
1114
|
-
if (!field.relationTo) {
|
|
1115
|
-
errors.push(`${context}: Upload field "${fieldName}" is missing "relationTo"`);
|
|
1116
|
-
}
|
|
1117
|
-
break;
|
|
1118
|
-
}
|
|
1119
|
-
if ("min" in field && "max" in field && field.min > field.max) {
|
|
1120
|
-
errors.push(`${context}: Field "${fieldName}" has min greater than max`);
|
|
1121
|
-
}
|
|
1122
|
-
if ("minLength" in field && "maxLength" in field && field.minLength > field.maxLength) {
|
|
1123
|
-
errors.push(`${context}: Field "${fieldName}" has minLength greater than maxLength`);
|
|
1124
|
-
}
|
|
1125
|
-
if ("minRows" in field && "maxRows" in field && field.minRows > field.maxRows) {
|
|
1126
|
-
errors.push(`${context}: Field "${fieldName}" has minRows greater than maxRows`);
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
return errors;
|
|
1130
|
-
}
|
|
1131
|
-
function validateBlocks(blocks, context) {
|
|
1132
|
-
const errors = [];
|
|
1133
|
-
const slugs = /* @__PURE__ */ new Set();
|
|
1134
|
-
for (const block of blocks) {
|
|
1135
|
-
if (!block.slug) {
|
|
1136
|
-
errors.push(`${context}: Block is missing a "slug" property`);
|
|
1137
|
-
continue;
|
|
1138
|
-
}
|
|
1139
|
-
if (slugs.has(block.slug)) {
|
|
1140
|
-
errors.push(`${context}: Duplicate block slug "${block.slug}"`);
|
|
1141
|
-
}
|
|
1142
|
-
slugs.add(block.slug);
|
|
1143
|
-
if (!block.label) {
|
|
1144
|
-
errors.push(`${context}: Block "${block.slug}" is missing a "label" property`);
|
|
1145
|
-
}
|
|
1146
|
-
if (!block.fields || block.fields.length === 0) {
|
|
1147
|
-
errors.push(`${context}: Block "${block.slug}" has no fields defined`);
|
|
1148
|
-
} else {
|
|
1149
|
-
const blockErrors = validateFields(block.fields, `${context}.${block.slug}`);
|
|
1150
|
-
errors.push(...blockErrors);
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
return errors;
|
|
1154
|
-
}
|
|
1155
|
-
function validateConfig(collections, globals = []) {
|
|
1156
|
-
const errors = [];
|
|
1157
|
-
const slugs = /* @__PURE__ */ new Set();
|
|
1158
|
-
for (const collection of collections) {
|
|
1159
|
-
if (slugs.has(collection.slug)) {
|
|
1160
|
-
errors.push(`Duplicate collection slug "${collection.slug}"`);
|
|
1161
|
-
}
|
|
1162
|
-
slugs.add(collection.slug);
|
|
1163
|
-
}
|
|
1164
|
-
for (const global of globals) {
|
|
1165
|
-
if (slugs.has(global.slug)) {
|
|
1166
|
-
errors.push(`Duplicate global slug "${global.slug}"`);
|
|
1167
|
-
}
|
|
1168
|
-
slugs.add(global.slug);
|
|
1169
|
-
}
|
|
1170
|
-
for (const collection of collections) {
|
|
1171
|
-
const collectionErrors = validateCollection(collection);
|
|
1172
|
-
errors.push(...collectionErrors);
|
|
1173
|
-
}
|
|
1174
|
-
for (const global of globals) {
|
|
1175
|
-
const globalErrors = validateGlobal(global);
|
|
1176
|
-
errors.push(...globalErrors);
|
|
1177
|
-
}
|
|
1178
|
-
for (const collection of collections) {
|
|
1179
|
-
const relationshipErrors = validateRelationships(collection.fields, collections);
|
|
1180
|
-
errors.push(...relationshipErrors);
|
|
1181
|
-
}
|
|
1182
|
-
if (errors.length > 0) {
|
|
1183
|
-
throw new ConfigValidationError(errors);
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
function validateRelationships(fields, collections) {
|
|
1187
|
-
const errors = [];
|
|
1188
|
-
const collectionSlugs = new Set(collections.map((c) => c.slug));
|
|
1189
|
-
for (const field of fields) {
|
|
1190
|
-
if (field.type === "relationship") {
|
|
1191
|
-
const targets = Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo];
|
|
1192
|
-
for (const target of targets) {
|
|
1193
|
-
if (!collectionSlugs.has(target)) {
|
|
1194
|
-
errors.push(`Relationship field "${field.name}" references unknown collection "${target}"`);
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
if (field.type === "upload") {
|
|
1199
|
-
const targets = Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo];
|
|
1200
|
-
for (const target of targets) {
|
|
1201
|
-
if (!collectionSlugs.has(target)) {
|
|
1202
|
-
errors.push(`Upload field "${field.name}" references unknown collection "${target}"`);
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
if ("fields" in field && field.fields) {
|
|
1207
|
-
const nestedErrors = validateRelationships(field.fields, collections);
|
|
1208
|
-
errors.push(...nestedErrors);
|
|
1209
|
-
}
|
|
1210
|
-
if ("tabs" in field) {
|
|
1211
|
-
for (const tab of field.tabs) {
|
|
1212
|
-
const tabErrors = validateRelationships(tab.fields, collections);
|
|
1213
|
-
errors.push(...tabErrors);
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
if ("blocks" in field) {
|
|
1217
|
-
for (const block of field.blocks) {
|
|
1218
|
-
const blockErrors = validateRelationships(block.fields, collections);
|
|
1219
|
-
errors.push(...blockErrors);
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
return errors;
|
|
1224
|
-
}
|
|
1225
|
-
function fieldToZod(field) {
|
|
1226
|
-
switch (field.type) {
|
|
1227
|
-
case "text":
|
|
1228
|
-
return textToZod(field);
|
|
1229
|
-
case "number":
|
|
1230
|
-
return numberToZod(field);
|
|
1231
|
-
case "checkbox":
|
|
1232
|
-
return checkboxToZod(field);
|
|
1233
|
-
case "date":
|
|
1234
|
-
return dateToZod(field);
|
|
1235
|
-
case "email":
|
|
1236
|
-
return emailToZod(field);
|
|
1237
|
-
case "password":
|
|
1238
|
-
return passwordToZod(field);
|
|
1239
|
-
case "textarea":
|
|
1240
|
-
return textareaToZod(field);
|
|
1241
|
-
case "select":
|
|
1242
|
-
return selectToZod(field);
|
|
1243
|
-
case "radio":
|
|
1244
|
-
return radioToZod(field);
|
|
1245
|
-
case "color":
|
|
1246
|
-
return colorToZod(field);
|
|
1247
|
-
case "richtext":
|
|
1248
|
-
return richTextToZod(field);
|
|
1249
|
-
case "json":
|
|
1250
|
-
return jsonToZod(field);
|
|
1251
|
-
case "code":
|
|
1252
|
-
return codeToZod(field);
|
|
1253
|
-
case "upload":
|
|
1254
|
-
return uploadToZod(field);
|
|
1255
|
-
case "markdown":
|
|
1256
|
-
return markdownToZod(field);
|
|
1257
|
-
case "relationship":
|
|
1258
|
-
return relationshipToZod(field);
|
|
1259
|
-
case "array":
|
|
1260
|
-
return arrayToZod(field);
|
|
1261
|
-
case "group":
|
|
1262
|
-
return groupToZod(field);
|
|
1263
|
-
case "blocks":
|
|
1264
|
-
return blocksToZod(field);
|
|
1265
|
-
case "row":
|
|
1266
|
-
return rowToZod(field);
|
|
1267
|
-
case "collapsible":
|
|
1268
|
-
return collapsibleToZod(field);
|
|
1269
|
-
case "tabs":
|
|
1270
|
-
return tabsToZod(field);
|
|
1271
|
-
default:
|
|
1272
|
-
return z.any();
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
function textToZod(field) {
|
|
1276
|
-
let schema = z.string();
|
|
1277
|
-
if (field.minLength) schema = schema.min(field.minLength);
|
|
1278
|
-
if (field.maxLength) schema = schema.max(field.maxLength);
|
|
1279
|
-
if (field.pattern) schema = schema.regex(new RegExp(field.pattern));
|
|
1280
|
-
if (field.variant === "email") schema = schema.email();
|
|
1281
|
-
if (field.variant === "url") schema = schema.url();
|
|
1282
|
-
if (field.hasMany) schema = z.array(schema);
|
|
1283
|
-
if (!field.required) schema = schema.optional();
|
|
1284
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1285
|
-
return schema;
|
|
1286
|
-
}
|
|
1287
|
-
function numberToZod(field) {
|
|
1288
|
-
let schema = field.integer ? z.number().int() : z.number();
|
|
1289
|
-
if (field.min !== void 0) schema = schema.min(field.min);
|
|
1290
|
-
if (field.max !== void 0) schema = schema.max(field.max);
|
|
1291
|
-
if (field.step) {
|
|
1292
|
-
schema = schema.refine(
|
|
1293
|
-
(val) => Number.isInteger(val / field.step),
|
|
1294
|
-
`Value must be divisible by ${field.step}`
|
|
1295
|
-
);
|
|
1296
|
-
}
|
|
1297
|
-
if (field.hasMany) schema = z.array(schema);
|
|
1298
|
-
if (!field.required) schema = schema.optional();
|
|
1299
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1300
|
-
return schema;
|
|
1301
|
-
}
|
|
1302
|
-
function checkboxToZod(field) {
|
|
1303
|
-
let schema = z.boolean();
|
|
1304
|
-
if (!field.required) schema = schema.optional();
|
|
1305
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1306
|
-
return schema;
|
|
1307
|
-
}
|
|
1308
|
-
function dateToZod(field) {
|
|
1309
|
-
let schema = z.string().refine(
|
|
1310
|
-
(val) => !isNaN(Date.parse(val)),
|
|
1311
|
-
"Invalid date format"
|
|
1312
|
-
);
|
|
1313
|
-
if (field.minDate) {
|
|
1314
|
-
schema = schema.refine(
|
|
1315
|
-
(val) => new Date(val) >= new Date(field.minDate),
|
|
1316
|
-
`Date must be after ${field.minDate}`
|
|
1317
|
-
);
|
|
1318
|
-
}
|
|
1319
|
-
if (field.maxDate) {
|
|
1320
|
-
schema = schema.refine(
|
|
1321
|
-
(val) => new Date(val) <= new Date(field.maxDate),
|
|
1322
|
-
`Date must be before ${field.maxDate}`
|
|
1323
|
-
);
|
|
1324
|
-
}
|
|
1325
|
-
if (!field.required) schema = schema.optional();
|
|
1326
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1327
|
-
return schema;
|
|
1328
|
-
}
|
|
1329
|
-
function emailToZod(field) {
|
|
1330
|
-
let schema = z.string().email("Invalid email");
|
|
1331
|
-
if (!field.required) schema = schema.optional();
|
|
1332
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1333
|
-
return schema;
|
|
1334
|
-
}
|
|
1335
|
-
function passwordToZod(field) {
|
|
1336
|
-
let schema = z.string().min(6, "Password must be at least 6 characters");
|
|
1337
|
-
if (!field.required) schema = schema.optional();
|
|
1338
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1339
|
-
return schema;
|
|
1340
|
-
}
|
|
1341
|
-
function textareaToZod(field) {
|
|
1342
|
-
let schema = z.string();
|
|
1343
|
-
if (field.minLength) schema = schema.min(field.minLength);
|
|
1344
|
-
if (field.maxLength) schema = schema.max(field.maxLength);
|
|
1345
|
-
if (!field.required) schema = schema.optional();
|
|
1346
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1347
|
-
return schema;
|
|
1348
|
-
}
|
|
1349
|
-
function selectToZod(field) {
|
|
1350
|
-
const values = field.options.map((opt) => opt.value);
|
|
1351
|
-
let schema;
|
|
1352
|
-
if (field.hasMany) {
|
|
1353
|
-
schema = z.array(z.enum(values));
|
|
1354
|
-
} else {
|
|
1355
|
-
schema = z.enum(values);
|
|
1356
|
-
}
|
|
1357
|
-
if (!field.required) schema = schema.optional();
|
|
1358
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1359
|
-
return schema;
|
|
1360
|
-
}
|
|
1361
|
-
function radioToZod(field) {
|
|
1362
|
-
const values = field.options.map((opt) => opt.value);
|
|
1363
|
-
let schema = z.enum(values);
|
|
1364
|
-
if (!field.required) schema = schema.optional();
|
|
1365
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1366
|
-
return schema;
|
|
1367
|
-
}
|
|
1368
|
-
function colorToZod(field) {
|
|
1369
|
-
let schema = z.string();
|
|
1370
|
-
if (field.format === "hex") schema = schema.regex(/^#[0-9A-Fa-f]{6}$/);
|
|
1371
|
-
if (field.format === "rgb") schema = schema.regex(/^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/);
|
|
1372
|
-
if (field.format === "hsl") schema = schema.regex(/^hsl\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*\)$/);
|
|
1373
|
-
if (!field.required) schema = schema.optional();
|
|
1374
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1375
|
-
return schema;
|
|
1376
|
-
}
|
|
1377
|
-
function richTextToZod(field) {
|
|
1378
|
-
let schema = z.array(z.object({
|
|
1379
|
-
type: z.string(),
|
|
1380
|
-
data: z.record(z.any()),
|
|
1381
|
-
children: z.array(z.any()).optional()
|
|
1382
|
-
}));
|
|
1383
|
-
if (!field.required) schema = schema.optional();
|
|
1384
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1385
|
-
return schema;
|
|
1386
|
-
}
|
|
1387
|
-
function jsonToZod(field) {
|
|
1388
|
-
let schema = z.record(z.any());
|
|
1389
|
-
if (!field.required) schema = schema.optional();
|
|
1390
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1391
|
-
return schema;
|
|
1392
|
-
}
|
|
1393
|
-
function codeToZod(field) {
|
|
1394
|
-
let schema = z.string();
|
|
1395
|
-
if (!field.required) schema = schema.optional();
|
|
1396
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1397
|
-
return schema;
|
|
1398
|
-
}
|
|
1399
|
-
function uploadToZod(field) {
|
|
1400
|
-
let schema = z.string();
|
|
1401
|
-
if (field.hasMany) schema = z.array(z.string());
|
|
1402
|
-
if (!field.required) schema = schema.optional();
|
|
1403
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1404
|
-
return schema;
|
|
1405
|
-
}
|
|
1406
|
-
function markdownToZod(field) {
|
|
1407
|
-
let schema = z.string();
|
|
1408
|
-
if (!field.required) schema = schema.optional();
|
|
1409
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1410
|
-
return schema;
|
|
1411
|
-
}
|
|
1412
|
-
function relationshipToZod(field) {
|
|
1413
|
-
let schema = z.string();
|
|
1414
|
-
if (Array.isArray(field.relationTo)) {
|
|
1415
|
-
schema = z.object({
|
|
1416
|
-
relationTo: z.enum(field.relationTo),
|
|
1417
|
-
value: z.string()
|
|
1418
|
-
});
|
|
1419
|
-
}
|
|
1420
|
-
if (field.hasMany) schema = z.array(schema);
|
|
1421
|
-
if (!field.required) schema = schema.optional();
|
|
1422
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1423
|
-
return schema;
|
|
1424
|
-
}
|
|
1425
|
-
function arrayToZod(field) {
|
|
1426
|
-
const itemSchema = z.object(
|
|
1427
|
-
Object.fromEntries(
|
|
1428
|
-
field.fields.filter((f) => f.name).map((f) => [f.name, fieldToZod(f)])
|
|
1429
|
-
)
|
|
1430
|
-
);
|
|
1431
|
-
let schema = z.array(itemSchema);
|
|
1432
|
-
if (field.minRows) schema = schema.min(field.minRows);
|
|
1433
|
-
if (field.maxRows) schema = schema.max(field.maxRows);
|
|
1434
|
-
if (!field.required) schema = schema.optional();
|
|
1435
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1436
|
-
return schema;
|
|
1437
|
-
}
|
|
1438
|
-
function groupToZod(field) {
|
|
1439
|
-
const schema = z.object(
|
|
1440
|
-
Object.fromEntries(
|
|
1441
|
-
field.fields.filter((f) => f.name).map((f) => [f.name, fieldToZod(f)])
|
|
1442
|
-
)
|
|
1443
|
-
);
|
|
1444
|
-
if (!field.required) return schema.optional();
|
|
1445
|
-
return schema;
|
|
1446
|
-
}
|
|
1447
|
-
function blocksToZod(field) {
|
|
1448
|
-
const blockSchemas = field.blocks.map((block) => {
|
|
1449
|
-
return z.object({
|
|
1450
|
-
blockType: z.literal(block.slug),
|
|
1451
|
-
...Object.fromEntries(
|
|
1452
|
-
block.fields.filter((f) => f.name).map((f) => [f.name, fieldToZod(f)])
|
|
1453
|
-
)
|
|
1454
|
-
});
|
|
1455
|
-
});
|
|
1456
|
-
let schema = z.array(z.discriminatedUnion("blockType", blockSchemas));
|
|
1457
|
-
if (field.minRows) schema = schema.min(field.minRows);
|
|
1458
|
-
if (field.maxRows) schema = schema.max(field.maxRows);
|
|
1459
|
-
if (!field.required) schema = schema.optional();
|
|
1460
|
-
if (field.validate) schema = addCustomValidation(schema, field.validate);
|
|
1461
|
-
return schema;
|
|
1462
|
-
}
|
|
1463
|
-
function rowToZod(field) {
|
|
1464
|
-
const schema = z.object(
|
|
1465
|
-
Object.fromEntries(
|
|
1466
|
-
field.fields.filter((f) => f.name).map((f) => [f.name, fieldToZod(f)])
|
|
1467
|
-
)
|
|
1468
|
-
);
|
|
1469
|
-
return schema;
|
|
1470
|
-
}
|
|
1471
|
-
function collapsibleToZod(field) {
|
|
1472
|
-
const schema = z.object(
|
|
1473
|
-
Object.fromEntries(
|
|
1474
|
-
field.fields.filter((f) => f.name).map((f) => [f.name, fieldToZod(f)])
|
|
1475
|
-
)
|
|
1476
|
-
);
|
|
1477
|
-
return schema;
|
|
1478
|
-
}
|
|
1479
|
-
function tabsToZod(field) {
|
|
1480
|
-
const schemas = {};
|
|
1481
|
-
for (const tab of field.tabs) {
|
|
1482
|
-
for (const f of tab.fields) {
|
|
1483
|
-
if (f.name) {
|
|
1484
|
-
schemas[f.name] = fieldToZod(f);
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1488
|
-
return z.object(schemas);
|
|
1489
|
-
}
|
|
1490
|
-
function addCustomValidation(schema, validate) {
|
|
1491
|
-
return schema.refine(
|
|
1492
|
-
async (val) => {
|
|
1493
|
-
const result = await validate(val, { required: false });
|
|
1494
|
-
return result === true;
|
|
1495
|
-
},
|
|
1496
|
-
{
|
|
1497
|
-
message: "Custom validation failed"
|
|
1498
|
-
}
|
|
1499
|
-
);
|
|
1500
|
-
}
|
|
1501
|
-
function collectionToZod(collection) {
|
|
1502
|
-
const shape = {};
|
|
1503
|
-
for (const field of collection.fields) {
|
|
1504
|
-
if (field.name) {
|
|
1505
|
-
shape[field.name] = fieldToZod(field);
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
if (collection.timestamps) {
|
|
1509
|
-
shape["createdAt"] = z.string().optional();
|
|
1510
|
-
shape["updatedAt"] = z.string().optional();
|
|
1511
|
-
}
|
|
1512
|
-
if (collection.tenantScoped) {
|
|
1513
|
-
shape["tenantID"] = z.string().optional();
|
|
1514
|
-
}
|
|
1515
|
-
shape["id"] = z.string().optional();
|
|
1516
|
-
return z.object(shape);
|
|
1517
|
-
}
|
|
1518
|
-
function collectionToCreateZod(collection) {
|
|
1519
|
-
const shape = {};
|
|
1520
|
-
for (const field of collection.fields) {
|
|
1521
|
-
if (field.name) {
|
|
1522
|
-
shape[field.name] = fieldToZod(field);
|
|
1523
|
-
}
|
|
1524
|
-
}
|
|
1525
|
-
return z.object(shape);
|
|
1526
|
-
}
|
|
1527
|
-
function collectionToUpdateZod(collection) {
|
|
1528
|
-
const shape = {};
|
|
1529
|
-
for (const field of collection.fields) {
|
|
1530
|
-
if (field.name) {
|
|
1531
|
-
shape[field.name] = fieldToZod(field).optional();
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
return z.object(shape);
|
|
1535
|
-
}
|
|
1536
|
-
function collectionToWhereZod(collection) {
|
|
1537
|
-
const shape = {};
|
|
1538
|
-
for (const field of collection.fields) {
|
|
1539
|
-
if (field.name) {
|
|
1540
|
-
shape[field.name] = z.object({
|
|
1541
|
-
equals: z.any().optional(),
|
|
1542
|
-
not_equals: z.any().optional(),
|
|
1543
|
-
in: z.array(z.any()).optional(),
|
|
1544
|
-
not_in: z.array(z.any()).optional(),
|
|
1545
|
-
greater_than: z.number().optional(),
|
|
1546
|
-
greater_than_equal: z.number().optional(),
|
|
1547
|
-
less_than: z.number().optional(),
|
|
1548
|
-
less_than_equal: z.number().optional(),
|
|
1549
|
-
like: z.string().optional(),
|
|
1550
|
-
not_like: z.string().optional(),
|
|
1551
|
-
contains: z.string().optional(),
|
|
1552
|
-
exists: z.boolean().optional()
|
|
1553
|
-
}).optional();
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
shape["AND"] = z.array(z.lazy(() => z.object(shape))).optional();
|
|
1557
|
-
shape["OR"] = z.array(z.lazy(() => z.object(shape))).optional();
|
|
1558
|
-
return z.object(shape).optional();
|
|
1559
|
-
}
|
|
1560
|
-
function globalToZod(global) {
|
|
1561
|
-
const shape = {};
|
|
1562
|
-
for (const field of global.fields) {
|
|
1563
|
-
if (field.name) {
|
|
1564
|
-
shape[field.name] = fieldToZod(field);
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
shape["id"] = z.string().optional();
|
|
1568
|
-
return z.object(shape);
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
// src/registry/index.ts
|
|
1572
|
-
var Registry = class {
|
|
1573
|
-
collections = /* @__PURE__ */ new Map();
|
|
1574
|
-
globals = /* @__PURE__ */ new Map();
|
|
1575
|
-
plugins = [];
|
|
1576
|
-
schemaCache = /* @__PURE__ */ new Map();
|
|
1577
|
-
initialized = false;
|
|
1578
|
-
// ========================================================================
|
|
1579
|
-
// Collection Management
|
|
1580
|
-
// ========================================================================
|
|
1581
|
-
addCollection(config) {
|
|
1582
|
-
if (this.initialized) {
|
|
1583
|
-
throw new Error("Cannot add collections after Registry has been initialized");
|
|
1584
|
-
}
|
|
1585
|
-
let finalConfig = { ...config };
|
|
1586
|
-
for (const plugin of this.plugins) {
|
|
1587
|
-
if (plugin.extendCollection) {
|
|
1588
|
-
finalConfig = plugin.extendCollection(finalConfig.slug, finalConfig);
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
finalConfig.fields = this.applyFieldDefaults(finalConfig);
|
|
1592
|
-
const errors = validateCollection(finalConfig);
|
|
1593
|
-
if (errors.length > 0) {
|
|
1594
|
-
throw new Error(`Invalid collection config: ${errors.join(", ")}`);
|
|
1595
|
-
}
|
|
1596
|
-
this.collections.set(finalConfig.slug, finalConfig);
|
|
1597
|
-
this.clearSchemaCache(finalConfig.slug);
|
|
1598
|
-
}
|
|
1599
|
-
addCollections(configs) {
|
|
1600
|
-
for (const config of configs) {
|
|
1601
|
-
this.addCollection(config);
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
getCollection(slug) {
|
|
1605
|
-
return this.collections.get(slug);
|
|
1606
|
-
}
|
|
1607
|
-
getCollections() {
|
|
1608
|
-
return Array.from(this.collections.values());
|
|
1609
|
-
}
|
|
1610
|
-
getCollectionSlugs() {
|
|
1611
|
-
return Array.from(this.collections.keys());
|
|
1612
|
-
}
|
|
1613
|
-
hasCollection(slug) {
|
|
1614
|
-
return this.collections.has(slug);
|
|
1615
|
-
}
|
|
1616
|
-
removeCollection(slug) {
|
|
1617
|
-
if (this.initialized) {
|
|
1618
|
-
throw new Error("Cannot remove collections after Registry has been initialized");
|
|
1619
|
-
}
|
|
1620
|
-
this.clearSchemaCache(slug);
|
|
1621
|
-
return this.collections.delete(slug);
|
|
1622
|
-
}
|
|
1623
|
-
// ========================================================================
|
|
1624
|
-
// Global Management
|
|
1625
|
-
// ========================================================================
|
|
1626
|
-
addGlobal(config) {
|
|
1627
|
-
if (this.initialized) {
|
|
1628
|
-
throw new Error("Cannot add globals after Registry has been initialized");
|
|
1629
|
-
}
|
|
1630
|
-
const errors = validateGlobal(config);
|
|
1631
|
-
if (errors.length > 0) {
|
|
1632
|
-
throw new Error(`Invalid global config: ${errors.join(", ")}`);
|
|
1633
|
-
}
|
|
1634
|
-
let finalConfig = { ...config };
|
|
1635
|
-
for (const plugin of this.plugins) {
|
|
1636
|
-
if (plugin.extendGlobal) {
|
|
1637
|
-
finalConfig = plugin.extendGlobal(finalConfig.slug, finalConfig);
|
|
1638
|
-
}
|
|
1639
|
-
}
|
|
1640
|
-
this.globals.set(finalConfig.slug, finalConfig);
|
|
1641
|
-
this.clearSchemaCache(`global:${finalConfig.slug}`);
|
|
1642
|
-
}
|
|
1643
|
-
addGlobals(configs) {
|
|
1644
|
-
for (const config of configs) {
|
|
1645
|
-
this.addGlobal(config);
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
getGlobal(slug) {
|
|
1649
|
-
return this.globals.get(slug);
|
|
1650
|
-
}
|
|
1651
|
-
getGlobals() {
|
|
1652
|
-
return Array.from(this.globals.values());
|
|
1653
|
-
}
|
|
1654
|
-
getGlobalSlugs() {
|
|
1655
|
-
return Array.from(this.globals.keys());
|
|
1656
|
-
}
|
|
1657
|
-
hasGlobal(slug) {
|
|
1658
|
-
return this.globals.has(slug);
|
|
1659
|
-
}
|
|
1660
|
-
removeGlobal(slug) {
|
|
1661
|
-
if (this.initialized) {
|
|
1662
|
-
throw new Error("Cannot remove globals after Registry has been initialized");
|
|
1663
|
-
}
|
|
1664
|
-
this.clearSchemaCache(`global:${slug}`);
|
|
1665
|
-
return this.globals.delete(slug);
|
|
1666
|
-
}
|
|
1667
|
-
// ========================================================================
|
|
1668
|
-
// Plugin Management
|
|
1669
|
-
// ========================================================================
|
|
1670
|
-
addPlugin(plugin) {
|
|
1671
|
-
if (this.initialized) {
|
|
1672
|
-
throw new Error("Cannot add plugins after Registry has been initialized");
|
|
1673
|
-
}
|
|
1674
|
-
this.plugins.push(plugin);
|
|
1675
|
-
}
|
|
1676
|
-
getPlugins() {
|
|
1677
|
-
return [...this.plugins];
|
|
1678
|
-
}
|
|
1679
|
-
// ========================================================================
|
|
1680
|
-
// Schema Generation
|
|
1681
|
-
// ========================================================================
|
|
1682
|
-
getZodSchema(slug) {
|
|
1683
|
-
const cached = this.schemaCache.get(slug);
|
|
1684
|
-
if (cached) return cached;
|
|
1685
|
-
const collection = this.collections.get(slug);
|
|
1686
|
-
if (collection) {
|
|
1687
|
-
const schema = collectionToZod(collection);
|
|
1688
|
-
this.schemaCache.set(slug, schema);
|
|
1689
|
-
return schema;
|
|
1690
|
-
}
|
|
1691
|
-
const global = this.globals.get(slug);
|
|
1692
|
-
if (global) {
|
|
1693
|
-
const schema = globalToZod(global);
|
|
1694
|
-
this.schemaCache.set(`global:${slug}`, schema);
|
|
1695
|
-
return schema;
|
|
1696
|
-
}
|
|
1697
|
-
throw new Error(`No collection or global found with slug "${slug}"`);
|
|
1698
|
-
}
|
|
1699
|
-
getCreateZodSchema(slug) {
|
|
1700
|
-
const cacheKey = `${slug}:create`;
|
|
1701
|
-
const cached = this.schemaCache.get(cacheKey);
|
|
1702
|
-
if (cached) return cached;
|
|
1703
|
-
const collection = this.collections.get(slug);
|
|
1704
|
-
if (collection) {
|
|
1705
|
-
const schema = collectionToCreateZod(collection);
|
|
1706
|
-
this.schemaCache.set(cacheKey, schema);
|
|
1707
|
-
return schema;
|
|
1708
|
-
}
|
|
1709
|
-
throw new Error(`No collection found with slug "${slug}"`);
|
|
1710
|
-
}
|
|
1711
|
-
getUpdateZodSchema(slug) {
|
|
1712
|
-
const cacheKey = `${slug}:update`;
|
|
1713
|
-
const cached = this.schemaCache.get(cacheKey);
|
|
1714
|
-
if (cached) return cached;
|
|
1715
|
-
const collection = this.collections.get(slug);
|
|
1716
|
-
if (collection) {
|
|
1717
|
-
const schema = collectionToUpdateZod(collection);
|
|
1718
|
-
this.schemaCache.set(cacheKey, schema);
|
|
1719
|
-
return schema;
|
|
1720
|
-
}
|
|
1721
|
-
throw new Error(`No collection found with slug "${slug}"`);
|
|
1722
|
-
}
|
|
1723
|
-
getWhereZodSchema(slug) {
|
|
1724
|
-
const cacheKey = `${slug}:where`;
|
|
1725
|
-
const cached = this.schemaCache.get(cacheKey);
|
|
1726
|
-
if (cached) return cached;
|
|
1727
|
-
const collection = this.collections.get(slug);
|
|
1728
|
-
if (collection) {
|
|
1729
|
-
const schema = collectionToWhereZod(collection);
|
|
1730
|
-
this.schemaCache.set(cacheKey, schema);
|
|
1731
|
-
return schema;
|
|
1732
|
-
}
|
|
1733
|
-
throw new Error(`No collection found with slug "${slug}"`);
|
|
1734
|
-
}
|
|
1735
|
-
getFieldZodSchema(field) {
|
|
1736
|
-
return fieldToZod(field);
|
|
1737
|
-
}
|
|
1738
|
-
clearSchemaCache(slug) {
|
|
1739
|
-
this.schemaCache.delete(slug);
|
|
1740
|
-
this.schemaCache.delete(`${slug}:create`);
|
|
1741
|
-
this.schemaCache.delete(`${slug}:update`);
|
|
1742
|
-
this.schemaCache.delete(`${slug}:where`);
|
|
1743
|
-
}
|
|
1744
|
-
// ========================================================================
|
|
1745
|
-
// Field Helpers
|
|
1746
|
-
// ========================================================================
|
|
1747
|
-
applyFieldDefaults(config) {
|
|
1748
|
-
const fields = [...config.fields];
|
|
1749
|
-
if (!fields.some((f) => f.name === "id")) {
|
|
1750
|
-
fields.unshift({
|
|
1751
|
-
name: "id",
|
|
1752
|
-
type: "text",
|
|
1753
|
-
admin: { readOnly: true, hidden: true }
|
|
1754
|
-
});
|
|
1755
|
-
}
|
|
1756
|
-
if (config.tenantScoped && !fields.some((f) => f.name === "tenantID")) {
|
|
1757
|
-
fields.push({
|
|
1758
|
-
name: "tenantID",
|
|
1759
|
-
type: "text",
|
|
1760
|
-
required: true,
|
|
1761
|
-
admin: { readOnly: true, hidden: true }
|
|
1762
|
-
});
|
|
1763
|
-
}
|
|
1764
|
-
if (config.timestamps && !fields.some((f) => f.name === "createdAt")) {
|
|
1765
|
-
fields.push({
|
|
1766
|
-
name: "createdAt",
|
|
1767
|
-
type: "date",
|
|
1768
|
-
admin: { readOnly: true, hidden: true }
|
|
1769
|
-
});
|
|
1770
|
-
fields.push({
|
|
1771
|
-
name: "updatedAt",
|
|
1772
|
-
type: "date",
|
|
1773
|
-
admin: { readOnly: true, hidden: true }
|
|
1774
|
-
});
|
|
1775
|
-
}
|
|
1776
|
-
return fields;
|
|
1777
|
-
}
|
|
1778
|
-
getFields(slug) {
|
|
1779
|
-
const collection = this.collections.get(slug);
|
|
1780
|
-
if (collection) return collection.fields;
|
|
1781
|
-
const global = this.globals.get(slug);
|
|
1782
|
-
if (global) return global.fields;
|
|
1783
|
-
throw new Error(`No collection or global found with slug "${slug}"`);
|
|
1784
|
-
}
|
|
1785
|
-
getFieldMap(slug) {
|
|
1786
|
-
const fields = this.getFields(slug);
|
|
1787
|
-
const map = /* @__PURE__ */ new Map();
|
|
1788
|
-
const addFields = (fields2) => {
|
|
1789
|
-
for (const field of fields2) {
|
|
1790
|
-
if (field.name) {
|
|
1791
|
-
map.set(field.name, field);
|
|
1792
|
-
}
|
|
1793
|
-
if ("fields" in field && field.fields) {
|
|
1794
|
-
addFields(field.fields);
|
|
1795
|
-
}
|
|
1796
|
-
if ("tabs" in field) {
|
|
1797
|
-
for (const tab of field.tabs) {
|
|
1798
|
-
addFields(tab.fields);
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
if ("blocks" in field) {
|
|
1802
|
-
for (const block of field.blocks) {
|
|
1803
|
-
addFields(block.fields);
|
|
1804
|
-
}
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
};
|
|
1808
|
-
addFields(fields);
|
|
1809
|
-
return map;
|
|
1810
|
-
}
|
|
1811
|
-
getVisibleFields(slug) {
|
|
1812
|
-
const fields = this.getFields(slug);
|
|
1813
|
-
return fields.filter((f) => !f.admin?.hidden);
|
|
1814
|
-
}
|
|
1815
|
-
// ========================================================================
|
|
1816
|
-
// Initialization
|
|
1817
|
-
// ========================================================================
|
|
1818
|
-
validate() {
|
|
1819
|
-
const collections = this.getCollections();
|
|
1820
|
-
const globals = this.getGlobals();
|
|
1821
|
-
validateConfig(collections, globals);
|
|
1822
|
-
}
|
|
1823
|
-
async init() {
|
|
1824
|
-
this.validate();
|
|
1825
|
-
for (const plugin of this.plugins) {
|
|
1826
|
-
if (plugin.init) {
|
|
1827
|
-
await plugin.init(this);
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
this.initialized = true;
|
|
1831
|
-
}
|
|
1832
|
-
isInitialized() {
|
|
1833
|
-
return this.initialized;
|
|
1834
|
-
}
|
|
1835
|
-
// ========================================================================
|
|
1836
|
-
// Query Helpers
|
|
1837
|
-
// ========================================================================
|
|
1838
|
-
getPaginationDefaults(slug) {
|
|
1839
|
-
const collection = this.collections.get(slug);
|
|
1840
|
-
return {
|
|
1841
|
-
defaultLimit: collection?.admin?.pagination?.defaultLimit || 10,
|
|
1842
|
-
limits: collection?.admin?.pagination?.limits || [10, 25, 50, 100]
|
|
1843
|
-
};
|
|
1844
|
-
}
|
|
1845
|
-
getDefaultSort(slug) {
|
|
1846
|
-
const collection = this.collections.get(slug);
|
|
1847
|
-
const useAsTitle = collection?.admin?.useAsTitle;
|
|
1848
|
-
if (useAsTitle) return useAsTitle;
|
|
1849
|
-
return "createdAt";
|
|
1850
|
-
}
|
|
1851
|
-
getDefaultColumns(slug) {
|
|
1852
|
-
const collection = this.collections.get(slug);
|
|
1853
|
-
if (collection?.admin?.defaultColumns) {
|
|
1854
|
-
return collection.admin.defaultColumns;
|
|
1855
|
-
}
|
|
1856
|
-
const fields = this.getVisibleFields(slug);
|
|
1857
|
-
return fields.slice(0, 4).map((f) => f.name);
|
|
1858
|
-
}
|
|
1859
|
-
// ========================================================================
|
|
1860
|
-
// Admin Helpers
|
|
1861
|
-
// ========================================================================
|
|
1862
|
-
getAdminTitle(slug) {
|
|
1863
|
-
const collection = this.collections.get(slug);
|
|
1864
|
-
return collection?.label || collection?.admin?.description || slug;
|
|
1865
|
-
}
|
|
1866
|
-
getAdminLabel(slug) {
|
|
1867
|
-
const collection = this.collections.get(slug);
|
|
1868
|
-
return collection?.singularLabel || collection?.label || slug;
|
|
1869
|
-
}
|
|
1870
|
-
getAdminGroup(slug) {
|
|
1871
|
-
return this.collections.get(slug)?.admin?.group;
|
|
1872
|
-
}
|
|
1873
|
-
// ========================================================================
|
|
1874
|
-
// Debug / Stats
|
|
1875
|
-
// ========================================================================
|
|
1876
|
-
getStats() {
|
|
1877
|
-
let totalFields = 0;
|
|
1878
|
-
for (const collection of this.collections.values()) {
|
|
1879
|
-
totalFields += collection.fields.length;
|
|
1880
|
-
}
|
|
1881
|
-
for (const global of this.globals.values()) {
|
|
1882
|
-
totalFields += global.fields.length;
|
|
1883
|
-
}
|
|
1884
|
-
return {
|
|
1885
|
-
collections: this.collections.size,
|
|
1886
|
-
globals: this.globals.size,
|
|
1887
|
-
plugins: this.plugins.length,
|
|
1888
|
-
fields: totalFields
|
|
1889
|
-
};
|
|
1890
|
-
}
|
|
1891
|
-
toJSON() {
|
|
1892
|
-
return {
|
|
1893
|
-
collections: this.getCollections(),
|
|
1894
|
-
globals: this.getGlobals()
|
|
1895
|
-
};
|
|
1896
|
-
}
|
|
1897
|
-
};
|
|
1898
|
-
var instance = null;
|
|
1899
|
-
function getRegistry() {
|
|
1900
|
-
if (!instance) {
|
|
1901
|
-
instance = new Registry();
|
|
1902
|
-
}
|
|
1903
|
-
return instance;
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
// src/auth/security/lockout.ts
|
|
1907
|
-
var DEFAULT_LOCKOUT_CONFIG = {
|
|
1908
|
-
maxAttempts: 5,
|
|
1909
|
-
lockDuration: 9e5,
|
|
1910
|
-
notifyUser: true,
|
|
1911
|
-
notifyAdmin: true,
|
|
1912
|
-
adminNotifyAfter: 3
|
|
1913
|
-
};
|
|
1914
|
-
var AccountLockout = class {
|
|
1915
|
-
redis;
|
|
1916
|
-
prefix;
|
|
1917
|
-
config;
|
|
1918
|
-
constructor(redis, config = {}, prefix = "kyro:lockout:") {
|
|
1919
|
-
this.redis = redis;
|
|
1920
|
-
this.prefix = prefix;
|
|
1921
|
-
this.config = { ...DEFAULT_LOCKOUT_CONFIG, ...config };
|
|
1922
|
-
}
|
|
1923
|
-
lockKey(userId) {
|
|
1924
|
-
return `${this.prefix}${userId}`;
|
|
1925
|
-
}
|
|
1926
|
-
historyKey(userId) {
|
|
1927
|
-
return `${this.prefix}${userId}:history`;
|
|
1928
|
-
}
|
|
1929
|
-
async checkLockout(userId) {
|
|
1930
|
-
const key = this.lockKey(userId);
|
|
1931
|
-
const data = await this.redis.hgetall(key);
|
|
1932
|
-
if (!data || Object.keys(data).length === 0) {
|
|
1933
|
-
return {
|
|
1934
|
-
locked: false,
|
|
1935
|
-
attemptsRemaining: this.config.maxAttempts,
|
|
1936
|
-
totalAttempts: 0
|
|
1937
|
-
};
|
|
1938
|
-
}
|
|
1939
|
-
const attempts = parseInt(data.attempts, 10);
|
|
1940
|
-
const lockedUntil = data.lockedUntil ? new Date(parseInt(data.lockedUntil, 10)) : void 0;
|
|
1941
|
-
if (lockedUntil && lockedUntil > /* @__PURE__ */ new Date()) {
|
|
1942
|
-
return {
|
|
1943
|
-
locked: true,
|
|
1944
|
-
attemptsRemaining: 0,
|
|
1945
|
-
lockedUntil,
|
|
1946
|
-
totalAttempts: attempts
|
|
1947
|
-
};
|
|
1948
|
-
}
|
|
1949
|
-
if (lockedUntil && lockedUntil <= /* @__PURE__ */ new Date()) {
|
|
1950
|
-
await this.unlockAccount(userId);
|
|
1951
|
-
return {
|
|
1952
|
-
locked: false,
|
|
1953
|
-
attemptsRemaining: this.config.maxAttempts,
|
|
1954
|
-
totalAttempts: 0
|
|
1955
|
-
};
|
|
1956
|
-
}
|
|
1957
|
-
return {
|
|
1958
|
-
locked: false,
|
|
1959
|
-
attemptsRemaining: Math.max(0, this.config.maxAttempts - attempts),
|
|
1960
|
-
totalAttempts: attempts
|
|
1961
|
-
};
|
|
1962
|
-
}
|
|
1963
|
-
async recordFailedAttempt(userId) {
|
|
1964
|
-
const key = this.lockKey(userId);
|
|
1965
|
-
const historyKey = this.historyKey(userId);
|
|
1966
|
-
const now = Date.now();
|
|
1967
|
-
const current = await this.redis.hincrby(key, "attempts", 1);
|
|
1968
|
-
await this.redis.hset(key, "lastAttempt", now.toString());
|
|
1969
|
-
await this.redis.lpush(historyKey, now.toString());
|
|
1970
|
-
await this.redis.ltrim(historyKey, 0, 99);
|
|
1971
|
-
if (current >= this.config.maxAttempts) {
|
|
1972
|
-
const lockedUntil = new Date(now + this.config.lockDuration);
|
|
1973
|
-
await this.redis.hset(key, {
|
|
1974
|
-
lockedAt: now.toString(),
|
|
1975
|
-
lockedUntil: lockedUntil.getTime().toString()
|
|
1976
|
-
});
|
|
1977
|
-
await this.redis.expire(
|
|
1978
|
-
key,
|
|
1979
|
-
Math.ceil(this.config.lockDuration / 1e3) + 3600
|
|
1980
|
-
);
|
|
1981
|
-
return {
|
|
1982
|
-
locked: true,
|
|
1983
|
-
attemptsRemaining: 0,
|
|
1984
|
-
lockedUntil,
|
|
1985
|
-
totalAttempts: current
|
|
1986
|
-
};
|
|
1987
|
-
}
|
|
1988
|
-
return {
|
|
1989
|
-
locked: false,
|
|
1990
|
-
attemptsRemaining: Math.max(0, this.config.maxAttempts - current),
|
|
1991
|
-
totalAttempts: current
|
|
1992
|
-
};
|
|
1993
|
-
}
|
|
1994
|
-
async lockAccount(userId, duration) {
|
|
1995
|
-
const key = this.lockKey(userId);
|
|
1996
|
-
const now = Date.now();
|
|
1997
|
-
const lockDuration = duration || this.config.lockDuration;
|
|
1998
|
-
const lockedUntil = new Date(now + lockDuration);
|
|
1999
|
-
const pipeline = this.redis.pipeline();
|
|
2000
|
-
pipeline.hset(key, {
|
|
2001
|
-
attempts: this.config.maxAttempts.toString(),
|
|
2002
|
-
lockedAt: now.toString(),
|
|
2003
|
-
lockedUntil: lockedUntil.getTime().toString()
|
|
2004
|
-
});
|
|
2005
|
-
pipeline.expire(key, Math.ceil(lockDuration / 1e3) + 3600);
|
|
2006
|
-
await pipeline.exec();
|
|
2007
|
-
}
|
|
2008
|
-
async unlockAccount(userId) {
|
|
2009
|
-
const key = this.lockKey(userId);
|
|
2010
|
-
await this.redis.del(key);
|
|
2011
|
-
}
|
|
2012
|
-
async resetAttempts(userId) {
|
|
2013
|
-
const key = this.lockKey(userId);
|
|
2014
|
-
const data = await this.redis.hgetall(key);
|
|
2015
|
-
if (data.lockedAt) {
|
|
2016
|
-
await this.redis.hset(key, {
|
|
2017
|
-
attempts: "0",
|
|
2018
|
-
lockedAt: "",
|
|
2019
|
-
lockedUntil: ""
|
|
2020
|
-
});
|
|
2021
|
-
} else {
|
|
2022
|
-
await this.redis.del(key);
|
|
2023
|
-
}
|
|
2024
|
-
}
|
|
2025
|
-
async getLockoutHistory(userId, limit = 10) {
|
|
2026
|
-
const historyKey = this.historyKey(userId);
|
|
2027
|
-
const timestamps = await this.redis.lrange(historyKey, 0, limit - 1);
|
|
2028
|
-
return timestamps.map((ts) => new Date(parseInt(ts, 10)));
|
|
2029
|
-
}
|
|
2030
|
-
async getLockoutStats(userId) {
|
|
2031
|
-
const historyKey = this.historyKey(userId);
|
|
2032
|
-
const timestamps = await this.redis.lrange(historyKey, 0, -1);
|
|
2033
|
-
const lockouts = timestamps.filter((_, i) => {
|
|
2034
|
-
const attemptNum = i + 1;
|
|
2035
|
-
return attemptNum % this.config.maxAttempts === 0;
|
|
2036
|
-
}).length;
|
|
2037
|
-
const lastLockoutData = await this.redis.hget(
|
|
2038
|
-
this.lockKey(userId),
|
|
2039
|
-
"lockedAt"
|
|
2040
|
-
);
|
|
2041
|
-
return {
|
|
2042
|
-
totalFailedAttempts: timestamps.length,
|
|
2043
|
-
lockoutCount: lockouts,
|
|
2044
|
-
lastLockout: lastLockoutData ? new Date(parseInt(lastLockoutData, 10)) : null,
|
|
2045
|
-
averageAttemptsBeforeLockout: lockouts > 0 ? this.config.maxAttempts : 0
|
|
2046
|
-
};
|
|
2047
|
-
}
|
|
2048
|
-
shouldNotifyAdmin(currentAttempts) {
|
|
2049
|
-
return this.config.notifyAdmin && currentAttempts >= this.config.adminNotifyAfter;
|
|
2050
|
-
}
|
|
2051
|
-
getConfig() {
|
|
2052
|
-
return { ...this.config };
|
|
2053
|
-
}
|
|
2054
|
-
setConfig(config) {
|
|
2055
|
-
this.config = { ...this.config, ...config };
|
|
2056
|
-
}
|
|
2057
|
-
};
|
|
2058
|
-
|
|
2059
|
-
// src/auth/security/rate-limit.ts
|
|
2060
|
-
var DEFAULT_RATE_LIMITS = {
|
|
2061
|
-
"auth:login": { window: 9e5, max: 5 },
|
|
2062
|
-
"auth:register": { window: 36e5, max: 3 },
|
|
2063
|
-
"auth:forgot": { window: 36e5, max: 3 },
|
|
2064
|
-
"auth:reset": { window: 36e5, max: 5 },
|
|
2065
|
-
"auth:verify": { window: 36e5, max: 5 },
|
|
2066
|
-
"api:general": { window: 6e4, max: 100 },
|
|
2067
|
-
"api:authenticated": { window: 6e4, max: 200 }
|
|
2068
|
-
};
|
|
2069
|
-
var RateLimiter = class {
|
|
2070
|
-
redis;
|
|
2071
|
-
prefix;
|
|
2072
|
-
limits;
|
|
2073
|
-
userLimits;
|
|
2074
|
-
constructor(redis, limits, userLimits, prefix = "kyro:ratelimit:") {
|
|
2075
|
-
this.redis = redis;
|
|
2076
|
-
this.prefix = prefix;
|
|
2077
|
-
this.limits = { ...DEFAULT_RATE_LIMITS, ...limits };
|
|
2078
|
-
this.userLimits = userLimits || {
|
|
2079
|
-
"user:api": { window: 6e4, max: 500 },
|
|
2080
|
-
"user:write": { window: 36e5, max: 100 }
|
|
2081
|
-
};
|
|
2082
|
-
}
|
|
2083
|
-
getKey(type, identifier) {
|
|
2084
|
-
return `${this.prefix}${type}:${identifier}`;
|
|
2085
|
-
}
|
|
2086
|
-
async check(type, identifier) {
|
|
2087
|
-
const config = this.limits[type] || this.limits["api:general"];
|
|
2088
|
-
const key = this.getKey(type, identifier);
|
|
2089
|
-
const now = Date.now();
|
|
2090
|
-
const windowStart = now - config.window;
|
|
2091
|
-
const pipeline = this.redis.pipeline();
|
|
2092
|
-
pipeline.zremrangebyscore(key, 0, windowStart);
|
|
2093
|
-
pipeline.zcard(key);
|
|
2094
|
-
pipeline.zadd(key, now, `${now}:${Math.random()}`);
|
|
2095
|
-
pipeline.expire(key, Math.ceil(config.window / 1e3) + 1);
|
|
2096
|
-
const results = await pipeline.exec();
|
|
2097
|
-
const count = results?.[1]?.[1] || 0;
|
|
2098
|
-
if (count >= config.max) {
|
|
2099
|
-
const oldestTimestamp = await this.redis.zrange(key, 0, 0, "WITHSCORES");
|
|
2100
|
-
const resetAt = oldestTimestamp.length > 1 ? parseInt(oldestTimestamp[1], 10) + config.window : now + config.window;
|
|
2101
|
-
return {
|
|
2102
|
-
allowed: false,
|
|
2103
|
-
remaining: 0,
|
|
2104
|
-
resetAt,
|
|
2105
|
-
retryAfter: Math.ceil((resetAt - now) / 1e3)
|
|
2106
|
-
};
|
|
2107
|
-
}
|
|
2108
|
-
return {
|
|
2109
|
-
allowed: true,
|
|
2110
|
-
remaining: config.max - count - 1,
|
|
2111
|
-
resetAt: now + config.window
|
|
2112
|
-
};
|
|
2113
|
-
}
|
|
2114
|
-
async checkUser(type, userId, identifier) {
|
|
2115
|
-
const config = this.userLimits[type] || this.userLimits["user:api"];
|
|
2116
|
-
const key = this.getKey(`user:${type}:${userId}`, identifier);
|
|
2117
|
-
const now = Date.now();
|
|
2118
|
-
const windowStart = now - config.window;
|
|
2119
|
-
const pipeline = this.redis.pipeline();
|
|
2120
|
-
pipeline.zremrangebyscore(key, 0, windowStart);
|
|
2121
|
-
pipeline.zcard(key);
|
|
2122
|
-
pipeline.zadd(key, now, `${now}:${Math.random()}`);
|
|
2123
|
-
pipeline.expire(key, Math.ceil(config.window / 1e3) + 1);
|
|
2124
|
-
const results = await pipeline.exec();
|
|
2125
|
-
const count = results?.[1]?.[1] || 0;
|
|
2126
|
-
if (count >= config.max) {
|
|
2127
|
-
const oldestTimestamp = await this.redis.zrange(key, 0, 0, "WITHSCORES");
|
|
2128
|
-
const resetAt = oldestTimestamp.length > 1 ? parseInt(oldestTimestamp[1], 10) + config.window : now + config.window;
|
|
2129
|
-
return {
|
|
2130
|
-
allowed: false,
|
|
2131
|
-
remaining: 0,
|
|
2132
|
-
resetAt,
|
|
2133
|
-
retryAfter: Math.ceil((resetAt - now) / 1e3)
|
|
2134
|
-
};
|
|
2135
|
-
}
|
|
2136
|
-
return {
|
|
2137
|
-
allowed: true,
|
|
2138
|
-
remaining: config.max - count - 1,
|
|
2139
|
-
resetAt: now + config.window
|
|
2140
|
-
};
|
|
2141
|
-
}
|
|
2142
|
-
async reset(type, identifier) {
|
|
2143
|
-
const key = this.getKey(type, identifier);
|
|
2144
|
-
await this.redis.del(key);
|
|
2145
|
-
}
|
|
2146
|
-
async resetUser(type, userId, identifier) {
|
|
2147
|
-
const key = this.getKey(`user:${type}:${userId}`, identifier);
|
|
2148
|
-
await this.redis.del(key);
|
|
2149
|
-
}
|
|
2150
|
-
async getStatus(type, identifier) {
|
|
2151
|
-
const config = this.limits[type] || this.limits["api:general"];
|
|
2152
|
-
const key = this.getKey(type, identifier);
|
|
2153
|
-
const now = Date.now();
|
|
2154
|
-
const windowStart = now - config.window;
|
|
2155
|
-
await this.redis.zremrangebyscore(key, 0, windowStart);
|
|
2156
|
-
const count = await this.redis.zcard(key);
|
|
2157
|
-
return {
|
|
2158
|
-
count,
|
|
2159
|
-
limit: config.max,
|
|
2160
|
-
remaining: Math.max(0, config.max - count),
|
|
2161
|
-
resetAt: now + config.window
|
|
2162
|
-
};
|
|
2163
|
-
}
|
|
2164
|
-
setLimit(type, config) {
|
|
2165
|
-
this.limits[type] = config;
|
|
2166
|
-
}
|
|
2167
|
-
setUserLimit(type, config) {
|
|
2168
|
-
this.userLimits[type] = config;
|
|
2169
|
-
}
|
|
2170
|
-
};
|
|
2171
|
-
var AuditLogger = class {
|
|
2172
|
-
redis;
|
|
2173
|
-
prefix;
|
|
2174
|
-
retentionDays;
|
|
2175
|
-
constructor(redis, retentionDays = 30, prefix = "kyro:audit:") {
|
|
2176
|
-
this.redis = redis;
|
|
2177
|
-
this.prefix = prefix;
|
|
2178
|
-
this.retentionDays = retentionDays;
|
|
2179
|
-
}
|
|
2180
|
-
async log(data) {
|
|
2181
|
-
const id = randomBytes(16).toString("hex");
|
|
2182
|
-
const timestamp = /* @__PURE__ */ new Date();
|
|
2183
|
-
const log = {
|
|
2184
|
-
...data,
|
|
2185
|
-
id,
|
|
2186
|
-
timestamp
|
|
2187
|
-
};
|
|
2188
|
-
const key = this.getKeyForDate(timestamp);
|
|
2189
|
-
const hashKey = `${this.prefix}log:${id}`;
|
|
2190
|
-
await this.redis.hset(hashKey, this.serializeLog(log));
|
|
2191
|
-
await this.redis.expire(hashKey, this.retentionDays * 24 * 60 * 60 + 3600);
|
|
2192
|
-
await this.redis.zadd(key, timestamp.getTime(), id);
|
|
2193
|
-
await this.redis.expire(key, this.retentionDays * 24 * 60 * 60 + 3600);
|
|
2194
|
-
const userIndex = data.userId ? `${this.prefix}user:${data.userId}` : null;
|
|
2195
|
-
if (userIndex) {
|
|
2196
|
-
await this.redis.zadd(userIndex, timestamp.getTime(), id);
|
|
2197
|
-
await this.redis.expire(
|
|
2198
|
-
userIndex,
|
|
2199
|
-
this.retentionDays * 24 * 60 * 60 + 3600
|
|
2200
|
-
);
|
|
2201
|
-
}
|
|
2202
|
-
return id;
|
|
2203
|
-
}
|
|
2204
|
-
async get(id) {
|
|
2205
|
-
const hashKey = `${this.prefix}log:${id}`;
|
|
2206
|
-
const data = await this.redis.hgetall(hashKey);
|
|
2207
|
-
if (!data || Object.keys(data).length === 0) {
|
|
2208
|
-
return null;
|
|
2209
|
-
}
|
|
2210
|
-
return this.deserializeLog(data);
|
|
2211
|
-
}
|
|
2212
|
-
async query(filter = {}) {
|
|
2213
|
-
const { limit = 50, offset = 0 } = filter;
|
|
2214
|
-
let keys = [];
|
|
2215
|
-
if (filter.userId) {
|
|
2216
|
-
keys.push(`${this.prefix}user:${filter.userId}`);
|
|
2217
|
-
} else if (filter.startDate || filter.endDate) {
|
|
2218
|
-
keys = this.getKeysForDateRange(filter.startDate, filter.endDate);
|
|
2219
|
-
} else {
|
|
2220
|
-
const now = /* @__PURE__ */ new Date();
|
|
2221
|
-
keys = this.getKeysForDateRange(
|
|
2222
|
-
new Date(now.getTime() - this.retentionDays * 24 * 60 * 60 * 1e3),
|
|
2223
|
-
now
|
|
2224
|
-
);
|
|
2225
|
-
}
|
|
2226
|
-
let idScores = [];
|
|
2227
|
-
for (const key of keys) {
|
|
2228
|
-
const items = await this.redis.zrange(key, 0, -1, "WITHSCORES");
|
|
2229
|
-
for (let i = 0; i < items.length; i += 2) {
|
|
2230
|
-
idScores.push([items[i], parseInt(items[i + 1], 10)]);
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
idScores.sort((a, b) => b[1] - a[1]);
|
|
2234
|
-
const total = idScores.length;
|
|
2235
|
-
idScores = idScores.slice(offset, offset + limit);
|
|
2236
|
-
const logs = [];
|
|
2237
|
-
for (const [id] of idScores) {
|
|
2238
|
-
const log = await this.get(id);
|
|
2239
|
-
if (log) {
|
|
2240
|
-
if (this.matchesFilter(log, filter)) {
|
|
2241
|
-
logs.push(log);
|
|
2242
|
-
}
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
return { logs, total };
|
|
2246
|
-
}
|
|
2247
|
-
async getRecent(limit = 50) {
|
|
2248
|
-
const logs = [];
|
|
2249
|
-
const now = /* @__PURE__ */ new Date();
|
|
2250
|
-
const keys = this.getKeysForDateRange(
|
|
2251
|
-
new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3),
|
|
2252
|
-
now
|
|
2253
|
-
);
|
|
2254
|
-
let allIds = [];
|
|
2255
|
-
for (const key of keys) {
|
|
2256
|
-
const items = await this.redis.zrange(key, 0, -1, "WITHSCORES");
|
|
2257
|
-
for (let i = 0; i < items.length; i += 2) {
|
|
2258
|
-
allIds.push([items[i], parseInt(items[i + 1], 10)]);
|
|
2259
|
-
}
|
|
2260
|
-
}
|
|
2261
|
-
allIds.sort((a, b) => b[1] - a[1]);
|
|
2262
|
-
for (const [id] of allIds.slice(0, limit)) {
|
|
2263
|
-
const log = await this.get(id);
|
|
2264
|
-
if (log) logs.push(log);
|
|
2265
|
-
}
|
|
2266
|
-
return logs;
|
|
2267
|
-
}
|
|
2268
|
-
async getUserActivity(userId, limit = 50) {
|
|
2269
|
-
const key = `${this.prefix}user:${userId}`;
|
|
2270
|
-
const ids = await this.redis.zrange(key, 0, limit - 1);
|
|
2271
|
-
const logs = [];
|
|
2272
|
-
for (const id of ids) {
|
|
2273
|
-
const log = await this.get(id);
|
|
2274
|
-
if (log) logs.push(log);
|
|
2275
|
-
}
|
|
2276
|
-
return logs;
|
|
2277
|
-
}
|
|
2278
|
-
async getStats(startDate, endDate) {
|
|
2279
|
-
const keys = this.getKeysForDateRange(
|
|
2280
|
-
startDate || new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3),
|
|
2281
|
-
endDate || /* @__PURE__ */ new Date()
|
|
2282
|
-
);
|
|
2283
|
-
const byAction = {};
|
|
2284
|
-
let totalEvents = 0;
|
|
2285
|
-
let failedLogins = 0;
|
|
2286
|
-
let successCount = 0;
|
|
2287
|
-
const uniqueUsers = /* @__PURE__ */ new Set();
|
|
2288
|
-
for (const key of keys) {
|
|
2289
|
-
const ids = await this.redis.zrange(key, 0, -1);
|
|
2290
|
-
for (const id of ids) {
|
|
2291
|
-
const log = await this.get(id);
|
|
2292
|
-
if (log) {
|
|
2293
|
-
totalEvents++;
|
|
2294
|
-
byAction[log.action] = (byAction[log.action] || 0) + 1;
|
|
2295
|
-
if (log.success) {
|
|
2296
|
-
successCount++;
|
|
2297
|
-
}
|
|
2298
|
-
if (log.action === "login_failed") {
|
|
2299
|
-
failedLogins++;
|
|
2300
|
-
}
|
|
2301
|
-
if (log.userId) {
|
|
2302
|
-
uniqueUsers.add(log.userId);
|
|
2303
|
-
}
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
}
|
|
2307
|
-
return {
|
|
2308
|
-
totalEvents,
|
|
2309
|
-
byAction,
|
|
2310
|
-
successRate: totalEvents > 0 ? successCount / totalEvents : 1,
|
|
2311
|
-
failedLogins,
|
|
2312
|
-
uniqueUsers
|
|
2313
|
-
};
|
|
2314
|
-
}
|
|
2315
|
-
async cleanup() {
|
|
2316
|
-
const cutoff = Date.now() - this.retentionDays * 24 * 60 * 60 * 1e3;
|
|
2317
|
-
const keys = await this.redis.keys(`${this.prefix}date:*`);
|
|
2318
|
-
let deleted = 0;
|
|
2319
|
-
for (const key of keys) {
|
|
2320
|
-
const timestamp = await this.redis.zrangebyscore(key, 0, cutoff);
|
|
2321
|
-
for (const id of timestamp) {
|
|
2322
|
-
await this.redis.del(`${this.prefix}log:${id}`);
|
|
2323
|
-
deleted++;
|
|
2324
|
-
}
|
|
2325
|
-
await this.redis.zremrangebyscore(key, 0, cutoff);
|
|
2326
|
-
}
|
|
2327
|
-
return deleted;
|
|
2328
|
-
}
|
|
2329
|
-
getKeyForDate(date) {
|
|
2330
|
-
const year = date.getFullYear();
|
|
2331
|
-
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
2332
|
-
const day = String(date.getDate()).padStart(2, "0");
|
|
2333
|
-
return `${this.prefix}date:${year}-${month}-${day}`;
|
|
2334
|
-
}
|
|
2335
|
-
getKeysForDateRange(start, end) {
|
|
2336
|
-
const keys = [];
|
|
2337
|
-
const startDate = start || new Date(Date.now() - this.retentionDays * 24 * 60 * 60 * 1e3);
|
|
2338
|
-
const endDate = end || /* @__PURE__ */ new Date();
|
|
2339
|
-
const current = new Date(startDate);
|
|
2340
|
-
while (current <= endDate) {
|
|
2341
|
-
keys.push(this.getKeyForDate(current));
|
|
2342
|
-
current.setDate(current.getDate() + 1);
|
|
2343
|
-
}
|
|
2344
|
-
return keys;
|
|
2345
|
-
}
|
|
2346
|
-
matchesFilter(log, filter) {
|
|
2347
|
-
if (filter.action) {
|
|
2348
|
-
const actions = Array.isArray(filter.action) ? filter.action : [filter.action];
|
|
2349
|
-
if (!actions.includes(log.action)) return false;
|
|
2350
|
-
}
|
|
2351
|
-
if (filter.resource && log.resource !== filter.resource) return false;
|
|
2352
|
-
if (filter.resourceId && log.resourceId !== filter.resourceId) return false;
|
|
2353
|
-
if (filter.success !== void 0 && log.success !== filter.success)
|
|
2354
|
-
return false;
|
|
2355
|
-
return true;
|
|
2356
|
-
}
|
|
2357
|
-
serializeLog(log) {
|
|
2358
|
-
const result = {
|
|
2359
|
-
id: log.id,
|
|
2360
|
-
timestamp: log.timestamp.toISOString(),
|
|
2361
|
-
action: log.action,
|
|
2362
|
-
resource: log.resource,
|
|
2363
|
-
success: log.success ? "1" : "0"
|
|
2364
|
-
};
|
|
2365
|
-
if (log.userId) result.userId = log.userId;
|
|
2366
|
-
if (log.userEmail) result.userEmail = log.userEmail;
|
|
2367
|
-
if (log.role) result.role = log.role;
|
|
2368
|
-
if (log.resourceId) result.resourceId = log.resourceId;
|
|
2369
|
-
if (log.ipAddress) result.ipAddress = log.ipAddress;
|
|
2370
|
-
if (log.userAgent) result.userAgent = log.userAgent;
|
|
2371
|
-
if (log.error) result.error = log.error;
|
|
2372
|
-
if (log.changes) result.changes = JSON.stringify(log.changes);
|
|
2373
|
-
if (log.metadata) result.metadata = JSON.stringify(log.metadata);
|
|
2374
|
-
return result;
|
|
2375
|
-
}
|
|
2376
|
-
deserializeLog(data) {
|
|
2377
|
-
return {
|
|
2378
|
-
id: data.id,
|
|
2379
|
-
timestamp: new Date(data.timestamp),
|
|
2380
|
-
action: data.action,
|
|
2381
|
-
userId: data.userId,
|
|
2382
|
-
userEmail: data.userEmail,
|
|
2383
|
-
role: data.role,
|
|
2384
|
-
resource: data.resource,
|
|
2385
|
-
resourceId: data.resourceId,
|
|
2386
|
-
ipAddress: data.ipAddress,
|
|
2387
|
-
userAgent: data.userAgent,
|
|
2388
|
-
success: data.success === "1",
|
|
2389
|
-
error: data.error,
|
|
2390
|
-
changes: data.changes ? JSON.parse(data.changes) : void 0,
|
|
2391
|
-
metadata: data.metadata ? JSON.parse(data.metadata) : void 0
|
|
2392
|
-
};
|
|
2393
|
-
}
|
|
2394
|
-
};
|
|
2395
|
-
function createAuditContext(req) {
|
|
2396
|
-
return {
|
|
2397
|
-
ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown",
|
|
2398
|
-
userAgent: req.headers.get("user-agent") || "unknown"
|
|
2399
|
-
};
|
|
2400
|
-
}
|
|
2401
|
-
function defaultExtractToken(req) {
|
|
2402
|
-
const authHeader = req.headers.get("Authorization");
|
|
2403
|
-
if (authHeader?.startsWith("Bearer ")) {
|
|
2404
|
-
return authHeader.slice(7);
|
|
2405
|
-
}
|
|
2406
|
-
const cookieHeader = req.headers.get("Cookie");
|
|
2407
|
-
if (cookieHeader) {
|
|
2408
|
-
const cookies = Object.fromEntries(
|
|
2409
|
-
cookieHeader.split("; ").map((c) => {
|
|
2410
|
-
const [key, ...val] = c.split("=");
|
|
2411
|
-
return [key.trim(), val.join("=")];
|
|
2412
|
-
})
|
|
2413
|
-
);
|
|
2414
|
-
return cookies["auth_token"] || null;
|
|
2415
|
-
}
|
|
2416
|
-
return null;
|
|
2417
|
-
}
|
|
2418
|
-
function generateToken(payload, secret, options = {}) {
|
|
2419
|
-
return jwt.sign(payload, secret, {
|
|
2420
|
-
expiresIn: options.expiresIn || "24h",
|
|
2421
|
-
issuer: options.issuer,
|
|
2422
|
-
audience: options.audience
|
|
2423
|
-
});
|
|
2424
|
-
}
|
|
2425
|
-
|
|
2426
|
-
// src/api/rest/auth-routes.ts
|
|
2427
|
-
var AuthRoutes = class {
|
|
2428
|
-
redis;
|
|
2429
|
-
email;
|
|
2430
|
-
jwtSecret;
|
|
2431
|
-
jwtExpiresIn;
|
|
2432
|
-
jwtIssuer;
|
|
2433
|
-
jwtAudience;
|
|
2434
|
-
passwordPolicy;
|
|
2435
|
-
lockout;
|
|
2436
|
-
rateLimiter;
|
|
2437
|
-
auditLogger;
|
|
2438
|
-
baseUrl;
|
|
2439
|
-
emailVerificationRequired;
|
|
2440
|
-
constructor(config) {
|
|
2441
|
-
this.redis = config.redis;
|
|
2442
|
-
this.email = config.email;
|
|
2443
|
-
this.jwtSecret = config.jwtSecret;
|
|
2444
|
-
this.jwtExpiresIn = config.jwtExpiresIn || "24h";
|
|
2445
|
-
this.jwtIssuer = config.jwtIssuer;
|
|
2446
|
-
this.jwtAudience = config.jwtAudience;
|
|
2447
|
-
this.passwordPolicy = config.passwordPolicy || new PasswordPolicy();
|
|
2448
|
-
this.lockout = config.lockout;
|
|
2449
|
-
this.rateLimiter = config.rateLimiter;
|
|
2450
|
-
this.auditLogger = config.auditLogger;
|
|
2451
|
-
this.baseUrl = config.baseUrl || "http://localhost:3000";
|
|
2452
|
-
this.emailVerificationRequired = config.emailVerificationRequired ?? true;
|
|
2453
|
-
}
|
|
2454
|
-
async register(req) {
|
|
2455
|
-
const { ipAddress, userAgent } = createAuditContext(req);
|
|
2456
|
-
if (this.rateLimiter) {
|
|
2457
|
-
const limit = await this.rateLimiter.check("auth:register", ipAddress);
|
|
2458
|
-
if (!limit.allowed) {
|
|
2459
|
-
return this.rateLimitResponse(limit);
|
|
2460
|
-
}
|
|
2461
|
-
}
|
|
2462
|
-
try {
|
|
2463
|
-
const body = await req.json();
|
|
2464
|
-
if (!body.email || !body.password) {
|
|
2465
|
-
return this.errorResponse("Email and password are required", 400);
|
|
2466
|
-
}
|
|
2467
|
-
if (body.password !== body.confirmPassword) {
|
|
2468
|
-
return this.errorResponse("Passwords do not match", 400);
|
|
2469
|
-
}
|
|
2470
|
-
const passwordValidation = this.passwordPolicy.validate(body.password);
|
|
2471
|
-
if (!passwordValidation.valid) {
|
|
2472
|
-
return this.errorResponse(passwordValidation.errors.join(". "), 400);
|
|
2473
|
-
}
|
|
2474
|
-
const existingUser = await this.redis.findUserByEmail(body.email);
|
|
2475
|
-
if (existingUser) {
|
|
2476
|
-
return this.errorResponse("Email already registered", 400);
|
|
2477
|
-
}
|
|
2478
|
-
const passwordHash = await this.redis.hashPassword(body.password);
|
|
2479
|
-
const user = await this.redis.createUser({
|
|
2480
|
-
email: body.email,
|
|
2481
|
-
passwordHash,
|
|
2482
|
-
role: body.role || "customer",
|
|
2483
|
-
tenantId: body.tenantId
|
|
2484
|
-
});
|
|
2485
|
-
if (this.emailVerificationRequired && this.email) {
|
|
2486
|
-
const verificationToken = randomBytes(32).toString("hex");
|
|
2487
|
-
const verificationUrl = `${this.baseUrl}/api/auth/verify?token=${verificationToken}`;
|
|
2488
|
-
await this.redis.createSession(user.id, { ipAddress, userAgent });
|
|
2489
|
-
const template = this.email.getTemplates().verifyEmail(verificationUrl, body.email);
|
|
2490
|
-
await this.email.send({ to: body.email, ...template });
|
|
2491
|
-
}
|
|
2492
|
-
if (this.auditLogger) {
|
|
2493
|
-
await this.auditLogger.log({
|
|
2494
|
-
action: "register",
|
|
2495
|
-
userId: user.id,
|
|
2496
|
-
userEmail: user.email,
|
|
2497
|
-
resource: "auth",
|
|
2498
|
-
ipAddress,
|
|
2499
|
-
userAgent,
|
|
2500
|
-
success: true
|
|
2501
|
-
});
|
|
2502
|
-
}
|
|
2503
|
-
return this.jsonResponse(
|
|
2504
|
-
{
|
|
2505
|
-
success: true,
|
|
2506
|
-
message: "Registration successful",
|
|
2507
|
-
user: this.sanitizeUser(user),
|
|
2508
|
-
requiresVerification: this.emailVerificationRequired && !!this.email
|
|
2509
|
-
},
|
|
2510
|
-
201
|
|
2511
|
-
);
|
|
2512
|
-
} catch (error) {
|
|
2513
|
-
return this.errorResponse("Registration failed", 500);
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
2516
|
-
async login(req) {
|
|
2517
|
-
const { ipAddress, userAgent } = createAuditContext(req);
|
|
2518
|
-
if (this.rateLimiter) {
|
|
2519
|
-
const limit = await this.rateLimiter.check("auth:login", ipAddress);
|
|
2520
|
-
if (!limit.allowed) {
|
|
2521
|
-
return this.rateLimitResponse(limit);
|
|
2522
|
-
}
|
|
2523
|
-
}
|
|
2524
|
-
try {
|
|
2525
|
-
const body = await req.json();
|
|
2526
|
-
if (!body.email || !body.password) {
|
|
2527
|
-
return this.errorResponse("Email and password are required", 400);
|
|
2528
|
-
}
|
|
2529
|
-
const user = await this.redis.findUserByEmail(body.email);
|
|
2530
|
-
if (!user) {
|
|
2531
|
-
await this.recordFailedLogin(ipAddress, userAgent);
|
|
2532
|
-
return this.errorResponse("Invalid credentials", 401);
|
|
2533
|
-
}
|
|
2534
|
-
if (this.lockout) {
|
|
2535
|
-
const lockoutStatus = await this.lockout.checkLockout(user.id);
|
|
2536
|
-
if (lockoutStatus.locked) {
|
|
2537
|
-
if (this.auditLogger) {
|
|
2538
|
-
await this.auditLogger.log({
|
|
2539
|
-
action: "login_failed",
|
|
2540
|
-
userId: user.id,
|
|
2541
|
-
userEmail: user.email,
|
|
2542
|
-
resource: "auth",
|
|
2543
|
-
ipAddress,
|
|
2544
|
-
userAgent,
|
|
2545
|
-
success: false,
|
|
2546
|
-
error: "Account locked"
|
|
2547
|
-
});
|
|
2548
|
-
}
|
|
2549
|
-
return this.errorResponse(
|
|
2550
|
-
`Account locked. Try again in ${Math.ceil((lockoutStatus.lockedUntil.getTime() - Date.now()) / 6e4)} minutes`,
|
|
2551
|
-
423
|
|
2552
|
-
);
|
|
2553
|
-
}
|
|
2554
|
-
}
|
|
2555
|
-
const validPassword = user.passwordHash ? await this.redis.verifyPassword(body.password, user.passwordHash) : false;
|
|
2556
|
-
if (!validPassword) {
|
|
2557
|
-
await this.recordFailedLogin(ipAddress, userAgent, user.id, user.email);
|
|
2558
|
-
return this.errorResponse("Invalid credentials", 401);
|
|
2559
|
-
}
|
|
2560
|
-
if (this.lockout) {
|
|
2561
|
-
await this.lockout.resetAttempts(user.id);
|
|
2562
|
-
}
|
|
2563
|
-
const session = await this.redis.createSession(user.id, {
|
|
2564
|
-
ipAddress,
|
|
2565
|
-
userAgent
|
|
2566
|
-
});
|
|
2567
|
-
const payload = {
|
|
2568
|
-
sub: user.id,
|
|
2569
|
-
email: user.email,
|
|
2570
|
-
role: user.role,
|
|
2571
|
-
tenantId: user.tenantId,
|
|
2572
|
-
iat: Math.floor(Date.now() / 1e3),
|
|
2573
|
-
exp: Math.floor(Date.now() / 1e3) + 86400
|
|
2574
|
-
};
|
|
2575
|
-
const accessToken = generateToken(payload, this.jwtSecret, {
|
|
2576
|
-
expiresIn: this.jwtExpiresIn,
|
|
2577
|
-
issuer: this.jwtIssuer,
|
|
2578
|
-
audience: this.jwtAudience
|
|
2579
|
-
});
|
|
2580
|
-
if (this.auditLogger) {
|
|
2581
|
-
await this.auditLogger.log({
|
|
2582
|
-
action: "login",
|
|
2583
|
-
userId: user.id,
|
|
2584
|
-
userEmail: user.email,
|
|
2585
|
-
role: user.role,
|
|
2586
|
-
resource: "auth",
|
|
2587
|
-
ipAddress,
|
|
2588
|
-
userAgent,
|
|
2589
|
-
success: true
|
|
2590
|
-
});
|
|
2591
|
-
}
|
|
2592
|
-
await this.redis.updateUser(user.id, {
|
|
2593
|
-
lastLogin: (/* @__PURE__ */ new Date()).toISOString()
|
|
2594
|
-
});
|
|
2595
|
-
return this.jsonResponse({
|
|
2596
|
-
success: true,
|
|
2597
|
-
user: this.sanitizeUser(user),
|
|
2598
|
-
accessToken,
|
|
2599
|
-
refreshToken: session.refreshToken,
|
|
2600
|
-
expiresIn: this.jwtExpiresIn
|
|
2601
|
-
});
|
|
2602
|
-
} catch (error) {
|
|
2603
|
-
return this.errorResponse("Login failed", 500);
|
|
2604
|
-
}
|
|
2605
|
-
}
|
|
2606
|
-
async logout(req) {
|
|
2607
|
-
const token = defaultExtractToken(req);
|
|
2608
|
-
if (!token) {
|
|
2609
|
-
return this.errorResponse("No session to logout", 401);
|
|
2610
|
-
}
|
|
2611
|
-
const { ipAddress, userAgent } = createAuditContext(req);
|
|
2612
|
-
try {
|
|
2613
|
-
const payload = jwt.decode(token);
|
|
2614
|
-
if (payload && payload.sub) {
|
|
2615
|
-
await this.redis.deleteUserSessions(payload.sub);
|
|
2616
|
-
if (this.auditLogger) {
|
|
2617
|
-
await this.auditLogger.log({
|
|
2618
|
-
action: "logout",
|
|
2619
|
-
userId: payload.sub,
|
|
2620
|
-
userEmail: payload.email,
|
|
2621
|
-
resource: "auth",
|
|
2622
|
-
ipAddress,
|
|
2623
|
-
userAgent,
|
|
2624
|
-
success: true
|
|
2625
|
-
});
|
|
2626
|
-
}
|
|
2627
|
-
}
|
|
2628
|
-
return this.jsonResponse({
|
|
2629
|
-
success: true,
|
|
2630
|
-
message: "Logged out successfully"
|
|
2631
|
-
});
|
|
2632
|
-
} catch (error) {
|
|
2633
|
-
return this.errorResponse("Logout failed", 500);
|
|
2634
|
-
}
|
|
2635
|
-
}
|
|
2636
|
-
async refresh(req) {
|
|
2637
|
-
try {
|
|
2638
|
-
const body = await req.json();
|
|
2639
|
-
const { refreshToken } = body;
|
|
2640
|
-
if (!refreshToken) {
|
|
2641
|
-
return this.errorResponse("Refresh token required", 400);
|
|
2642
|
-
}
|
|
2643
|
-
return this.jsonResponse({ success: true, accessToken: "" });
|
|
2644
|
-
} catch (error) {
|
|
2645
|
-
return this.errorResponse("Token refresh failed", 500);
|
|
2646
|
-
}
|
|
2647
|
-
}
|
|
2648
|
-
async me(req) {
|
|
2649
|
-
const token = defaultExtractToken(req);
|
|
2650
|
-
if (!token) {
|
|
2651
|
-
return this.errorResponse("Not authenticated", 401);
|
|
2652
|
-
}
|
|
2653
|
-
try {
|
|
2654
|
-
const payload = jwt.verify(token, this.jwtSecret, {
|
|
2655
|
-
issuer: this.jwtIssuer,
|
|
2656
|
-
audience: this.jwtAudience
|
|
2657
|
-
});
|
|
2658
|
-
const user = await this.redis.findUserById(payload.sub);
|
|
2659
|
-
if (!user) {
|
|
2660
|
-
return this.errorResponse("User not found", 404);
|
|
2661
|
-
}
|
|
2662
|
-
return this.jsonResponse({
|
|
2663
|
-
success: true,
|
|
2664
|
-
user: this.sanitizeUser(user)
|
|
2665
|
-
});
|
|
2666
|
-
} catch (error) {
|
|
2667
|
-
return this.errorResponse("Authentication failed", 401);
|
|
2668
|
-
}
|
|
2669
|
-
}
|
|
2670
|
-
async changePassword(req) {
|
|
2671
|
-
const token = defaultExtractToken(req);
|
|
2672
|
-
if (!token) {
|
|
2673
|
-
return this.errorResponse("Not authenticated", 401);
|
|
2674
|
-
}
|
|
2675
|
-
const { ipAddress, userAgent } = createAuditContext(req);
|
|
2676
|
-
try {
|
|
2677
|
-
const payload = jwt.verify(token, this.jwtSecret);
|
|
2678
|
-
const body = await req.json();
|
|
2679
|
-
const { currentPassword, newPassword, confirmPassword } = body;
|
|
2680
|
-
if (!currentPassword || !newPassword) {
|
|
2681
|
-
return this.errorResponse("Current and new password required", 400);
|
|
2682
|
-
}
|
|
2683
|
-
if (newPassword !== confirmPassword) {
|
|
2684
|
-
return this.errorResponse("Passwords do not match", 400);
|
|
2685
|
-
}
|
|
2686
|
-
const passwordValidation = this.passwordPolicy.validate(newPassword);
|
|
2687
|
-
if (!passwordValidation.valid) {
|
|
2688
|
-
return this.errorResponse(passwordValidation.errors.join(". "), 400);
|
|
2689
|
-
}
|
|
2690
|
-
const user = await this.redis.findUserById(payload.sub);
|
|
2691
|
-
if (!user) {
|
|
2692
|
-
return this.errorResponse("User not found", 404);
|
|
2693
|
-
}
|
|
2694
|
-
const validPassword = user.passwordHash ? await this.redis.verifyPassword(currentPassword, user.passwordHash) : false;
|
|
2695
|
-
if (!validPassword) {
|
|
2696
|
-
return this.errorResponse("Current password is incorrect", 401);
|
|
2697
|
-
}
|
|
2698
|
-
const passwordHistory = await this.redis.getPasswordHistory(user.id, 5);
|
|
2699
|
-
const isReused = await this.redis.isPasswordInHistory(
|
|
2700
|
-
newPassword,
|
|
2701
|
-
user.id,
|
|
2702
|
-
5
|
|
2703
|
-
);
|
|
2704
|
-
if (isReused) {
|
|
2705
|
-
return this.errorResponse(
|
|
2706
|
-
"Password was recently used. Please choose a different password",
|
|
2707
|
-
400
|
|
2708
|
-
);
|
|
2709
|
-
}
|
|
2710
|
-
const newPasswordHash = await this.redis.hashPassword(newPassword);
|
|
2711
|
-
if (user.passwordHash) {
|
|
2712
|
-
await this.redis.addPasswordToHistory(user.id, user.passwordHash);
|
|
2713
|
-
}
|
|
2714
|
-
await this.redis.updateUser(user.id, { passwordHash: newPasswordHash });
|
|
2715
|
-
await this.redis.deleteUserSessions(user.id);
|
|
2716
|
-
if (this.email && this.email.getTemplates) {
|
|
2717
|
-
const template = this.email.getTemplates().passwordChanged(user.email);
|
|
2718
|
-
await this.email.send({ to: user.email, ...template });
|
|
2719
|
-
}
|
|
2720
|
-
if (this.auditLogger) {
|
|
2721
|
-
await this.auditLogger.log({
|
|
2722
|
-
action: "password_change",
|
|
2723
|
-
userId: user.id,
|
|
2724
|
-
userEmail: user.email,
|
|
2725
|
-
resource: "auth",
|
|
2726
|
-
ipAddress,
|
|
2727
|
-
userAgent,
|
|
2728
|
-
success: true
|
|
2729
|
-
});
|
|
2730
|
-
}
|
|
2731
|
-
return this.jsonResponse({
|
|
2732
|
-
success: true,
|
|
2733
|
-
message: "Password changed successfully"
|
|
2734
|
-
});
|
|
2735
|
-
} catch (error) {
|
|
2736
|
-
return this.errorResponse("Password change failed", 500);
|
|
2737
|
-
}
|
|
2738
|
-
}
|
|
2739
|
-
async forgotPassword(req) {
|
|
2740
|
-
const { ipAddress, userAgent } = createAuditContext(req);
|
|
2741
|
-
if (this.rateLimiter) {
|
|
2742
|
-
const limit = await this.rateLimiter.check("auth:forgot", ipAddress);
|
|
2743
|
-
if (!limit.allowed) {
|
|
2744
|
-
return this.rateLimitResponse(limit);
|
|
2745
|
-
}
|
|
2746
|
-
}
|
|
2747
|
-
try {
|
|
2748
|
-
const body = await req.json();
|
|
2749
|
-
const { email } = body;
|
|
2750
|
-
if (!email) {
|
|
2751
|
-
return this.errorResponse("Email required", 400);
|
|
2752
|
-
}
|
|
2753
|
-
const user = await this.redis.findUserByEmail(email);
|
|
2754
|
-
if (!user) {
|
|
2755
|
-
return this.jsonResponse({
|
|
2756
|
-
success: true,
|
|
2757
|
-
message: "If the email exists, a reset link has been sent"
|
|
2758
|
-
});
|
|
2759
|
-
}
|
|
2760
|
-
if (this.email) {
|
|
2761
|
-
const resetToken = randomBytes(32).toString("hex");
|
|
2762
|
-
const resetUrl = `${this.baseUrl}/api/auth/reset-password?token=${resetToken}`;
|
|
2763
|
-
const template = this.email.getTemplates().resetPassword(resetUrl, user.email);
|
|
2764
|
-
await this.email.send({ to: user.email, ...template });
|
|
2765
|
-
}
|
|
2766
|
-
if (this.auditLogger) {
|
|
2767
|
-
await this.auditLogger.log({
|
|
2768
|
-
action: "password_reset_request",
|
|
2769
|
-
userId: user.id,
|
|
2770
|
-
userEmail: user.email,
|
|
2771
|
-
resource: "auth",
|
|
2772
|
-
ipAddress,
|
|
2773
|
-
userAgent,
|
|
2774
|
-
success: true
|
|
2775
|
-
});
|
|
2776
|
-
}
|
|
2777
|
-
return this.jsonResponse({
|
|
2778
|
-
success: true,
|
|
2779
|
-
message: "If the email exists, a reset link has been sent"
|
|
2780
|
-
});
|
|
2781
|
-
} catch (error) {
|
|
2782
|
-
return this.errorResponse("Password reset request failed", 500);
|
|
2783
|
-
}
|
|
2784
|
-
}
|
|
2785
|
-
async verifyEmail(req) {
|
|
2786
|
-
const url = new URL(req.url);
|
|
2787
|
-
const token = url.searchParams.get("token");
|
|
2788
|
-
if (!token) {
|
|
2789
|
-
return this.errorResponse("Verification token required", 400);
|
|
2790
|
-
}
|
|
2791
|
-
try {
|
|
2792
|
-
return this.jsonResponse({ success: true, message: "Email verified" });
|
|
2793
|
-
} catch (error) {
|
|
2794
|
-
return this.errorResponse("Email verification failed", 500);
|
|
2795
|
-
}
|
|
2796
|
-
}
|
|
2797
|
-
async recordFailedLogin(ipAddress, userAgent, userId, userEmail) {
|
|
2798
|
-
if (this.lockout) {
|
|
2799
|
-
await this.lockout.recordFailedAttempt(userId || ipAddress);
|
|
2800
|
-
}
|
|
2801
|
-
if (this.auditLogger) {
|
|
2802
|
-
await this.auditLogger.log({
|
|
2803
|
-
action: "login_failed",
|
|
2804
|
-
userId,
|
|
2805
|
-
userEmail,
|
|
2806
|
-
resource: "auth",
|
|
2807
|
-
ipAddress,
|
|
2808
|
-
userAgent,
|
|
2809
|
-
success: false,
|
|
2810
|
-
error: "Invalid credentials"
|
|
2811
|
-
});
|
|
2812
|
-
}
|
|
2813
|
-
}
|
|
2814
|
-
sanitizeUser(user) {
|
|
2815
|
-
const { passwordHash, ...sanitized } = user;
|
|
2816
|
-
return sanitized;
|
|
2817
|
-
}
|
|
2818
|
-
jsonResponse(data, status = 200) {
|
|
2819
|
-
return new Response(JSON.stringify(data), {
|
|
2820
|
-
status,
|
|
2821
|
-
headers: {
|
|
2822
|
-
"Content-Type": "application/json"
|
|
2823
|
-
}
|
|
2824
|
-
});
|
|
2825
|
-
}
|
|
2826
|
-
errorResponse(message, status) {
|
|
2827
|
-
return new Response(JSON.stringify({ success: false, error: message }), {
|
|
2828
|
-
status,
|
|
2829
|
-
headers: {
|
|
2830
|
-
"Content-Type": "application/json"
|
|
2831
|
-
}
|
|
2832
|
-
});
|
|
2833
|
-
}
|
|
2834
|
-
rateLimitResponse(limit) {
|
|
2835
|
-
return new Response(
|
|
2836
|
-
JSON.stringify({
|
|
2837
|
-
success: false,
|
|
2838
|
-
error: "Too many requests",
|
|
2839
|
-
retryAfter: limit.retryAfter
|
|
2840
|
-
}),
|
|
2841
|
-
{
|
|
2842
|
-
status: 429,
|
|
2843
|
-
headers: {
|
|
2844
|
-
"Content-Type": "application/json",
|
|
2845
|
-
"Retry-After": String(limit.retryAfter || 60)
|
|
2846
|
-
}
|
|
2847
|
-
}
|
|
2848
|
-
);
|
|
2849
|
-
}
|
|
2850
|
-
};
|
|
2851
|
-
|
|
2852
|
-
// src/auth/config.ts
|
|
2853
|
-
function getEnv(key, fallback = "") {
|
|
2854
|
-
return process.env[key] || fallback;
|
|
2855
|
-
}
|
|
2856
|
-
function getEnvBool(key, fallback = false) {
|
|
2857
|
-
const val = process.env[key];
|
|
2858
|
-
if (!val) return fallback;
|
|
2859
|
-
return val.toLowerCase() === "true";
|
|
2860
|
-
}
|
|
2861
|
-
function getEnvNum(key, fallback = 0) {
|
|
2862
|
-
const val = process.env[key];
|
|
2863
|
-
if (!val) return fallback;
|
|
2864
|
-
return parseInt(val, 10);
|
|
2865
|
-
}
|
|
2866
|
-
async function createAuthConfig() {
|
|
2867
|
-
const redisUrl = getEnv("REDIS_URL", "redis://localhost:6379");
|
|
2868
|
-
const redisKeyPrefix = getEnv("REDIS_KEY_PREFIX", "kyro:auth:");
|
|
2869
|
-
const redisSessionTTL = getEnvNum("REDIS_SESSION_TTL", 86400);
|
|
2870
|
-
const redisRefreshTTL = getEnvNum("REDIS_REFRESH_TOKEN_TTL", 604800);
|
|
2871
|
-
const redisAdapter = new RedisAuthAdapter({
|
|
2872
|
-
url: redisUrl,
|
|
2873
|
-
keyPrefix: redisKeyPrefix,
|
|
2874
|
-
tokenExpiration: redisSessionTTL,
|
|
2875
|
-
refreshTokenExpiration: redisRefreshTTL,
|
|
2876
|
-
tls: getEnvBool("REDIS_TLS", false)
|
|
2877
|
-
});
|
|
2878
|
-
await redisAdapter.connect();
|
|
2879
|
-
const redisClient = redisAdapter.redis;
|
|
2880
|
-
const emailConfig = getEmailConfig();
|
|
2881
|
-
const email = emailConfig ? new EmailTransport(emailConfig) : void 0;
|
|
2882
|
-
const passwordPolicy = new PasswordPolicy({
|
|
2883
|
-
minLength: getEnvNum("PASSWORD_MIN_LENGTH", 12),
|
|
2884
|
-
requireUppercase: getEnvBool("PASSWORD_REQUIRE_UPPERCASE", true),
|
|
2885
|
-
requireLowercase: getEnvBool("PASSWORD_REQUIRE_LOWERCASE", true),
|
|
2886
|
-
requireNumbers: getEnvBool("PASSWORD_REQUIRE_NUMBERS", true),
|
|
2887
|
-
requireSpecialChars: getEnvBool("PASSWORD_REQUIRE_SPECIAL", true),
|
|
2888
|
-
preventReuse: getEnvNum("PASSWORD_PREVENT_REUSE", 5),
|
|
2889
|
-
maxLength: getEnvNum("PASSWORD_MAX_LENGTH", 128)
|
|
2890
|
-
});
|
|
2891
|
-
let lockout;
|
|
2892
|
-
if (getEnvBool("LOCKOUT_ENABLED", true)) {
|
|
2893
|
-
lockout = new AccountLockout(redisClient, {
|
|
2894
|
-
maxAttempts: getEnvNum("LOCKOUT_MAX_ATTEMPTS", 5),
|
|
2895
|
-
lockDuration: getEnvNum("LOCKOUT_DURATION_MINUTES", 15) * 60 * 1e3
|
|
2896
|
-
});
|
|
2897
|
-
}
|
|
2898
|
-
let rateLimiter;
|
|
2899
|
-
if (getEnvBool("RATE_LIMIT_ENABLED", true)) {
|
|
2900
|
-
rateLimiter = new RateLimiter(redisClient, {
|
|
2901
|
-
"auth:login": {
|
|
2902
|
-
window: getEnvNum("RATE_LIMIT_AUTH_WINDOW_MS", 9e5),
|
|
2903
|
-
max: getEnvNum("RATE_LIMIT_AUTH_MAX_REQUESTS", 10)
|
|
2904
|
-
},
|
|
2905
|
-
"api:general": {
|
|
2906
|
-
window: getEnvNum("RATE_LIMIT_WINDOW_MS", 6e4),
|
|
2907
|
-
max: getEnvNum("RATE_LIMIT_MAX_REQUESTS", 100)
|
|
2908
|
-
}
|
|
2909
|
-
});
|
|
2910
|
-
}
|
|
2911
|
-
let auditLogger;
|
|
2912
|
-
if (getEnvBool("AUDIT_LOG_ENABLED", true)) {
|
|
2913
|
-
auditLogger = new AuditLogger(
|
|
2914
|
-
redisClient,
|
|
2915
|
-
getEnvNum("AUDIT_LOG_RETENTION_DAYS", 30)
|
|
2916
|
-
);
|
|
2917
|
-
}
|
|
2918
|
-
const routes = new AuthRoutes({
|
|
2919
|
-
redis: redisAdapter,
|
|
2920
|
-
email,
|
|
2921
|
-
jwtSecret: getEnv("JWT_SECRET", "change-me"),
|
|
2922
|
-
jwtExpiresIn: getEnv("JWT_EXPIRES_IN", "24h"),
|
|
2923
|
-
jwtIssuer: getEnv("JWT_ISSUER", "kyro-cms"),
|
|
2924
|
-
jwtAudience: getEnv("JWT_AUDIENCE", "kyro-cms-client"),
|
|
2925
|
-
passwordPolicy,
|
|
2926
|
-
lockout,
|
|
2927
|
-
rateLimiter,
|
|
2928
|
-
auditLogger,
|
|
2929
|
-
baseUrl: getEnv("EMAIL_BASE_URL", "http://localhost:4321"),
|
|
2930
|
-
emailVerificationRequired: getEnvBool("EMAIL_VERIFICATION_REQUIRED", true)
|
|
2931
|
-
});
|
|
2932
|
-
return {
|
|
2933
|
-
redis: redisAdapter,
|
|
2934
|
-
redisClient,
|
|
2935
|
-
email,
|
|
2936
|
-
passwordPolicy,
|
|
2937
|
-
lockout,
|
|
2938
|
-
rateLimiter,
|
|
2939
|
-
auditLogger,
|
|
2940
|
-
routes
|
|
2941
|
-
};
|
|
2942
|
-
}
|
|
2943
|
-
function getEmailConfig() {
|
|
2944
|
-
const host = getEnv("SMTP_HOST");
|
|
2945
|
-
if (!host) return void 0;
|
|
2946
|
-
return {
|
|
2947
|
-
host,
|
|
2948
|
-
port: getEnvNum("SMTP_PORT", 587),
|
|
2949
|
-
secure: getEnvBool("SMTP_SECURE", false),
|
|
2950
|
-
auth: {
|
|
2951
|
-
user: getEnv("SMTP_USER"),
|
|
2952
|
-
pass: getEnv("SMTP_PASS")
|
|
2953
|
-
},
|
|
2954
|
-
from: getEnv("SMTP_FROM", "noreply@example.com"),
|
|
2955
|
-
fromName: getEnv("SMTP_FROM_NAME", "Kyro CMS")
|
|
2956
|
-
};
|
|
2957
|
-
}
|
|
2958
|
-
createAuthConfig();
|
|
2959
|
-
|
|
2960
|
-
export { AccountLockout, AuditLogger, ConfigValidationError, EmailTransport, PasswordPolicy, RateLimiter, RedisAuthAdapter, Registry, collectionToCreateZod, collectionToUpdateZod, collectionToWhereZod, collectionToZod, createAuditContext, createAuthConfig, fieldToZod, getRegistry, globalToZod, validateCollection, validateConfig, validateFields, validateGlobal };
|