create-velox-app 0.6.31 → 0.6.51
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/CHANGELOG.md +120 -0
- package/GUIDE.md +230 -0
- package/dist/cli.js +1 -0
- package/dist/index.js +14 -4
- package/dist/templates/auth.js +10 -0
- package/dist/templates/index.js +30 -1
- package/dist/templates/placeholders.js +0 -3
- package/dist/templates/rsc-auth.d.ts +12 -0
- package/dist/templates/rsc-auth.js +208 -0
- package/dist/templates/rsc.js +40 -1
- package/dist/templates/shared/css-generator.d.ts +26 -0
- package/dist/templates/shared/css-generator.js +553 -0
- package/dist/templates/shared/index.d.ts +3 -0
- package/dist/templates/shared/index.js +3 -0
- package/dist/templates/shared/rsc-styles.d.ts +54 -0
- package/dist/templates/shared/rsc-styles.js +68 -0
- package/dist/templates/shared/theme.d.ts +133 -0
- package/dist/templates/shared/theme.js +141 -0
- package/dist/templates/spa.js +10 -0
- package/dist/templates/trpc.js +10 -0
- package/dist/templates/types.d.ts +2 -1
- package/dist/templates/types.js +6 -0
- package/package.json +6 -3
- package/src/templates/source/api/config/database.ts +13 -32
- package/src/templates/source/api/docker-compose.yml +21 -0
- package/src/templates/source/root/CLAUDE.auth.md +6 -0
- package/src/templates/source/root/CLAUDE.default.md +6 -0
- package/src/templates/source/rsc/CLAUDE.md +56 -2
- package/src/templates/source/rsc/app/actions/posts.ts +1 -1
- package/src/templates/source/rsc/app/actions/users.ts +111 -20
- package/src/templates/source/rsc/app/layouts/dashboard.tsx +21 -16
- package/src/templates/source/rsc/app/layouts/marketing.tsx +34 -0
- package/src/templates/source/rsc/app/layouts/minimal-content.tsx +21 -0
- package/src/templates/source/rsc/app/layouts/minimal.tsx +86 -5
- package/src/templates/source/rsc/app/layouts/root.tsx +148 -44
- package/src/templates/source/rsc/docker-compose.yml +21 -0
- package/src/templates/source/rsc/package.json +3 -3
- package/src/templates/source/rsc/src/api/database.ts +13 -32
- package/src/templates/source/rsc/src/api/handler.ts +1 -1
- package/src/templates/source/rsc/src/entry.client.tsx +65 -18
- package/src/templates/source/rsc-auth/CLAUDE.md +230 -0
- package/src/templates/source/rsc-auth/app/actions/auth.ts +112 -0
- package/src/templates/source/rsc-auth/app/actions/users.ts +289 -0
- package/src/templates/source/rsc-auth/app/layouts/dashboard.tsx +132 -0
- package/src/templates/source/rsc-auth/app/layouts/marketing.tsx +59 -0
- package/src/templates/source/rsc-auth/app/layouts/minimal-content.tsx +21 -0
- package/src/templates/source/rsc-auth/app/layouts/minimal.tsx +111 -0
- package/src/templates/source/rsc-auth/app/layouts/root.tsx +355 -0
- package/src/templates/source/rsc-auth/app/pages/_not-found.tsx +15 -0
- package/src/templates/source/rsc-auth/app/pages/auth/login.tsx +198 -0
- package/src/templates/source/rsc-auth/app/pages/auth/register.tsx +225 -0
- package/src/templates/source/rsc-auth/app/pages/dashboard/index.tsx +267 -0
- package/src/templates/source/rsc-auth/app/pages/index.tsx +83 -0
- package/src/templates/source/rsc-auth/app/pages/users.tsx +47 -0
- package/src/templates/source/rsc-auth/app.config.ts +12 -0
- package/src/templates/source/rsc-auth/docker-compose.yml +21 -0
- package/src/templates/source/rsc-auth/env.example +11 -0
- package/src/templates/source/rsc-auth/gitignore +34 -0
- package/src/templates/source/rsc-auth/package.json +44 -0
- package/src/templates/source/rsc-auth/prisma/schema.prisma +23 -0
- package/src/templates/source/rsc-auth/prisma.config.ts +22 -0
- package/src/templates/source/rsc-auth/public/favicon.svg +4 -0
- package/src/templates/source/rsc-auth/src/api/database.ts +129 -0
- package/src/templates/source/rsc-auth/src/api/handler.ts +85 -0
- package/src/templates/source/rsc-auth/src/api/procedures/auth.ts +262 -0
- package/src/templates/source/rsc-auth/src/api/procedures/health.ts +48 -0
- package/src/templates/source/rsc-auth/src/api/procedures/users.ts +87 -0
- package/src/templates/source/rsc-auth/src/api/schemas/auth.ts +79 -0
- package/src/templates/source/rsc-auth/src/api/schemas/user.ts +38 -0
- package/src/templates/source/rsc-auth/src/api/utils/auth.ts +157 -0
- package/src/templates/source/rsc-auth/src/entry.client.tsx +63 -0
- package/src/templates/source/rsc-auth/src/entry.server.tsx +262 -0
- package/src/templates/source/rsc-auth/tsconfig.json +24 -0
- package/src/templates/source/shared/scripts/check-client-imports.sh +75 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Procedures
|
|
3
|
+
*
|
|
4
|
+
* Authentication procedures for user registration, login, and token management.
|
|
5
|
+
*
|
|
6
|
+
* REST Endpoints:
|
|
7
|
+
* - POST /auth/register - Create new account
|
|
8
|
+
* - POST /auth/login - Authenticate and get tokens
|
|
9
|
+
* - POST /auth/refresh - Refresh access token
|
|
10
|
+
* - POST /auth/logout - Revoke current token
|
|
11
|
+
* - GET /auth/me - Get current user (protected)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
AuthError,
|
|
16
|
+
authenticated,
|
|
17
|
+
createAuthRateLimiter,
|
|
18
|
+
hashPassword,
|
|
19
|
+
jwtManager,
|
|
20
|
+
verifyPassword,
|
|
21
|
+
} from '@veloxts/auth';
|
|
22
|
+
import { procedure, procedures } from '@veloxts/router';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
LoginInput,
|
|
26
|
+
LogoutResponse,
|
|
27
|
+
RefreshInput,
|
|
28
|
+
RegisterInput,
|
|
29
|
+
TokenResponse,
|
|
30
|
+
UserResponse,
|
|
31
|
+
} from '../schemas/auth.js';
|
|
32
|
+
import { getJwtSecrets, parseUserRoles, tokenStore } from '../utils/auth.js';
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Rate Limiter
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
const rateLimiter = createAuthRateLimiter({
|
|
39
|
+
login: {
|
|
40
|
+
maxAttempts: 5,
|
|
41
|
+
windowMs: 15 * 60 * 1000,
|
|
42
|
+
lockoutDurationMs: 15 * 60 * 1000,
|
|
43
|
+
progressiveBackoff: true,
|
|
44
|
+
},
|
|
45
|
+
register: {
|
|
46
|
+
maxAttempts: 3,
|
|
47
|
+
windowMs: 60 * 60 * 1000,
|
|
48
|
+
lockoutDurationMs: 60 * 60 * 1000,
|
|
49
|
+
},
|
|
50
|
+
refresh: {
|
|
51
|
+
maxAttempts: 10,
|
|
52
|
+
windowMs: 60 * 1000,
|
|
53
|
+
lockoutDurationMs: 60 * 1000,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// Password Blacklist (runtime-only, not in type chain)
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
const COMMON_PASSWORDS = new Set([
|
|
62
|
+
'password',
|
|
63
|
+
'password123',
|
|
64
|
+
'12345678',
|
|
65
|
+
'123456789',
|
|
66
|
+
'qwerty123',
|
|
67
|
+
'letmein',
|
|
68
|
+
'welcome',
|
|
69
|
+
'admin123',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
// Enhanced password validation with common password check
|
|
73
|
+
const EnhancedRegisterInput = RegisterInput.extend({
|
|
74
|
+
password: RegisterInput.shape.password
|
|
75
|
+
.refine((pwd) => /[a-z]/.test(pwd), 'Password must contain at least one lowercase letter')
|
|
76
|
+
.refine((pwd) => /[A-Z]/.test(pwd), 'Password must contain at least one uppercase letter')
|
|
77
|
+
.refine((pwd) => /[0-9]/.test(pwd), 'Password must contain at least one number')
|
|
78
|
+
.refine(
|
|
79
|
+
(pwd) => !COMMON_PASSWORDS.has(pwd.toLowerCase()),
|
|
80
|
+
'Password is too common. Please choose a stronger password.'
|
|
81
|
+
),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// JWT Manager
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
const { jwtSecret, refreshSecret } = getJwtSecrets();
|
|
89
|
+
|
|
90
|
+
const jwt = jwtManager({
|
|
91
|
+
secret: jwtSecret,
|
|
92
|
+
refreshSecret: refreshSecret,
|
|
93
|
+
accessTokenExpiry: '15m',
|
|
94
|
+
refreshTokenExpiry: '7d',
|
|
95
|
+
issuer: 'velox-app',
|
|
96
|
+
audience: 'velox-app-client',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Dummy hash for timing attack prevention
|
|
100
|
+
const DUMMY_HASH = '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.uy7dPSSXB5G6Uy';
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// Auth Procedures
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
export const authProcedures = procedures('auth', {
|
|
107
|
+
createAccount: procedure()
|
|
108
|
+
.rest({ method: 'POST', path: '/auth/register' })
|
|
109
|
+
.use(rateLimiter.register())
|
|
110
|
+
.input(EnhancedRegisterInput)
|
|
111
|
+
.output(TokenResponse)
|
|
112
|
+
.mutation(async ({ input, ctx }) => {
|
|
113
|
+
const normalizedEmail = input.email.toLowerCase().trim();
|
|
114
|
+
|
|
115
|
+
const existing = await ctx.db.user.findUnique({
|
|
116
|
+
where: { email: normalizedEmail },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (existing) {
|
|
120
|
+
throw new AuthError(
|
|
121
|
+
'Registration failed. If this email is not already registered, please try again.',
|
|
122
|
+
400,
|
|
123
|
+
'REGISTRATION_FAILED'
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const hashedPassword = await hashPassword(input.password);
|
|
128
|
+
|
|
129
|
+
const user = await ctx.db.user.create({
|
|
130
|
+
data: {
|
|
131
|
+
name: input.name.trim(),
|
|
132
|
+
email: normalizedEmail,
|
|
133
|
+
password: hashedPassword,
|
|
134
|
+
roles: JSON.stringify(['user']),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return jwt.createTokenPair({
|
|
139
|
+
id: user.id,
|
|
140
|
+
email: user.email,
|
|
141
|
+
roles: ['user'],
|
|
142
|
+
});
|
|
143
|
+
}),
|
|
144
|
+
|
|
145
|
+
createSession: procedure()
|
|
146
|
+
.rest({ method: 'POST', path: '/auth/login' })
|
|
147
|
+
.use(
|
|
148
|
+
rateLimiter.login((ctx) => {
|
|
149
|
+
const input = ctx.input as { email?: string } | undefined;
|
|
150
|
+
return input?.email?.toLowerCase() ?? '';
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
.input(LoginInput)
|
|
154
|
+
.output(TokenResponse)
|
|
155
|
+
.mutation(async ({ input, ctx }) => {
|
|
156
|
+
const normalizedEmail = input.email.toLowerCase().trim();
|
|
157
|
+
|
|
158
|
+
const user = await ctx.db.user.findUnique({
|
|
159
|
+
where: { email: normalizedEmail },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const hashToVerify = user?.password || DUMMY_HASH;
|
|
163
|
+
const isValid = await verifyPassword(input.password, hashToVerify);
|
|
164
|
+
|
|
165
|
+
if (!user || !user.password || !isValid) {
|
|
166
|
+
throw new AuthError('Invalid email or password', 401, 'INVALID_CREDENTIALS');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const roles = parseUserRoles(user.roles);
|
|
170
|
+
|
|
171
|
+
return jwt.createTokenPair({
|
|
172
|
+
id: user.id,
|
|
173
|
+
email: user.email,
|
|
174
|
+
roles,
|
|
175
|
+
});
|
|
176
|
+
}),
|
|
177
|
+
|
|
178
|
+
createRefresh: procedure()
|
|
179
|
+
.rest({ method: 'POST', path: '/auth/refresh' })
|
|
180
|
+
.use(rateLimiter.refresh())
|
|
181
|
+
.input(RefreshInput)
|
|
182
|
+
.output(TokenResponse)
|
|
183
|
+
.mutation(async ({ input, ctx }) => {
|
|
184
|
+
try {
|
|
185
|
+
const payload = jwt.verifyToken(input.refreshToken);
|
|
186
|
+
|
|
187
|
+
if (payload.type !== 'refresh') {
|
|
188
|
+
throw new AuthError('Invalid token type', 401, 'INVALID_TOKEN_TYPE');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (payload.jti && tokenStore.isRevoked(payload.jti)) {
|
|
192
|
+
throw new AuthError('Token has been revoked', 401, 'TOKEN_REVOKED');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (payload.jti) {
|
|
196
|
+
const previousUserId = tokenStore.isRefreshTokenUsed(payload.jti);
|
|
197
|
+
if (previousUserId) {
|
|
198
|
+
tokenStore.revokeAllUserTokens(previousUserId);
|
|
199
|
+
throw new AuthError(
|
|
200
|
+
'Security alert: Refresh token reuse detected.',
|
|
201
|
+
401,
|
|
202
|
+
'TOKEN_REUSE_DETECTED'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
tokenStore.markRefreshTokenUsed(payload.jti, payload.sub);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const user = await ctx.db.user.findUnique({
|
|
209
|
+
where: { id: payload.sub },
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (!user) {
|
|
213
|
+
throw new AuthError('User not found', 401, 'USER_NOT_FOUND');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return jwt.createTokenPair({
|
|
217
|
+
id: user.id,
|
|
218
|
+
email: user.email,
|
|
219
|
+
roles: parseUserRoles(user.roles),
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error instanceof AuthError) throw error;
|
|
223
|
+
throw new AuthError('Invalid refresh token', 401, 'INVALID_REFRESH_TOKEN');
|
|
224
|
+
}
|
|
225
|
+
}),
|
|
226
|
+
|
|
227
|
+
deleteSession: procedure()
|
|
228
|
+
.rest({ method: 'POST', path: '/auth/logout' })
|
|
229
|
+
.guard(authenticated)
|
|
230
|
+
.output(LogoutResponse)
|
|
231
|
+
.mutation(async ({ ctx }) => {
|
|
232
|
+
const tokenId = ctx.auth?.token?.jti;
|
|
233
|
+
|
|
234
|
+
if (tokenId) {
|
|
235
|
+
tokenStore.revoke(tokenId, 15 * 60 * 1000);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
success: true,
|
|
240
|
+
message: 'Successfully logged out',
|
|
241
|
+
};
|
|
242
|
+
}),
|
|
243
|
+
|
|
244
|
+
getMe: procedure()
|
|
245
|
+
.rest({ method: 'GET', path: '/auth/me' })
|
|
246
|
+
.guard(authenticated)
|
|
247
|
+
.output(UserResponse)
|
|
248
|
+
.query(async ({ ctx }) => {
|
|
249
|
+
const user = ctx.user;
|
|
250
|
+
|
|
251
|
+
if (!user) {
|
|
252
|
+
throw new AuthError('Not authenticated', 401, 'NOT_AUTHENTICATED');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
id: user.id,
|
|
257
|
+
name: (user.name as string) || '',
|
|
258
|
+
email: user.email,
|
|
259
|
+
roles: Array.isArray(user.roles) ? user.roles : ['user'],
|
|
260
|
+
};
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check Procedures
|
|
3
|
+
*
|
|
4
|
+
* API endpoints for application health monitoring.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { procedure, procedures } from '@veloxts/router';
|
|
8
|
+
|
|
9
|
+
import { db } from '../database.js';
|
|
10
|
+
|
|
11
|
+
export const healthProcedures = procedures('health', {
|
|
12
|
+
/**
|
|
13
|
+
* Basic health check
|
|
14
|
+
* GET /api/health
|
|
15
|
+
*/
|
|
16
|
+
getHealth: procedure()
|
|
17
|
+
.rest({ method: 'GET', path: '/health' })
|
|
18
|
+
.query(() => ({
|
|
19
|
+
status: 'healthy',
|
|
20
|
+
timestamp: new Date().toISOString(),
|
|
21
|
+
uptime: process.uptime(),
|
|
22
|
+
})),
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Readiness check (includes database)
|
|
26
|
+
* GET /api/health/ready
|
|
27
|
+
*/
|
|
28
|
+
getReady: procedure()
|
|
29
|
+
.rest({ method: 'GET', path: '/health/ready' })
|
|
30
|
+
.query(async () => {
|
|
31
|
+
try {
|
|
32
|
+
// Test database connection
|
|
33
|
+
await db.$queryRaw`SELECT 1`;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
status: 'ready',
|
|
37
|
+
database: 'connected',
|
|
38
|
+
timestamp: new Date().toISOString(),
|
|
39
|
+
};
|
|
40
|
+
} catch {
|
|
41
|
+
return {
|
|
42
|
+
status: 'not_ready',
|
|
43
|
+
database: 'disconnected',
|
|
44
|
+
timestamp: new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Procedures
|
|
3
|
+
*
|
|
4
|
+
* CRUD operations for user management.
|
|
5
|
+
* Uses direct db import for proper PrismaClient typing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { procedure, procedures } from '@veloxts/router';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
import { db } from '../database.js';
|
|
12
|
+
import { CreateUserSchema, UpdateUserSchema, UserSchema } from '../schemas/user.js';
|
|
13
|
+
|
|
14
|
+
export const userProcedures = procedures('users', {
|
|
15
|
+
/**
|
|
16
|
+
* List all users
|
|
17
|
+
* GET /api/users
|
|
18
|
+
*/
|
|
19
|
+
listUsers: procedure()
|
|
20
|
+
.output(z.array(UserSchema))
|
|
21
|
+
.query(async () => {
|
|
22
|
+
return db.user.findMany({
|
|
23
|
+
orderBy: { createdAt: 'desc' },
|
|
24
|
+
});
|
|
25
|
+
}),
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get a single user by ID
|
|
29
|
+
* GET /api/users/:id
|
|
30
|
+
*/
|
|
31
|
+
getUser: procedure()
|
|
32
|
+
.input(z.object({ id: z.string().uuid() }))
|
|
33
|
+
.output(UserSchema)
|
|
34
|
+
.query(async ({ input }) => {
|
|
35
|
+
const user = await db.user.findUnique({
|
|
36
|
+
where: { id: input.id },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!user) {
|
|
40
|
+
throw Object.assign(new Error('User not found'), { statusCode: 404 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return user;
|
|
44
|
+
}),
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a new user
|
|
48
|
+
* POST /api/users
|
|
49
|
+
*/
|
|
50
|
+
createUser: procedure()
|
|
51
|
+
.input(CreateUserSchema)
|
|
52
|
+
.output(UserSchema)
|
|
53
|
+
.mutation(async ({ input }) => {
|
|
54
|
+
return db.user.create({
|
|
55
|
+
data: input,
|
|
56
|
+
});
|
|
57
|
+
}),
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Update an existing user
|
|
61
|
+
* PUT /api/users/:id
|
|
62
|
+
*/
|
|
63
|
+
updateUser: procedure()
|
|
64
|
+
.input(UpdateUserSchema.extend({ id: z.string().uuid() }))
|
|
65
|
+
.output(UserSchema)
|
|
66
|
+
.mutation(async ({ input }) => {
|
|
67
|
+
const { id, ...data } = input;
|
|
68
|
+
return db.user.update({
|
|
69
|
+
where: { id },
|
|
70
|
+
data,
|
|
71
|
+
});
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Delete a user
|
|
76
|
+
* DELETE /api/users/:id
|
|
77
|
+
*/
|
|
78
|
+
deleteUser: procedure()
|
|
79
|
+
.input(z.object({ id: z.string().uuid() }))
|
|
80
|
+
.output(z.object({ success: z.boolean() }))
|
|
81
|
+
.mutation(async ({ input }) => {
|
|
82
|
+
await db.user.delete({
|
|
83
|
+
where: { id: input.id },
|
|
84
|
+
});
|
|
85
|
+
return { success: true };
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Schemas
|
|
3
|
+
*
|
|
4
|
+
* BROWSER-SAFE: This file imports ONLY from 'zod'.
|
|
5
|
+
* Never import from @veloxts/* packages here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Password Schema (for validation display)
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
export const PasswordSchema = z
|
|
15
|
+
.string()
|
|
16
|
+
.min(12, 'Password must be at least 12 characters')
|
|
17
|
+
.max(128, 'Password must not exceed 128 characters');
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Email Schema
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
export const EmailSchema = z
|
|
24
|
+
.string()
|
|
25
|
+
.email('Invalid email address')
|
|
26
|
+
.transform((email) => email.toLowerCase().trim());
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Input Schemas
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export const RegisterInput = z.object({
|
|
33
|
+
name: z.string().min(2).max(100).trim(),
|
|
34
|
+
email: EmailSchema,
|
|
35
|
+
password: PasswordSchema,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const LoginInput = z.object({
|
|
39
|
+
email: EmailSchema,
|
|
40
|
+
password: z.string().min(1),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const RefreshInput = z.object({
|
|
44
|
+
refreshToken: z.string(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Response Schemas
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
export const TokenResponse = z.object({
|
|
52
|
+
accessToken: z.string(),
|
|
53
|
+
refreshToken: z.string(),
|
|
54
|
+
expiresIn: z.number(),
|
|
55
|
+
tokenType: z.literal('Bearer'),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const UserResponse = z.object({
|
|
59
|
+
id: z.string(),
|
|
60
|
+
name: z.string(),
|
|
61
|
+
email: z.string(),
|
|
62
|
+
roles: z.array(z.string()),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const LogoutResponse = z.object({
|
|
66
|
+
success: z.boolean(),
|
|
67
|
+
message: z.string(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// Type Exports
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
export type RegisterData = z.infer<typeof RegisterInput>;
|
|
75
|
+
export type LoginData = z.infer<typeof LoginInput>;
|
|
76
|
+
export type RefreshData = z.infer<typeof RefreshInput>;
|
|
77
|
+
export type TokenResponseData = z.infer<typeof TokenResponse>;
|
|
78
|
+
export type UserResponseData = z.infer<typeof UserResponse>;
|
|
79
|
+
export type LogoutResponseData = z.infer<typeof LogoutResponse>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Schemas
|
|
3
|
+
*
|
|
4
|
+
* Zod schemas for user validation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* User entity schema
|
|
11
|
+
*/
|
|
12
|
+
export const UserSchema = z.object({
|
|
13
|
+
id: z.string().uuid(),
|
|
14
|
+
name: z.string(),
|
|
15
|
+
email: z.string().email(),
|
|
16
|
+
createdAt: z.date(),
|
|
17
|
+
updatedAt: z.date(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create user input schema
|
|
22
|
+
*/
|
|
23
|
+
export const CreateUserSchema = z.object({
|
|
24
|
+
name: z.string().min(1, 'Name is required'),
|
|
25
|
+
email: z.string().email('Invalid email address'),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Update user input schema (all fields optional)
|
|
30
|
+
*/
|
|
31
|
+
export const UpdateUserSchema = z.object({
|
|
32
|
+
name: z.string().min(1).optional(),
|
|
33
|
+
email: z.string().email().optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type User = z.infer<typeof UserSchema>;
|
|
37
|
+
export type CreateUser = z.infer<typeof CreateUserSchema>;
|
|
38
|
+
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Utilities
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for authentication that don't require database access.
|
|
5
|
+
* These are safe to import from procedures without pulling in server-only code.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Role Parsing
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
const ALLOWED_ROLES = ['user', 'admin', 'moderator', 'editor'] as const;
|
|
13
|
+
|
|
14
|
+
export function parseUserRoles(rolesJson: string | null): string[] {
|
|
15
|
+
if (!rolesJson) return ['user'];
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const parsed: unknown = JSON.parse(rolesJson);
|
|
19
|
+
|
|
20
|
+
if (!Array.isArray(parsed)) {
|
|
21
|
+
return ['user'];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const validRoles = parsed
|
|
25
|
+
.filter((role): role is string => typeof role === 'string')
|
|
26
|
+
.filter((role) => ALLOWED_ROLES.includes(role as (typeof ALLOWED_ROLES)[number]));
|
|
27
|
+
|
|
28
|
+
return validRoles.length > 0 ? validRoles : ['user'];
|
|
29
|
+
} catch {
|
|
30
|
+
return ['user'];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Token Revocation Store
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* In-memory token revocation store.
|
|
40
|
+
*
|
|
41
|
+
* PRODUCTION NOTE: Replace with Redis or database-backed store for:
|
|
42
|
+
* - Persistence across server restarts
|
|
43
|
+
* - Horizontal scaling (multiple server instances)
|
|
44
|
+
*/
|
|
45
|
+
class InMemoryTokenStore {
|
|
46
|
+
private revokedTokens: Map<string, number> = new Map();
|
|
47
|
+
private usedRefreshTokens: Map<string, string> = new Map();
|
|
48
|
+
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
49
|
+
|
|
50
|
+
constructor() {
|
|
51
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
revoke(jti: string, expiresInMs: number = 7 * 24 * 60 * 60 * 1000): void {
|
|
55
|
+
this.revokedTokens.set(jti, Date.now() + expiresInMs);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
isRevoked(jti: string): boolean {
|
|
59
|
+
const expiry = this.revokedTokens.get(jti);
|
|
60
|
+
if (!expiry) return false;
|
|
61
|
+
if (Date.now() > expiry) {
|
|
62
|
+
this.revokedTokens.delete(jti);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
markRefreshTokenUsed(jti: string, userId: string): void {
|
|
69
|
+
this.usedRefreshTokens.set(jti, userId);
|
|
70
|
+
setTimeout(() => this.usedRefreshTokens.delete(jti), 7 * 24 * 60 * 60 * 1000);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
isRefreshTokenUsed(jti: string): string | undefined {
|
|
74
|
+
return this.usedRefreshTokens.get(jti);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
revokeAllUserTokens(userId: string): void {
|
|
78
|
+
console.warn(
|
|
79
|
+
`[Security] Token reuse detected for user ${userId}. ` +
|
|
80
|
+
'All tokens should be revoked. Implement proper user->token mapping for production.'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private cleanup(): void {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
for (const [jti, expiry] of this.revokedTokens.entries()) {
|
|
87
|
+
if (now > expiry) {
|
|
88
|
+
this.revokedTokens.delete(jti);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
destroy(): void {
|
|
94
|
+
if (this.cleanupInterval) {
|
|
95
|
+
clearInterval(this.cleanupInterval);
|
|
96
|
+
this.cleanupInterval = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const tokenStore = new InMemoryTokenStore();
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// JWT Configuration Helper
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Gets required JWT secrets from environment variables.
|
|
109
|
+
* Throws a clear error in production if secrets are not configured.
|
|
110
|
+
*/
|
|
111
|
+
export function getJwtSecrets(): { jwtSecret: string; refreshSecret: string } {
|
|
112
|
+
const jwtSecret = process.env.JWT_SECRET;
|
|
113
|
+
const refreshSecret = process.env.JWT_REFRESH_SECRET;
|
|
114
|
+
|
|
115
|
+
const isDevelopment = process.env.NODE_ENV !== 'production';
|
|
116
|
+
|
|
117
|
+
if (!jwtSecret || !refreshSecret) {
|
|
118
|
+
if (isDevelopment) {
|
|
119
|
+
console.warn(
|
|
120
|
+
'\n' +
|
|
121
|
+
'='.repeat(70) +
|
|
122
|
+
'\n' +
|
|
123
|
+
' WARNING: JWT secrets not configured!\n' +
|
|
124
|
+
' Using temporary development secrets. DO NOT USE IN PRODUCTION!\n' +
|
|
125
|
+
'\n' +
|
|
126
|
+
' To configure secrets, add to .env:\n' +
|
|
127
|
+
' JWT_SECRET=<generate with: openssl rand -base64 64>\n' +
|
|
128
|
+
' JWT_REFRESH_SECRET=<generate with: openssl rand -base64 64>\n' +
|
|
129
|
+
'='.repeat(70) +
|
|
130
|
+
'\n'
|
|
131
|
+
);
|
|
132
|
+
return {
|
|
133
|
+
jwtSecret:
|
|
134
|
+
jwtSecret || `dev-only-jwt-secret-${Math.random().toString(36).substring(2).repeat(4)}`,
|
|
135
|
+
refreshSecret:
|
|
136
|
+
refreshSecret ||
|
|
137
|
+
`dev-only-refresh-secret-${Math.random().toString(36).substring(2).repeat(4)}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
throw new Error(
|
|
142
|
+
'\n' +
|
|
143
|
+
'CRITICAL: JWT secrets are required but not configured.\n' +
|
|
144
|
+
'\n' +
|
|
145
|
+
'Required environment variables:\n' +
|
|
146
|
+
' - JWT_SECRET: Secret for signing access tokens (64+ characters)\n' +
|
|
147
|
+
' - JWT_REFRESH_SECRET: Secret for signing refresh tokens (64+ characters)\n' +
|
|
148
|
+
'\n' +
|
|
149
|
+
'Generate secure secrets with:\n' +
|
|
150
|
+
' openssl rand -base64 64\n' +
|
|
151
|
+
'\n' +
|
|
152
|
+
'Add them to your environment or .env file before starting the server.\n'
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { jwtSecret, refreshSecret };
|
|
157
|
+
}
|