@revealui/auth 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/useMFA.d.ts +83 -0
- package/dist/react/useMFA.d.ts.map +1 -0
- package/dist/react/useMFA.js +182 -0
- package/dist/react/usePasskey.d.ts +88 -0
- package/dist/react/usePasskey.d.ts.map +1 -0
- package/dist/react/usePasskey.js +203 -0
- package/dist/react/useSession.d.ts.map +1 -1
- package/dist/react/useSession.js +16 -5
- package/dist/react/useSignIn.d.ts +9 -3
- package/dist/react/useSignIn.d.ts.map +1 -1
- package/dist/react/useSignIn.js +32 -10
- package/dist/react/useSignOut.d.ts.map +1 -1
- package/dist/react/useSignUp.d.ts +1 -0
- package/dist/react/useSignUp.d.ts.map +1 -1
- package/dist/react/useSignUp.js +25 -9
- package/dist/server/auth.d.ts +2 -0
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +93 -5
- package/dist/server/brute-force.d.ts +10 -1
- package/dist/server/brute-force.d.ts.map +1 -1
- package/dist/server/brute-force.js +46 -23
- package/dist/server/errors.d.ts +4 -0
- package/dist/server/errors.d.ts.map +1 -1
- package/dist/server/errors.js +8 -0
- package/dist/server/index.d.ts +17 -6
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +12 -5
- package/dist/server/magic-link.d.ts +52 -0
- package/dist/server/magic-link.d.ts.map +1 -0
- package/dist/server/magic-link.js +111 -0
- package/dist/server/mfa.d.ts +87 -0
- package/dist/server/mfa.d.ts.map +1 -0
- package/dist/server/mfa.js +263 -0
- package/dist/server/oauth.d.ts +86 -0
- package/dist/server/oauth.d.ts.map +1 -0
- package/dist/server/oauth.js +355 -0
- package/dist/server/passkey.d.ts +132 -0
- package/dist/server/passkey.d.ts.map +1 -0
- package/dist/server/passkey.js +257 -0
- package/dist/server/password-reset.d.ts +32 -6
- package/dist/server/password-reset.d.ts.map +1 -1
- package/dist/server/password-reset.js +116 -47
- package/dist/server/password-validation.d.ts.map +1 -1
- package/dist/server/providers/github.d.ts +14 -0
- package/dist/server/providers/github.d.ts.map +1 -0
- package/dist/server/providers/github.js +89 -0
- package/dist/server/providers/google.d.ts +11 -0
- package/dist/server/providers/google.d.ts.map +1 -0
- package/dist/server/providers/google.js +69 -0
- package/dist/server/providers/vercel.d.ts +11 -0
- package/dist/server/providers/vercel.d.ts.map +1 -0
- package/dist/server/providers/vercel.js +63 -0
- package/dist/server/rate-limit.d.ts +10 -1
- package/dist/server/rate-limit.d.ts.map +1 -1
- package/dist/server/rate-limit.js +61 -43
- package/dist/server/session.d.ts +48 -1
- package/dist/server/session.d.ts.map +1 -1
- package/dist/server/session.js +126 -7
- package/dist/server/signed-cookie.d.ts +32 -0
- package/dist/server/signed-cookie.d.ts.map +1 -0
- package/dist/server/signed-cookie.js +67 -0
- package/dist/server/storage/database.d.ts +10 -1
- package/dist/server/storage/database.d.ts.map +1 -1
- package/dist/server/storage/database.js +43 -5
- package/dist/server/storage/in-memory.d.ts +4 -0
- package/dist/server/storage/in-memory.d.ts.map +1 -1
- package/dist/server/storage/in-memory.js +16 -6
- package/dist/server/storage/index.d.ts +11 -3
- package/dist/server/storage/index.d.ts.map +1 -1
- package/dist/server/storage/index.js +18 -4
- package/dist/server/storage/interface.d.ts +11 -1
- package/dist/server/storage/interface.d.ts.map +1 -1
- package/dist/server/storage/interface.js +1 -1
- package/dist/types.d.ts +23 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- package/dist/utils/database.d.ts.map +1 -1
- package/dist/utils/database.js +12 -2
- package/dist/utils/token.d.ts +9 -1
- package/dist/utils/token.d.ts.map +1 -1
- package/dist/utils/token.js +9 -1
- package/package.json +26 -8
package/dist/server/session.js
CHANGED
|
@@ -7,16 +7,61 @@
|
|
|
7
7
|
import { logger } from '@revealui/core/observability/logger';
|
|
8
8
|
import { getClient } from '@revealui/db/client';
|
|
9
9
|
import { sessions, users } from '@revealui/db/schema';
|
|
10
|
-
import { and, eq, gt } from 'drizzle-orm';
|
|
10
|
+
import { and, eq, gt, isNull } from 'drizzle-orm';
|
|
11
11
|
import { hashToken } from '../utils/token.js';
|
|
12
12
|
import { DatabaseError, TokenError } from './errors.js';
|
|
13
|
+
const DEFAULT_SESSION_BINDING = {
|
|
14
|
+
enforceUserAgent: true,
|
|
15
|
+
enforceIp: false,
|
|
16
|
+
warnOnIpChange: true,
|
|
17
|
+
};
|
|
18
|
+
let sessionBindingConfig = { ...DEFAULT_SESSION_BINDING };
|
|
19
|
+
/** Override session binding behaviour (useful for tests or strict deployments). */
|
|
20
|
+
export function configureSessionBinding(overrides) {
|
|
21
|
+
sessionBindingConfig = { ...DEFAULT_SESSION_BINDING, ...overrides };
|
|
22
|
+
}
|
|
23
|
+
/** Reset to defaults (for tests). */
|
|
24
|
+
export function resetSessionBindingConfig() {
|
|
25
|
+
sessionBindingConfig = { ...DEFAULT_SESSION_BINDING };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Validate that the current request context matches the session's stored
|
|
29
|
+
* binding values (IP address, user-agent).
|
|
30
|
+
*
|
|
31
|
+
* @returns `null` when the session is valid, or a reason string when it should
|
|
32
|
+
* be invalidated.
|
|
33
|
+
*/
|
|
34
|
+
export function validateSessionBinding(session, ctx) {
|
|
35
|
+
// User-agent enforcement
|
|
36
|
+
if (sessionBindingConfig.enforceUserAgent &&
|
|
37
|
+
ctx.userAgent &&
|
|
38
|
+
session.userAgent &&
|
|
39
|
+
ctx.userAgent !== session.userAgent) {
|
|
40
|
+
return 'user-agent mismatch';
|
|
41
|
+
}
|
|
42
|
+
// IP enforcement / warning
|
|
43
|
+
if (ctx.ipAddress && session.ipAddress && ctx.ipAddress !== session.ipAddress) {
|
|
44
|
+
if (sessionBindingConfig.enforceIp) {
|
|
45
|
+
return 'ip-address mismatch';
|
|
46
|
+
}
|
|
47
|
+
if (sessionBindingConfig.warnOnIpChange) {
|
|
48
|
+
logger.warn('Session IP changed', {
|
|
49
|
+
sessionId: session.id,
|
|
50
|
+
storedIp: session.ipAddress,
|
|
51
|
+
currentIp: ctx.ipAddress,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
13
57
|
/**
|
|
14
58
|
* Get session from request headers (cookie)
|
|
15
59
|
*
|
|
16
60
|
* @param headers - Request headers containing cookies
|
|
61
|
+
* @param requestContext - Optional IP / user-agent for session binding validation
|
|
17
62
|
* @returns Session data with user, or null if invalid/expired
|
|
18
63
|
*/
|
|
19
|
-
export async function getSession(headers) {
|
|
64
|
+
export async function getSession(headers, requestContext) {
|
|
20
65
|
try {
|
|
21
66
|
const cookieHeader = headers.get('cookie');
|
|
22
67
|
if (!cookieHeader) {
|
|
@@ -61,10 +106,32 @@ export async function getSession(headers) {
|
|
|
61
106
|
if (!session) {
|
|
62
107
|
return null;
|
|
63
108
|
}
|
|
109
|
+
// Session binding validation (IP / user-agent)
|
|
110
|
+
if (requestContext) {
|
|
111
|
+
const bindingError = validateSessionBinding(session, requestContext);
|
|
112
|
+
if (bindingError) {
|
|
113
|
+
logger.warn('Session binding violation — invalidating session', {
|
|
114
|
+
sessionId: session.id,
|
|
115
|
+
reason: bindingError,
|
|
116
|
+
});
|
|
117
|
+
// Delete the compromised session
|
|
118
|
+
try {
|
|
119
|
+
await db.delete(sessions).where(eq(sessions.id, session.id));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
logger.error('Failed to delete session after binding violation');
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
64
127
|
// Get user data
|
|
65
128
|
let user;
|
|
66
129
|
try {
|
|
67
|
-
const result = await db
|
|
130
|
+
const result = await db
|
|
131
|
+
.select()
|
|
132
|
+
.from(users)
|
|
133
|
+
.where(and(eq(users.id, session.userId), isNull(users.deletedAt)))
|
|
134
|
+
.limit(1);
|
|
68
135
|
user = result[0];
|
|
69
136
|
}
|
|
70
137
|
catch (error) {
|
|
@@ -128,9 +195,11 @@ export async function createSession(userId, options) {
|
|
|
128
195
|
logger.error('Error generating session token');
|
|
129
196
|
throw new TokenError('Failed to generate session token');
|
|
130
197
|
}
|
|
131
|
-
// Calculate expiration (7 days for persistent, 1 day for regular)
|
|
132
|
-
const expiresAt = new Date();
|
|
133
|
-
|
|
198
|
+
// Calculate expiration (custom override, 7 days for persistent, 1 day for regular)
|
|
199
|
+
const expiresAt = options?.expiresAt ?? new Date();
|
|
200
|
+
if (!options?.expiresAt) {
|
|
201
|
+
expiresAt.setDate(expiresAt.getDate() + (options?.persistent ? 7 : 1));
|
|
202
|
+
}
|
|
134
203
|
// Create session in database
|
|
135
204
|
let session;
|
|
136
205
|
try {
|
|
@@ -145,6 +214,7 @@ export async function createSession(userId, options) {
|
|
|
145
214
|
userAgent: options?.userAgent,
|
|
146
215
|
ipAddress: options?.ipAddress,
|
|
147
216
|
lastActivityAt: new Date(),
|
|
217
|
+
metadata: options?.metadata ?? null,
|
|
148
218
|
})
|
|
149
219
|
.returning();
|
|
150
220
|
session = result[0];
|
|
@@ -171,6 +241,55 @@ export async function createSession(userId, options) {
|
|
|
171
241
|
throw new DatabaseError('Unexpected error creating session', err);
|
|
172
242
|
}
|
|
173
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Rotate a user's session to prevent session fixation attacks.
|
|
246
|
+
*
|
|
247
|
+
* Deletes the old session (by token hash) or all sessions for the user,
|
|
248
|
+
* then creates a fresh session with a new token.
|
|
249
|
+
*
|
|
250
|
+
* @param userId - User ID to rotate sessions for
|
|
251
|
+
* @param options - Rotation options
|
|
252
|
+
* @returns New session token and session data
|
|
253
|
+
*/
|
|
254
|
+
export async function rotateSession(userId, options) {
|
|
255
|
+
try {
|
|
256
|
+
let db;
|
|
257
|
+
try {
|
|
258
|
+
db = getClient();
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
logger.error('Error getting database client in rotateSession');
|
|
262
|
+
throw new DatabaseError('Database connection failed', error);
|
|
263
|
+
}
|
|
264
|
+
// Invalidate old session(s)
|
|
265
|
+
try {
|
|
266
|
+
if (options?.oldSessionToken) {
|
|
267
|
+
const oldTokenHash = hashToken(options.oldSessionToken);
|
|
268
|
+
await db.delete(sessions).where(eq(sessions.tokenHash, oldTokenHash));
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
await db.delete(sessions).where(eq(sessions.userId, userId));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
logger.error('Error deleting old session(s) during rotation');
|
|
276
|
+
throw new DatabaseError('Failed to delete old session(s)', error);
|
|
277
|
+
}
|
|
278
|
+
// Create a fresh session
|
|
279
|
+
return await createSession(userId, {
|
|
280
|
+
persistent: options?.persistent,
|
|
281
|
+
userAgent: options?.userAgent,
|
|
282
|
+
ipAddress: options?.ipAddress,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
if (err instanceof DatabaseError || err instanceof TokenError) {
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
logger.error('Unexpected error in rotateSession');
|
|
290
|
+
throw new DatabaseError('Unexpected error rotating session', err);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
174
293
|
/**
|
|
175
294
|
* Delete a session (sign out)
|
|
176
295
|
*
|
|
@@ -235,7 +354,7 @@ function extractSessionToken(cookieHeader) {
|
|
|
235
354
|
if (!sessionCookie) {
|
|
236
355
|
return null;
|
|
237
356
|
}
|
|
238
|
-
return sessionCookie.substring('revealui-session='.length);
|
|
357
|
+
return decodeURIComponent(sessionCookie.substring('revealui-session='.length));
|
|
239
358
|
}
|
|
240
359
|
/**
|
|
241
360
|
* Generate a secure random session token
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed Cookie Utility
|
|
3
|
+
*
|
|
4
|
+
* Signs and verifies JSON payloads for stateless storage in httpOnly cookies.
|
|
5
|
+
* Used by MFA pre-auth flow and WebAuthn challenge flow.
|
|
6
|
+
*
|
|
7
|
+
* Format: base64url(JSON payload) + '.' + base64url(HMAC-SHA256 signature)
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Sign a payload for storage in an httpOnly cookie.
|
|
11
|
+
* Format: base64url(JSON payload) + '.' + base64url(HMAC-SHA256 signature)
|
|
12
|
+
*
|
|
13
|
+
* @param payload - Must include `expiresAt` (Unix ms timestamp)
|
|
14
|
+
* @param secret - HMAC signing key (e.g., REVEALUI_SECRET)
|
|
15
|
+
* @returns Signed string safe for cookie value
|
|
16
|
+
*/
|
|
17
|
+
export declare function signCookiePayload<T extends {
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
}>(payload: T, secret: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Verify and decode a signed cookie payload.
|
|
22
|
+
* Returns null if: signature invalid, expired, or malformed.
|
|
23
|
+
* Uses timing-safe comparison to prevent timing attacks.
|
|
24
|
+
*
|
|
25
|
+
* @param signed - Signed cookie string from `signCookiePayload`
|
|
26
|
+
* @param secret - HMAC signing key (must match the one used to sign)
|
|
27
|
+
* @returns Decoded payload or null if invalid
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyCookiePayload<T extends {
|
|
30
|
+
expiresAt: number;
|
|
31
|
+
}>(signed: string, secret: string): T | null;
|
|
32
|
+
//# sourceMappingURL=signed-cookie.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signed-cookie.d.ts","sourceRoot":"","sources":["../../src/server/signed-cookie.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,EAC/D,OAAO,EAAE,CAAC,EACV,MAAM,EAAE,MAAM,GACb,MAAM,CAQR;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,SAAS;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,EACjE,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GACb,CAAC,GAAG,IAAI,CAyCV"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed Cookie Utility
|
|
3
|
+
*
|
|
4
|
+
* Signs and verifies JSON payloads for stateless storage in httpOnly cookies.
|
|
5
|
+
* Used by MFA pre-auth flow and WebAuthn challenge flow.
|
|
6
|
+
*
|
|
7
|
+
* Format: base64url(JSON payload) + '.' + base64url(HMAC-SHA256 signature)
|
|
8
|
+
*/
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
/**
|
|
11
|
+
* Sign a payload for storage in an httpOnly cookie.
|
|
12
|
+
* Format: base64url(JSON payload) + '.' + base64url(HMAC-SHA256 signature)
|
|
13
|
+
*
|
|
14
|
+
* @param payload - Must include `expiresAt` (Unix ms timestamp)
|
|
15
|
+
* @param secret - HMAC signing key (e.g., REVEALUI_SECRET)
|
|
16
|
+
* @returns Signed string safe for cookie value
|
|
17
|
+
*/
|
|
18
|
+
export function signCookiePayload(payload, secret) {
|
|
19
|
+
const payloadJson = JSON.stringify(payload);
|
|
20
|
+
const payloadB64 = Buffer.from(payloadJson).toString('base64url');
|
|
21
|
+
const signature = crypto.createHmac('sha256', secret).update(payloadB64).digest();
|
|
22
|
+
const signatureB64 = signature.toString('base64url');
|
|
23
|
+
return `${payloadB64}.${signatureB64}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Verify and decode a signed cookie payload.
|
|
27
|
+
* Returns null if: signature invalid, expired, or malformed.
|
|
28
|
+
* Uses timing-safe comparison to prevent timing attacks.
|
|
29
|
+
*
|
|
30
|
+
* @param signed - Signed cookie string from `signCookiePayload`
|
|
31
|
+
* @param secret - HMAC signing key (must match the one used to sign)
|
|
32
|
+
* @returns Decoded payload or null if invalid
|
|
33
|
+
*/
|
|
34
|
+
export function verifyCookiePayload(signed, secret) {
|
|
35
|
+
try {
|
|
36
|
+
if (!signed) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const parts = signed.split('.');
|
|
40
|
+
if (parts.length !== 2) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const [payloadB64, signatureB64] = parts;
|
|
44
|
+
// Recompute the expected signature
|
|
45
|
+
const expectedSignature = crypto.createHmac('sha256', secret).update(payloadB64).digest();
|
|
46
|
+
const actualSignature = Buffer.from(signatureB64, 'base64url');
|
|
47
|
+
// Timing-safe comparison — buffers must be same length
|
|
48
|
+
if (expectedSignature.length !== actualSignature.length) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (!crypto.timingSafeEqual(expectedSignature, actualSignature)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
// Signature valid — decode and parse the payload
|
|
55
|
+
const payloadJson = Buffer.from(payloadB64, 'base64url').toString('utf8');
|
|
56
|
+
const payload = JSON.parse(payloadJson);
|
|
57
|
+
// Check expiry
|
|
58
|
+
if (typeof payload.expiresAt !== 'number' || payload.expiresAt <= Date.now()) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return payload;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Malformed input (bad base64, bad JSON, etc.) → null
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Database Storage
|
|
3
3
|
*
|
|
4
4
|
* Database backend using Drizzle ORM
|
|
5
|
-
*
|
|
5
|
+
* Uses existing database infrastructure with no external cache dependency
|
|
6
6
|
*/
|
|
7
7
|
import type { Storage } from './interface.js';
|
|
8
8
|
export declare class DatabaseStorage implements Storage {
|
|
@@ -13,5 +13,14 @@ export declare class DatabaseStorage implements Storage {
|
|
|
13
13
|
del(key: string): Promise<void>;
|
|
14
14
|
incr(key: string): Promise<number>;
|
|
15
15
|
exists(key: string): Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Atomically read and update a value using a database transaction.
|
|
18
|
+
* Falls back to non-atomic get-then-set if transactions are unavailable
|
|
19
|
+
* (e.g., Neon HTTP mode in environments that don't support advisory locks).
|
|
20
|
+
*/
|
|
21
|
+
atomicUpdate(key: string, updater: (existing: string | null) => {
|
|
22
|
+
value: string;
|
|
23
|
+
ttlSeconds: number;
|
|
24
|
+
}): Promise<void>;
|
|
16
25
|
}
|
|
17
26
|
//# sourceMappingURL=database.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../../src/server/storage/database.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../../src/server/storage/database.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAQH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAE9C,qBAAa,eAAgB,YAAW,OAAO;IAC7C,OAAO,CAAC,EAAE,CAAW;gBAET,gBAAgB,CAAC,EAAE,MAAM;IAkB/B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAcxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBnE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IASlC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAK3C;;;;OAIG;IACG,YAAY,CAChB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,KAAK;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAC1E,OAAO,CAAC,IAAI,CAAC;CA6BjB"}
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
* Database Storage
|
|
3
3
|
*
|
|
4
4
|
* Database backend using Drizzle ORM
|
|
5
|
-
*
|
|
5
|
+
* Uses existing database infrastructure with no external cache dependency
|
|
6
6
|
*/
|
|
7
7
|
// Import config module (ESM)
|
|
8
8
|
// Config uses proxy for lazy loading, so import is safe - validation only happens on property access
|
|
9
9
|
import configModule from '@revealui/config';
|
|
10
10
|
import { createClient } from '@revealui/db/client';
|
|
11
|
-
import {
|
|
11
|
+
import { rateLimits } from '@revealui/db/schema';
|
|
12
|
+
import { and, eq, gte } from 'drizzle-orm';
|
|
12
13
|
export class DatabaseStorage {
|
|
13
14
|
db;
|
|
14
15
|
constructor(connectionString) {
|
|
@@ -60,13 +61,50 @@ export class DatabaseStorage {
|
|
|
60
61
|
await this.db.delete(rateLimits).where(eq(rateLimits.key, key));
|
|
61
62
|
}
|
|
62
63
|
async incr(key) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
let newValue = 1;
|
|
65
|
+
await this.atomicUpdate(key, (existing) => {
|
|
66
|
+
newValue = existing ? parseInt(existing, 10) + 1 : 1;
|
|
67
|
+
return { value: String(newValue), ttlSeconds: 24 * 60 * 60 };
|
|
68
|
+
});
|
|
66
69
|
return newValue;
|
|
67
70
|
}
|
|
68
71
|
async exists(key) {
|
|
69
72
|
const result = await this.get(key);
|
|
70
73
|
return result !== null;
|
|
71
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Atomically read and update a value using a database transaction.
|
|
77
|
+
* Falls back to non-atomic get-then-set if transactions are unavailable
|
|
78
|
+
* (e.g., Neon HTTP mode in environments that don't support advisory locks).
|
|
79
|
+
*/
|
|
80
|
+
async atomicUpdate(key, updater) {
|
|
81
|
+
try {
|
|
82
|
+
await this.db.transaction(async (tx) => {
|
|
83
|
+
const now = new Date();
|
|
84
|
+
const result = await tx.query.rateLimits.findFirst({
|
|
85
|
+
where: and(eq(rateLimits.key, key), gte(rateLimits.resetAt, now)),
|
|
86
|
+
});
|
|
87
|
+
const { value, ttlSeconds } = updater(result?.value ?? null);
|
|
88
|
+
const resetAt = new Date(Date.now() + ttlSeconds * 1000);
|
|
89
|
+
await tx
|
|
90
|
+
.insert(rateLimits)
|
|
91
|
+
.values({ key, value, resetAt })
|
|
92
|
+
.onConflictDoUpdate({
|
|
93
|
+
target: rateLimits.key,
|
|
94
|
+
set: { value, resetAt, updatedAt: new Date() },
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// Only fall back for transaction-not-supported errors (e.g., Neon HTTP serverless).
|
|
100
|
+
// Re-throw real DB errors (connection failures, constraint violations, deadlocks).
|
|
101
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
102
|
+
if (!(msg.includes('transaction') || msg.includes('Transaction'))) {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const existing = await this.get(key);
|
|
106
|
+
const { value, ttlSeconds } = updater(existing);
|
|
107
|
+
await this.set(key, value, ttlSeconds);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
72
110
|
}
|
|
@@ -12,6 +12,10 @@ export declare class InMemoryStorage implements Storage {
|
|
|
12
12
|
del(key: string): Promise<void>;
|
|
13
13
|
incr(key: string): Promise<number>;
|
|
14
14
|
exists(key: string): Promise<boolean>;
|
|
15
|
+
atomicUpdate(key: string, updater: (existing: string | null) => {
|
|
16
|
+
value: string;
|
|
17
|
+
ttlSeconds: number;
|
|
18
|
+
}): Promise<void>;
|
|
15
19
|
/**
|
|
16
20
|
* Clean up expired entries (should be called periodically)
|
|
17
21
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"in-memory.d.ts","sourceRoot":"","sources":["../../../src/server/storage/in-memory.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,
|
|
1
|
+
{"version":3,"file":"in-memory.d.ts","sourceRoot":"","sources":["../../../src/server/storage/in-memory.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAO9C,qBAAa,eAAgB,YAAW,OAAO;IAC7C,OAAO,CAAC,KAAK,CAAwC;IAE/C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAgBxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASnE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAYlC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAgBrC,YAAY,CAChB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,KAAK;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAC1E,OAAO,CAAC,IAAI,CAAC;IAUhB;;OAEG;IACH,OAAO,IAAI,IAAI;IASf;;OAEG;IACH,KAAK,IAAI,IAAI;CAGd"}
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export class InMemoryStorage {
|
|
8
8
|
store = new Map();
|
|
9
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
10
9
|
async get(key) {
|
|
11
10
|
const entry = this.store.get(key);
|
|
12
11
|
if (!entry) {
|
|
@@ -19,7 +18,6 @@ export class InMemoryStorage {
|
|
|
19
18
|
}
|
|
20
19
|
return entry.value;
|
|
21
20
|
}
|
|
22
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
23
21
|
async set(key, value, ttlSeconds) {
|
|
24
22
|
const entry = {
|
|
25
23
|
value,
|
|
@@ -27,17 +25,20 @@ export class InMemoryStorage {
|
|
|
27
25
|
};
|
|
28
26
|
this.store.set(key, entry);
|
|
29
27
|
}
|
|
30
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
31
28
|
async del(key) {
|
|
32
29
|
this.store.delete(key);
|
|
33
30
|
}
|
|
34
31
|
async incr(key) {
|
|
35
|
-
|
|
32
|
+
// Read and write synchronously on the Map to avoid yielding to the event loop
|
|
33
|
+
// between read and write (same approach as atomicUpdate).
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const entry = this.store.get(key);
|
|
36
|
+
const current = entry && (!entry.expiresAt || entry.expiresAt >= now) ? entry.value : null;
|
|
36
37
|
const newValue = current ? parseInt(current, 10) + 1 : 1;
|
|
37
|
-
|
|
38
|
+
// Preserve the original TTL if the entry exists
|
|
39
|
+
this.store.set(key, { value: String(newValue), expiresAt: entry?.expiresAt });
|
|
38
40
|
return newValue;
|
|
39
41
|
}
|
|
40
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
41
42
|
async exists(key) {
|
|
42
43
|
const entry = this.store.get(key);
|
|
43
44
|
if (!entry) {
|
|
@@ -50,6 +51,15 @@ export class InMemoryStorage {
|
|
|
50
51
|
}
|
|
51
52
|
return true;
|
|
52
53
|
}
|
|
54
|
+
async atomicUpdate(key, updater) {
|
|
55
|
+
// Read synchronously from the Map to avoid yielding to the event loop between
|
|
56
|
+
// read and write (JavaScript is single-threaded; no I/O = no interleaving).
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const entry = this.store.get(key);
|
|
59
|
+
const existing = entry && (!entry.expiresAt || entry.expiresAt >= now) ? entry.value : null;
|
|
60
|
+
const { value, ttlSeconds } = updater(existing);
|
|
61
|
+
this.store.set(key, { value, expiresAt: now + ttlSeconds * 1000 });
|
|
62
|
+
}
|
|
53
63
|
/**
|
|
54
64
|
* Clean up expired entries (should be called periodically)
|
|
55
65
|
*/
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Storage Factory
|
|
3
3
|
*
|
|
4
|
-
* Selects storage backend based on configuration
|
|
4
|
+
* Selects storage backend based on configuration.
|
|
5
5
|
* Priority: Database > In-Memory
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Architecture Decision (2026-03-11):
|
|
8
|
+
* Production deployments use DatabaseStorage backed by NeonDB (PostgreSQL).
|
|
9
|
+
* Neon's serverless driver uses HTTP (not persistent connections), so each
|
|
10
|
+
* rate limit check is a single HTTP round-trip (~30-50ms). State persists
|
|
11
|
+
* across Vercel cold starts because it lives in PostgreSQL, not process memory.
|
|
12
|
+
* This is acceptable for current scale. If sub-10ms latency becomes critical,
|
|
13
|
+
* add an ElectricSQL/PGlite adapter implementing the Storage interface.
|
|
14
|
+
*
|
|
15
|
+
* In-memory storage is ONLY used in development (throws in production if
|
|
16
|
+
* DATABASE_URL is missing).
|
|
9
17
|
*/
|
|
10
18
|
import type { Storage } from './interface.js';
|
|
11
19
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/storage/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/storage/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAI9C;;GAEG;AACH,wBAAgB,UAAU,IAAI,OAAO,CA6CpC;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAevC;AAED;;GAEG;AACH,wBAAgB,YAAY,IAAI,IAAI,CAEnC;AAED,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEhD,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,YAAY,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC"}
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Storage Factory
|
|
3
3
|
*
|
|
4
|
-
* Selects storage backend based on configuration
|
|
4
|
+
* Selects storage backend based on configuration.
|
|
5
5
|
* Priority: Database > In-Memory
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Architecture Decision (2026-03-11):
|
|
8
|
+
* Production deployments use DatabaseStorage backed by NeonDB (PostgreSQL).
|
|
9
|
+
* Neon's serverless driver uses HTTP (not persistent connections), so each
|
|
10
|
+
* rate limit check is a single HTTP round-trip (~30-50ms). State persists
|
|
11
|
+
* across Vercel cold starts because it lives in PostgreSQL, not process memory.
|
|
12
|
+
* This is acceptable for current scale. If sub-10ms latency becomes critical,
|
|
13
|
+
* add an ElectricSQL/PGlite adapter implementing the Storage interface.
|
|
14
|
+
*
|
|
15
|
+
* In-memory storage is ONLY used in development (throws in production if
|
|
16
|
+
* DATABASE_URL is missing).
|
|
9
17
|
*/
|
|
10
18
|
import config from '@revealui/config';
|
|
11
19
|
import { logger } from '@revealui/core/observability/logger';
|
|
@@ -40,12 +48,18 @@ export function getStorage() {
|
|
|
40
48
|
return globalStorage;
|
|
41
49
|
}
|
|
42
50
|
catch (error) {
|
|
51
|
+
if (process.env.NODE_ENV === 'production') {
|
|
52
|
+
throw new Error(`Rate limiting requires database storage in production. DatabaseStorage failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
|
+
}
|
|
43
54
|
logger.warn('Failed to create DatabaseStorage, falling back to InMemoryStorage', {
|
|
44
55
|
error: error instanceof Error ? error.message : String(error),
|
|
45
56
|
});
|
|
46
57
|
}
|
|
47
58
|
}
|
|
48
|
-
|
|
59
|
+
if (process.env.NODE_ENV === 'production') {
|
|
60
|
+
throw new Error('Rate limiting requires DATABASE_URL or POSTGRES_URL in production. In-memory storage is not safe for distributed deployments.');
|
|
61
|
+
}
|
|
62
|
+
// Fallback to in-memory (development only)
|
|
49
63
|
globalStorage = new InMemoryStorage();
|
|
50
64
|
return globalStorage;
|
|
51
65
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Storage Interface
|
|
3
3
|
*
|
|
4
|
-
* Abstract interface for storage backends (in-memory,
|
|
4
|
+
* Abstract interface for storage backends (in-memory, database)
|
|
5
5
|
*/
|
|
6
6
|
export interface Storage {
|
|
7
7
|
/**
|
|
@@ -32,5 +32,15 @@ export interface Storage {
|
|
|
32
32
|
* Set multiple key-value pairs
|
|
33
33
|
*/
|
|
34
34
|
mset?(pairs: Array<[string, string]>, ttlSeconds?: number): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Atomically read and update a value.
|
|
37
|
+
* The updater receives the current value (or null) and returns the new value + TTL.
|
|
38
|
+
* Implementations that support DB transactions should do so; others fall back to
|
|
39
|
+
* a non-atomic get-then-set, which is safe for low-concurrency scenarios.
|
|
40
|
+
*/
|
|
41
|
+
atomicUpdate?(key: string, updater: (existing: string | null) => {
|
|
42
|
+
value: string;
|
|
43
|
+
ttlSeconds: number;
|
|
44
|
+
}): Promise<void>;
|
|
35
45
|
}
|
|
36
46
|
//# sourceMappingURL=interface.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interface.d.ts","sourceRoot":"","sources":["../../../src/server/storage/interface.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,OAAO;IACtB;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"interface.d.ts","sourceRoot":"","sources":["../../../src/server/storage/interface.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,OAAO;IACtB;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAEzC;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpE;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhC;;OAEG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAEnC;;OAEG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEtC;;OAEG;IACH,IAAI,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;IAElD;;OAEG;IACH,IAAI,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1E;;;;;OAKG;IACH,YAAY,CAAC,CACX,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,KAAK;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAC1E,OAAO,CAAC,IAAI,CAAC,CAAC;CAClB"}
|
package/dist/types.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Auth Types
|
|
3
3
|
*
|
|
4
4
|
* Type definitions for authentication system.
|
|
5
|
-
* Uses concrete interfaces instead of z.infer<> aliases
|
|
6
|
-
*
|
|
5
|
+
* Uses concrete interfaces instead of z.infer<> aliases for
|
|
6
|
+
* clear type definitions and better IDE support.
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
9
|
* User row type matching the users table schema.
|
|
@@ -22,6 +22,11 @@ export interface User {
|
|
|
22
22
|
agentModel: string | null;
|
|
23
23
|
agentCapabilities: string[] | null;
|
|
24
24
|
agentConfig: unknown;
|
|
25
|
+
emailVerified: boolean;
|
|
26
|
+
emailVerificationToken: string | null;
|
|
27
|
+
emailVerifiedAt: Date | null;
|
|
28
|
+
mfaEnabled: boolean;
|
|
29
|
+
mfaVerifiedAt: Date | null;
|
|
25
30
|
preferences: unknown;
|
|
26
31
|
createdAt: Date;
|
|
27
32
|
updatedAt: Date;
|
|
@@ -43,17 +48,27 @@ export interface Session {
|
|
|
43
48
|
lastActivityAt: Date;
|
|
44
49
|
createdAt: Date;
|
|
45
50
|
expiresAt: Date;
|
|
51
|
+
metadata: Record<string, unknown> | null;
|
|
46
52
|
}
|
|
47
53
|
export interface AuthSession {
|
|
48
54
|
session: Session;
|
|
49
55
|
user: User;
|
|
50
56
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
/** Discriminated union for sign-in outcomes. Check `success` first, then `reason` for failure details. */
|
|
58
|
+
export type SignInResult = {
|
|
59
|
+
success: true;
|
|
60
|
+
requiresMfa?: false;
|
|
61
|
+
user: User;
|
|
62
|
+
sessionToken: string;
|
|
63
|
+
} | {
|
|
64
|
+
success: true;
|
|
65
|
+
requiresMfa: true;
|
|
66
|
+
mfaUserId: string;
|
|
67
|
+
} | {
|
|
68
|
+
success: false;
|
|
69
|
+
reason: 'invalid_credentials' | 'account_locked' | 'rate_limited' | 'database_error' | 'session_error' | 'email_not_verified' | 'unexpected_error';
|
|
70
|
+
error: string;
|
|
71
|
+
};
|
|
57
72
|
export interface SignUpResult {
|
|
58
73
|
success: boolean;
|
|
59
74
|
user?: User;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;GAGG;AACH,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;GAGG;AACH,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,iBAAiB,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IACnC,WAAW,EAAE,OAAO,CAAC;IACrB,aAAa,EAAE,OAAO,CAAC;IACvB,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,eAAe,EAAE,IAAI,GAAG,IAAI,CAAC;IAC7B,UAAU,EAAE,OAAO,CAAC;IACpB,aAAa,EAAE,IAAI,GAAG,IAAI,CAAC;IAC3B,WAAW,EAAE,OAAO,CAAC;IACrB,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;IAChB,YAAY,EAAE,IAAI,GAAG,IAAI,CAAC;IAE1B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;GAGG;AACH,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,OAAO,GAAG,IAAI,CAAC;IAC3B,cAAc,EAAE,IAAI,CAAC;IACrB,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC1C;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,IAAI,CAAC;CACZ;AAED,0GAA0G;AAC1G,MAAM,MAAM,YAAY,GACpB;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,WAAW,CAAC,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACxE;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,WAAW,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GACvD;IACE,OAAO,EAAE,KAAK,CAAC;IACf,MAAM,EACF,qBAAqB,GACrB,gBAAgB,GAChB,cAAc,GACd,gBAAgB,GAChB,eAAe,GACf,oBAAoB,GACpB,kBAAkB,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEN,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB"}
|