create-velox-app 0.3.4 → 0.3.6
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/dist/cli.js +126 -13
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +218 -82
- package/dist/index.js.map +1 -1
- package/dist/templates/auth.d.ts +9 -0
- package/dist/templates/auth.d.ts.map +1 -0
- package/dist/templates/auth.js +1143 -0
- package/dist/templates/auth.js.map +1 -0
- package/dist/templates/default.d.ts +9 -0
- package/dist/templates/default.d.ts.map +1 -0
- package/dist/{templates.js → templates/default.js} +52 -300
- package/dist/templates/default.js.map +1 -0
- package/dist/templates/index.d.ts +20 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +45 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/templates/shared.d.ts +22 -0
- package/dist/templates/shared.d.ts.map +1 -0
- package/dist/templates/shared.js +301 -0
- package/dist/templates/shared.js.map +1 -0
- package/dist/templates/types.d.ts +91 -0
- package/dist/templates/types.d.ts.map +1 -0
- package/dist/templates/types.js +78 -0
- package/dist/templates/types.js.map +1 -0
- package/package.json +2 -1
- package/dist/templates.d.ts +0 -35
- package/dist/templates.d.ts.map +0 -1
- package/dist/templates.js.map +0 -1
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Template
|
|
3
|
+
*
|
|
4
|
+
* Full authentication template with JWT auth, guards, rate limiting,
|
|
5
|
+
* token rotation, and secure password hashing.
|
|
6
|
+
*/
|
|
7
|
+
import { generateSharedFiles, VELOXTS_VERSION } from './shared.js';
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Package.json
|
|
10
|
+
// ============================================================================
|
|
11
|
+
function generatePackageJson(config) {
|
|
12
|
+
return JSON.stringify({
|
|
13
|
+
name: config.projectName,
|
|
14
|
+
version: '0.0.1',
|
|
15
|
+
description: 'A VeloxTS application with full authentication',
|
|
16
|
+
type: 'module',
|
|
17
|
+
main: 'dist/index.js',
|
|
18
|
+
scripts: {
|
|
19
|
+
build: 'tsup',
|
|
20
|
+
start: 'node dist/index.js',
|
|
21
|
+
dev: 'tsx watch src/index.ts',
|
|
22
|
+
'type-check': 'tsc --noEmit',
|
|
23
|
+
clean: "node -e \"require('fs').rmSync('dist',{recursive:true,force:true});require('fs').rmSync('tsconfig.tsbuildinfo',{force:true})\"",
|
|
24
|
+
'db:generate': 'prisma generate',
|
|
25
|
+
'db:push': 'prisma db push',
|
|
26
|
+
'db:studio': 'prisma studio',
|
|
27
|
+
postinstall: 'prisma generate',
|
|
28
|
+
},
|
|
29
|
+
dependencies: {
|
|
30
|
+
'@fastify/static': '^8.3.0',
|
|
31
|
+
'@prisma/adapter-better-sqlite3': '^7.1.0',
|
|
32
|
+
'@prisma/client': '^7.1.0',
|
|
33
|
+
'@veloxts/velox': `^${VELOXTS_VERSION}`,
|
|
34
|
+
bcrypt: '^5.1.1',
|
|
35
|
+
'better-sqlite3': '^12.5.0',
|
|
36
|
+
dotenv: '^17.2.3',
|
|
37
|
+
zod: '^3.24.4',
|
|
38
|
+
},
|
|
39
|
+
devDependencies: {
|
|
40
|
+
'@types/bcrypt': '^5.0.2',
|
|
41
|
+
prisma: '^7.1.0',
|
|
42
|
+
tsup: '^8.5.1',
|
|
43
|
+
tsx: '^4.21.0',
|
|
44
|
+
typescript: '^5.9.3',
|
|
45
|
+
},
|
|
46
|
+
}, null, 2);
|
|
47
|
+
}
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Environment Files
|
|
50
|
+
// ============================================================================
|
|
51
|
+
function generateEnvExample() {
|
|
52
|
+
return `# Database URL
|
|
53
|
+
# SQLite (local development):
|
|
54
|
+
DATABASE_URL="file:./dev.db"
|
|
55
|
+
# PostgreSQL (production):
|
|
56
|
+
# DATABASE_URL="postgresql://user:password@localhost:5432/myapp"
|
|
57
|
+
|
|
58
|
+
# Server Configuration
|
|
59
|
+
PORT=3210
|
|
60
|
+
HOST=0.0.0.0
|
|
61
|
+
NODE_ENV=development
|
|
62
|
+
|
|
63
|
+
# API Configuration
|
|
64
|
+
API_PREFIX=/api
|
|
65
|
+
|
|
66
|
+
# ============================================================================
|
|
67
|
+
# Authentication (REQUIRED for production)
|
|
68
|
+
# ============================================================================
|
|
69
|
+
# Generate secrets with: openssl rand -base64 64
|
|
70
|
+
#
|
|
71
|
+
# JWT_SECRET=<your-access-token-secret>
|
|
72
|
+
# JWT_REFRESH_SECRET=<your-refresh-token-secret>
|
|
73
|
+
#
|
|
74
|
+
# NOTE: In development mode, temporary secrets will be generated with a warning.
|
|
75
|
+
# Always set these in production!
|
|
76
|
+
|
|
77
|
+
# ============================================================================
|
|
78
|
+
# Session Authentication (Alternative to JWT)
|
|
79
|
+
# ============================================================================
|
|
80
|
+
# If using cookie-based sessions instead of JWT, configure these:
|
|
81
|
+
#
|
|
82
|
+
# SESSION_SECRET=<your-session-secret>
|
|
83
|
+
#
|
|
84
|
+
# Generate with: openssl rand -base64 32
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// Prisma Schema (with password field)
|
|
89
|
+
// ============================================================================
|
|
90
|
+
function generatePrismaSchema() {
|
|
91
|
+
return `// Prisma Schema
|
|
92
|
+
//
|
|
93
|
+
// This schema defines the database structure with authentication support.
|
|
94
|
+
// Using SQLite for simplicity - easily swap to PostgreSQL for production.
|
|
95
|
+
|
|
96
|
+
generator client {
|
|
97
|
+
provider = "prisma-client"
|
|
98
|
+
output = "../src/generated/prisma"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
datasource db {
|
|
102
|
+
provider = "sqlite"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// User Model
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
/// User model with authentication support
|
|
110
|
+
model User {
|
|
111
|
+
id String @id @default(uuid())
|
|
112
|
+
name String
|
|
113
|
+
email String @unique
|
|
114
|
+
password String? // Hashed password (optional for social auth)
|
|
115
|
+
roles String @default("[\\"user\\"]") // JSON array of roles
|
|
116
|
+
createdAt DateTime @default(now())
|
|
117
|
+
updatedAt DateTime @updatedAt
|
|
118
|
+
|
|
119
|
+
@@map("users")
|
|
120
|
+
}
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// Auth Config
|
|
125
|
+
// ============================================================================
|
|
126
|
+
function generateAuthConfig() {
|
|
127
|
+
return `/**
|
|
128
|
+
* Authentication Configuration
|
|
129
|
+
*
|
|
130
|
+
* JWT-based authentication configuration.
|
|
131
|
+
*
|
|
132
|
+
* SECURITY: JWT secrets are required from environment variables.
|
|
133
|
+
* The app will fail to start in production without them.
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
import type { AuthPluginOptions } from '@veloxts/velox';
|
|
137
|
+
|
|
138
|
+
import { prisma } from '../database/index.js';
|
|
139
|
+
|
|
140
|
+
// ============================================================================
|
|
141
|
+
// Environment Variable Validation
|
|
142
|
+
// ============================================================================
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Gets required JWT secrets from environment variables.
|
|
146
|
+
* Throws a clear error in production if secrets are not configured.
|
|
147
|
+
*/
|
|
148
|
+
function getRequiredSecrets(): { jwtSecret: string; refreshSecret: string } {
|
|
149
|
+
const jwtSecret = process.env.JWT_SECRET;
|
|
150
|
+
const refreshSecret = process.env.JWT_REFRESH_SECRET;
|
|
151
|
+
|
|
152
|
+
const isDevelopment = process.env.NODE_ENV !== 'production';
|
|
153
|
+
|
|
154
|
+
if (!jwtSecret || !refreshSecret) {
|
|
155
|
+
if (isDevelopment) {
|
|
156
|
+
console.warn(
|
|
157
|
+
'\\n' +
|
|
158
|
+
'='.repeat(70) +
|
|
159
|
+
'\\n' +
|
|
160
|
+
' WARNING: JWT secrets not configured!\\n' +
|
|
161
|
+
' Using temporary development secrets. DO NOT USE IN PRODUCTION!\\n' +
|
|
162
|
+
'\\n' +
|
|
163
|
+
' To configure secrets, add to .env:\\n' +
|
|
164
|
+
' JWT_SECRET=<generate with: openssl rand -base64 64>\\n' +
|
|
165
|
+
' JWT_REFRESH_SECRET=<generate with: openssl rand -base64 64>\\n' +
|
|
166
|
+
'='.repeat(70) +
|
|
167
|
+
'\\n'
|
|
168
|
+
);
|
|
169
|
+
return {
|
|
170
|
+
jwtSecret:
|
|
171
|
+
jwtSecret || \`dev-only-jwt-secret-\${Math.random().toString(36).substring(2).repeat(4)}\`,
|
|
172
|
+
refreshSecret:
|
|
173
|
+
refreshSecret ||
|
|
174
|
+
\`dev-only-refresh-secret-\${Math.random().toString(36).substring(2).repeat(4)}\`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
throw new Error(
|
|
179
|
+
'\\n' +
|
|
180
|
+
'CRITICAL: JWT secrets are required but not configured.\\n' +
|
|
181
|
+
'\\n' +
|
|
182
|
+
'Required environment variables:\\n' +
|
|
183
|
+
' - JWT_SECRET: Secret for signing access tokens (64+ characters)\\n' +
|
|
184
|
+
' - JWT_REFRESH_SECRET: Secret for signing refresh tokens (64+ characters)\\n' +
|
|
185
|
+
'\\n' +
|
|
186
|
+
'Generate secure secrets with:\\n' +
|
|
187
|
+
' openssl rand -base64 64\\n' +
|
|
188
|
+
'\\n' +
|
|
189
|
+
'Add them to your environment or .env file before starting the server.\\n'
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { jwtSecret, refreshSecret };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================================================
|
|
197
|
+
// Token Revocation Store
|
|
198
|
+
// ============================================================================
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* In-memory token revocation store.
|
|
202
|
+
*
|
|
203
|
+
* PRODUCTION NOTE: Replace with Redis or database-backed store for:
|
|
204
|
+
* - Persistence across server restarts
|
|
205
|
+
* - Horizontal scaling (multiple server instances)
|
|
206
|
+
*/
|
|
207
|
+
class InMemoryTokenStore {
|
|
208
|
+
private revokedTokens: Map<string, number> = new Map();
|
|
209
|
+
private usedRefreshTokens: Map<string, string> = new Map();
|
|
210
|
+
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
211
|
+
|
|
212
|
+
constructor() {
|
|
213
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
revoke(jti: string, expiresInMs: number = 7 * 24 * 60 * 60 * 1000): void {
|
|
217
|
+
this.revokedTokens.set(jti, Date.now() + expiresInMs);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
isRevoked(jti: string): boolean {
|
|
221
|
+
const expiry = this.revokedTokens.get(jti);
|
|
222
|
+
if (!expiry) return false;
|
|
223
|
+
if (Date.now() > expiry) {
|
|
224
|
+
this.revokedTokens.delete(jti);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
markRefreshTokenUsed(jti: string, userId: string): void {
|
|
231
|
+
this.usedRefreshTokens.set(jti, userId);
|
|
232
|
+
setTimeout(() => this.usedRefreshTokens.delete(jti), 7 * 24 * 60 * 60 * 1000);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
isRefreshTokenUsed(jti: string): string | undefined {
|
|
236
|
+
return this.usedRefreshTokens.get(jti);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
revokeAllUserTokens(userId: string): void {
|
|
240
|
+
console.warn(
|
|
241
|
+
\`[Security] Token reuse detected for user \${userId}. \` +
|
|
242
|
+
'All tokens should be revoked. Implement proper user->token mapping for production.'
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private cleanup(): void {
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
for (const [jti, expiry] of this.revokedTokens.entries()) {
|
|
249
|
+
if (now > expiry) {
|
|
250
|
+
this.revokedTokens.delete(jti);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const tokenStore = new InMemoryTokenStore();
|
|
257
|
+
|
|
258
|
+
// ============================================================================
|
|
259
|
+
// Role Parsing
|
|
260
|
+
// ============================================================================
|
|
261
|
+
|
|
262
|
+
const ALLOWED_ROLES = ['user', 'admin', 'moderator', 'editor'] as const;
|
|
263
|
+
|
|
264
|
+
export function parseUserRoles(rolesJson: string | null): string[] {
|
|
265
|
+
if (!rolesJson) return ['user'];
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const parsed: unknown = JSON.parse(rolesJson);
|
|
269
|
+
|
|
270
|
+
if (!Array.isArray(parsed)) {
|
|
271
|
+
return ['user'];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const validRoles = parsed
|
|
275
|
+
.filter((role): role is string => typeof role === 'string')
|
|
276
|
+
.filter((role) => ALLOWED_ROLES.includes(role as (typeof ALLOWED_ROLES)[number]));
|
|
277
|
+
|
|
278
|
+
return validRoles.length > 0 ? validRoles : ['user'];
|
|
279
|
+
} catch {
|
|
280
|
+
return ['user'];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ============================================================================
|
|
285
|
+
// User Loader
|
|
286
|
+
// ============================================================================
|
|
287
|
+
|
|
288
|
+
async function userLoader(userId: string) {
|
|
289
|
+
const user = await prisma.user.findUnique({
|
|
290
|
+
where: { id: userId },
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (!user) return null;
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
id: user.id,
|
|
297
|
+
email: user.email,
|
|
298
|
+
name: user.name,
|
|
299
|
+
roles: parseUserRoles(user.roles),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ============================================================================
|
|
304
|
+
// Auth Configuration
|
|
305
|
+
// ============================================================================
|
|
306
|
+
|
|
307
|
+
export function createAuthConfig(): AuthPluginOptions {
|
|
308
|
+
const { jwtSecret, refreshSecret } = getRequiredSecrets();
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
jwt: {
|
|
312
|
+
secret: jwtSecret,
|
|
313
|
+
refreshSecret: refreshSecret,
|
|
314
|
+
accessTokenExpiry: '15m',
|
|
315
|
+
refreshTokenExpiry: '7d',
|
|
316
|
+
issuer: 'velox-app',
|
|
317
|
+
audience: 'velox-app-client',
|
|
318
|
+
},
|
|
319
|
+
userLoader,
|
|
320
|
+
isTokenRevoked: async (jti: string) => tokenStore.isRevoked(jti),
|
|
321
|
+
rateLimit: {
|
|
322
|
+
max: 100,
|
|
323
|
+
windowMs: 60000,
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export const authConfig = createAuthConfig();
|
|
329
|
+
`;
|
|
330
|
+
}
|
|
331
|
+
function generateConfigIndexWithAuth() {
|
|
332
|
+
return `/**
|
|
333
|
+
* Configuration Exports
|
|
334
|
+
*/
|
|
335
|
+
|
|
336
|
+
export * from './app.js';
|
|
337
|
+
export * from './auth.js';
|
|
338
|
+
`;
|
|
339
|
+
}
|
|
340
|
+
// ============================================================================
|
|
341
|
+
// Auth Procedures
|
|
342
|
+
// ============================================================================
|
|
343
|
+
function generateAuthProcedures() {
|
|
344
|
+
return `/**
|
|
345
|
+
* Auth Procedures
|
|
346
|
+
*
|
|
347
|
+
* Authentication procedures for user registration, login, and token management.
|
|
348
|
+
*
|
|
349
|
+
* REST Endpoints:
|
|
350
|
+
* - POST /auth/register - Create new account
|
|
351
|
+
* - POST /auth/login - Authenticate and get tokens
|
|
352
|
+
* - POST /auth/refresh - Refresh access token
|
|
353
|
+
* - POST /auth/logout - Revoke current token
|
|
354
|
+
* - GET /auth/me - Get current user (protected)
|
|
355
|
+
*/
|
|
356
|
+
|
|
357
|
+
import {
|
|
358
|
+
AuthError,
|
|
359
|
+
authenticated,
|
|
360
|
+
createAuthRateLimiter,
|
|
361
|
+
hashPassword,
|
|
362
|
+
jwtManager,
|
|
363
|
+
verifyPassword,
|
|
364
|
+
defineProcedures,
|
|
365
|
+
procedure,
|
|
366
|
+
z,
|
|
367
|
+
} from '@veloxts/velox';
|
|
368
|
+
|
|
369
|
+
import { authConfig, parseUserRoles, tokenStore } from '../config/index.js';
|
|
370
|
+
import { prisma } from '../database/index.js';
|
|
371
|
+
|
|
372
|
+
// ============================================================================
|
|
373
|
+
// Rate Limiter
|
|
374
|
+
// ============================================================================
|
|
375
|
+
|
|
376
|
+
const rateLimiter = createAuthRateLimiter({
|
|
377
|
+
login: {
|
|
378
|
+
maxAttempts: 5,
|
|
379
|
+
windowMs: 15 * 60 * 1000,
|
|
380
|
+
lockoutDurationMs: 15 * 60 * 1000,
|
|
381
|
+
progressiveBackoff: true,
|
|
382
|
+
},
|
|
383
|
+
register: {
|
|
384
|
+
maxAttempts: 3,
|
|
385
|
+
windowMs: 60 * 60 * 1000,
|
|
386
|
+
lockoutDurationMs: 60 * 60 * 1000,
|
|
387
|
+
},
|
|
388
|
+
refresh: {
|
|
389
|
+
maxAttempts: 10,
|
|
390
|
+
windowMs: 60 * 1000,
|
|
391
|
+
lockoutDurationMs: 60 * 1000,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ============================================================================
|
|
396
|
+
// Password Blacklist
|
|
397
|
+
// ============================================================================
|
|
398
|
+
|
|
399
|
+
const COMMON_PASSWORDS = new Set([
|
|
400
|
+
'password', 'password123', '12345678', '123456789',
|
|
401
|
+
'qwerty123', 'letmein', 'welcome', 'admin123',
|
|
402
|
+
]);
|
|
403
|
+
|
|
404
|
+
// ============================================================================
|
|
405
|
+
// Schemas
|
|
406
|
+
// ============================================================================
|
|
407
|
+
|
|
408
|
+
const PasswordSchema = z
|
|
409
|
+
.string()
|
|
410
|
+
.min(12, 'Password must be at least 12 characters')
|
|
411
|
+
.max(128, 'Password must not exceed 128 characters')
|
|
412
|
+
.refine((pwd) => /[a-z]/.test(pwd), 'Password must contain at least one lowercase letter')
|
|
413
|
+
.refine((pwd) => /[A-Z]/.test(pwd), 'Password must contain at least one uppercase letter')
|
|
414
|
+
.refine((pwd) => /[0-9]/.test(pwd), 'Password must contain at least one number')
|
|
415
|
+
.refine(
|
|
416
|
+
(pwd) => !COMMON_PASSWORDS.has(pwd.toLowerCase()),
|
|
417
|
+
'Password is too common. Please choose a stronger password.'
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const EmailSchema = z
|
|
421
|
+
.string()
|
|
422
|
+
.email('Invalid email address')
|
|
423
|
+
.transform((email) => email.toLowerCase().trim());
|
|
424
|
+
|
|
425
|
+
const RegisterInput = z.object({
|
|
426
|
+
name: z.string().min(2).max(100).trim(),
|
|
427
|
+
email: EmailSchema,
|
|
428
|
+
password: PasswordSchema,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const LoginInput = z.object({
|
|
432
|
+
email: EmailSchema,
|
|
433
|
+
password: z.string().min(1),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const RefreshInput = z.object({
|
|
437
|
+
refreshToken: z.string(),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const TokenResponse = z.object({
|
|
441
|
+
accessToken: z.string(),
|
|
442
|
+
refreshToken: z.string(),
|
|
443
|
+
expiresIn: z.number(),
|
|
444
|
+
tokenType: z.literal('Bearer'),
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const UserResponse = z.object({
|
|
448
|
+
id: z.string(),
|
|
449
|
+
name: z.string(),
|
|
450
|
+
email: z.string(),
|
|
451
|
+
roles: z.array(z.string()),
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const LogoutResponse = z.object({
|
|
455
|
+
success: z.boolean(),
|
|
456
|
+
message: z.string(),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// ============================================================================
|
|
460
|
+
// JWT Manager
|
|
461
|
+
// ============================================================================
|
|
462
|
+
|
|
463
|
+
const jwt = jwtManager(authConfig.jwt);
|
|
464
|
+
|
|
465
|
+
// Dummy hash for timing attack prevention
|
|
466
|
+
const DUMMY_HASH = '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.uy7dPSSXB5G6Uy';
|
|
467
|
+
|
|
468
|
+
// ============================================================================
|
|
469
|
+
// Auth Procedures
|
|
470
|
+
// ============================================================================
|
|
471
|
+
|
|
472
|
+
export const authProcedures = defineProcedures('auth', {
|
|
473
|
+
register: procedure()
|
|
474
|
+
.rest({ method: 'POST', path: '/auth/register' })
|
|
475
|
+
.use(rateLimiter.register())
|
|
476
|
+
.input(RegisterInput)
|
|
477
|
+
.output(TokenResponse)
|
|
478
|
+
.mutation(async ({ input }) => {
|
|
479
|
+
const normalizedEmail = input.email.toLowerCase().trim();
|
|
480
|
+
|
|
481
|
+
const existing = await prisma.user.findUnique({
|
|
482
|
+
where: { email: normalizedEmail },
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
if (existing) {
|
|
486
|
+
throw new AuthError(
|
|
487
|
+
'Registration failed. If this email is not already registered, please try again.',
|
|
488
|
+
400,
|
|
489
|
+
'REGISTRATION_FAILED'
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const hashedPassword = await hashPassword(input.password);
|
|
494
|
+
|
|
495
|
+
const user = await prisma.user.create({
|
|
496
|
+
data: {
|
|
497
|
+
name: input.name.trim(),
|
|
498
|
+
email: normalizedEmail,
|
|
499
|
+
password: hashedPassword,
|
|
500
|
+
roles: JSON.stringify(['user']),
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
return jwt.createTokenPair({
|
|
505
|
+
id: user.id,
|
|
506
|
+
email: user.email,
|
|
507
|
+
roles: ['user'],
|
|
508
|
+
});
|
|
509
|
+
}),
|
|
510
|
+
|
|
511
|
+
login: procedure()
|
|
512
|
+
.rest({ method: 'POST', path: '/auth/login' })
|
|
513
|
+
.use(
|
|
514
|
+
rateLimiter.login((ctx) => {
|
|
515
|
+
const input = ctx.input as { email?: string } | undefined;
|
|
516
|
+
return input?.email?.toLowerCase() ?? '';
|
|
517
|
+
})
|
|
518
|
+
)
|
|
519
|
+
.input(LoginInput)
|
|
520
|
+
.output(TokenResponse)
|
|
521
|
+
.mutation(async ({ input }) => {
|
|
522
|
+
const normalizedEmail = input.email.toLowerCase().trim();
|
|
523
|
+
|
|
524
|
+
const user = await prisma.user.findUnique({
|
|
525
|
+
where: { email: normalizedEmail },
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const hashToVerify = user?.password || DUMMY_HASH;
|
|
529
|
+
const isValid = await verifyPassword(input.password, hashToVerify);
|
|
530
|
+
|
|
531
|
+
if (!user || !user.password || !isValid) {
|
|
532
|
+
throw new AuthError('Invalid email or password', 401, 'INVALID_CREDENTIALS');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const roles = parseUserRoles(user.roles);
|
|
536
|
+
|
|
537
|
+
return jwt.createTokenPair({
|
|
538
|
+
id: user.id,
|
|
539
|
+
email: user.email,
|
|
540
|
+
roles,
|
|
541
|
+
});
|
|
542
|
+
}),
|
|
543
|
+
|
|
544
|
+
refresh: procedure()
|
|
545
|
+
.rest({ method: 'POST', path: '/auth/refresh' })
|
|
546
|
+
.use(rateLimiter.refresh())
|
|
547
|
+
.input(RefreshInput)
|
|
548
|
+
.output(TokenResponse)
|
|
549
|
+
.mutation(async ({ input }) => {
|
|
550
|
+
try {
|
|
551
|
+
const payload = jwt.verifyToken(input.refreshToken);
|
|
552
|
+
|
|
553
|
+
if (payload.type !== 'refresh') {
|
|
554
|
+
throw new AuthError('Invalid token type', 401, 'INVALID_TOKEN_TYPE');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (payload.jti && tokenStore.isRevoked(payload.jti)) {
|
|
558
|
+
throw new AuthError('Token has been revoked', 401, 'TOKEN_REVOKED');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (payload.jti) {
|
|
562
|
+
const previousUserId = tokenStore.isRefreshTokenUsed(payload.jti);
|
|
563
|
+
if (previousUserId) {
|
|
564
|
+
tokenStore.revokeAllUserTokens(previousUserId);
|
|
565
|
+
throw new AuthError(
|
|
566
|
+
'Security alert: Refresh token reuse detected.',
|
|
567
|
+
401,
|
|
568
|
+
'TOKEN_REUSE_DETECTED'
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
tokenStore.markRefreshTokenUsed(payload.jti, payload.sub);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const user = await prisma.user.findUnique({
|
|
575
|
+
where: { id: payload.sub },
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
if (!user) {
|
|
579
|
+
throw new AuthError('User not found', 401, 'USER_NOT_FOUND');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return jwt.createTokenPair({
|
|
583
|
+
id: user.id,
|
|
584
|
+
email: user.email,
|
|
585
|
+
roles: parseUserRoles(user.roles),
|
|
586
|
+
});
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (error instanceof AuthError) throw error;
|
|
589
|
+
throw new AuthError('Invalid refresh token', 401, 'INVALID_REFRESH_TOKEN');
|
|
590
|
+
}
|
|
591
|
+
}),
|
|
592
|
+
|
|
593
|
+
logout: procedure()
|
|
594
|
+
.rest({ method: 'POST', path: '/auth/logout' })
|
|
595
|
+
.guard(authenticated)
|
|
596
|
+
.output(LogoutResponse)
|
|
597
|
+
.mutation(async ({ ctx }) => {
|
|
598
|
+
const tokenId = ctx.auth?.token?.jti;
|
|
599
|
+
|
|
600
|
+
if (tokenId) {
|
|
601
|
+
tokenStore.revoke(tokenId, 15 * 60 * 1000);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
success: true,
|
|
606
|
+
message: 'Successfully logged out',
|
|
607
|
+
};
|
|
608
|
+
}),
|
|
609
|
+
|
|
610
|
+
getMe: procedure()
|
|
611
|
+
.rest({ method: 'GET', path: '/auth/me' })
|
|
612
|
+
.guard(authenticated)
|
|
613
|
+
.output(UserResponse)
|
|
614
|
+
.query(async ({ ctx }) => {
|
|
615
|
+
const user = ctx.user;
|
|
616
|
+
|
|
617
|
+
if (!user) {
|
|
618
|
+
throw new AuthError('Not authenticated', 401, 'NOT_AUTHENTICATED');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
id: user.id,
|
|
623
|
+
name: (user.name as string) || '',
|
|
624
|
+
email: user.email,
|
|
625
|
+
roles: Array.isArray(user.roles) ? user.roles : ['user'],
|
|
626
|
+
};
|
|
627
|
+
}),
|
|
628
|
+
});
|
|
629
|
+
`;
|
|
630
|
+
}
|
|
631
|
+
// ============================================================================
|
|
632
|
+
// User Procedures (with guards)
|
|
633
|
+
// ============================================================================
|
|
634
|
+
function generateUserProceduresWithAuth() {
|
|
635
|
+
return `/**
|
|
636
|
+
* User Procedures
|
|
637
|
+
*
|
|
638
|
+
* CRUD procedures for user management with authentication guards.
|
|
639
|
+
*/
|
|
640
|
+
|
|
641
|
+
import {
|
|
642
|
+
AuthError,
|
|
643
|
+
authenticated,
|
|
644
|
+
hasRole,
|
|
645
|
+
defineProcedures,
|
|
646
|
+
GuardError,
|
|
647
|
+
procedure,
|
|
648
|
+
paginationInputSchema,
|
|
649
|
+
z,
|
|
650
|
+
} from '@veloxts/velox';
|
|
651
|
+
|
|
652
|
+
import {
|
|
653
|
+
CreateUserInput,
|
|
654
|
+
UpdateUserInput,
|
|
655
|
+
type User,
|
|
656
|
+
UserSchema,
|
|
657
|
+
} from '../schemas/user.js';
|
|
658
|
+
|
|
659
|
+
// ============================================================================
|
|
660
|
+
// Database Types
|
|
661
|
+
// ============================================================================
|
|
662
|
+
|
|
663
|
+
interface DbUser {
|
|
664
|
+
id: string;
|
|
665
|
+
name: string;
|
|
666
|
+
email: string;
|
|
667
|
+
createdAt: Date;
|
|
668
|
+
updatedAt: Date;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
interface DbClient {
|
|
672
|
+
user: {
|
|
673
|
+
findUnique: (args: { where: { id: string } }) => Promise<DbUser | null>;
|
|
674
|
+
findMany: (args?: { skip?: number; take?: number }) => Promise<DbUser[]>;
|
|
675
|
+
create: (args: { data: { name: string; email: string } }) => Promise<DbUser>;
|
|
676
|
+
update: (args: { where: { id: string }; data: { name?: string; email?: string } }) => Promise<DbUser>;
|
|
677
|
+
delete: (args: { where: { id: string } }) => Promise<DbUser>;
|
|
678
|
+
count: () => Promise<number>;
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function getDb(ctx: { db: unknown }): DbClient {
|
|
683
|
+
return ctx.db as DbClient;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function toUserResponse(dbUser: DbUser): User {
|
|
687
|
+
return {
|
|
688
|
+
id: dbUser.id,
|
|
689
|
+
name: dbUser.name,
|
|
690
|
+
email: dbUser.email,
|
|
691
|
+
createdAt: dbUser.createdAt instanceof Date ? dbUser.createdAt.toISOString() : dbUser.createdAt,
|
|
692
|
+
updatedAt: dbUser.updatedAt instanceof Date ? dbUser.updatedAt.toISOString() : dbUser.updatedAt,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ============================================================================
|
|
697
|
+
// User Procedures
|
|
698
|
+
// ============================================================================
|
|
699
|
+
|
|
700
|
+
export const userProcedures = defineProcedures('users', {
|
|
701
|
+
getUser: procedure()
|
|
702
|
+
.input(z.object({ id: z.string().uuid() }))
|
|
703
|
+
.output(UserSchema.nullable())
|
|
704
|
+
.query(async ({ input, ctx }) => {
|
|
705
|
+
const db = getDb(ctx);
|
|
706
|
+
const user = await db.user.findUnique({ where: { id: input.id } });
|
|
707
|
+
return user ? toUserResponse(user) : null;
|
|
708
|
+
}),
|
|
709
|
+
|
|
710
|
+
listUsers: procedure()
|
|
711
|
+
.input(paginationInputSchema.optional())
|
|
712
|
+
.output(
|
|
713
|
+
z.object({
|
|
714
|
+
data: z.array(UserSchema),
|
|
715
|
+
meta: z.object({
|
|
716
|
+
page: z.number(),
|
|
717
|
+
limit: z.number(),
|
|
718
|
+
total: z.number(),
|
|
719
|
+
}),
|
|
720
|
+
})
|
|
721
|
+
)
|
|
722
|
+
.query(async ({ input, ctx }) => {
|
|
723
|
+
const db = getDb(ctx);
|
|
724
|
+
const page = input?.page ?? 1;
|
|
725
|
+
const limit = input?.limit ?? 10;
|
|
726
|
+
const skip = (page - 1) * limit;
|
|
727
|
+
|
|
728
|
+
const [dbUsers, total] = await Promise.all([
|
|
729
|
+
db.user.findMany({ skip, take: limit }),
|
|
730
|
+
db.user.count(),
|
|
731
|
+
]);
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
data: dbUsers.map(toUserResponse),
|
|
735
|
+
meta: { page, limit, total },
|
|
736
|
+
};
|
|
737
|
+
}),
|
|
738
|
+
|
|
739
|
+
createUser: procedure()
|
|
740
|
+
.guard(authenticated)
|
|
741
|
+
.input(CreateUserInput)
|
|
742
|
+
.output(UserSchema)
|
|
743
|
+
.mutation(async ({ input, ctx }) => {
|
|
744
|
+
const db = getDb(ctx);
|
|
745
|
+
const user = await db.user.create({ data: input });
|
|
746
|
+
return toUserResponse(user);
|
|
747
|
+
}),
|
|
748
|
+
|
|
749
|
+
updateUser: procedure()
|
|
750
|
+
.guard(authenticated)
|
|
751
|
+
.input(z.object({ id: z.string().uuid() }).merge(UpdateUserInput))
|
|
752
|
+
.output(UserSchema)
|
|
753
|
+
.mutation(async ({ input, ctx }) => {
|
|
754
|
+
const db = getDb(ctx);
|
|
755
|
+
const { id, ...data } = input;
|
|
756
|
+
|
|
757
|
+
if (!ctx.user) {
|
|
758
|
+
throw new AuthError('Authentication required', 401, 'NOT_AUTHENTICATED');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const isOwner = ctx.user.id === id;
|
|
762
|
+
const isAdmin = Array.isArray(ctx.user.roles) && ctx.user.roles.includes('admin');
|
|
763
|
+
|
|
764
|
+
if (!isOwner && !isAdmin) {
|
|
765
|
+
throw new GuardError('ownership', 'You can only update your own profile', 403);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const updated = await db.user.update({ where: { id }, data });
|
|
769
|
+
return toUserResponse(updated);
|
|
770
|
+
}),
|
|
771
|
+
|
|
772
|
+
patchUser: procedure()
|
|
773
|
+
.guard(authenticated)
|
|
774
|
+
.input(z.object({ id: z.string().uuid() }).merge(UpdateUserInput))
|
|
775
|
+
.output(UserSchema)
|
|
776
|
+
.mutation(async ({ input, ctx }) => {
|
|
777
|
+
const db = getDb(ctx);
|
|
778
|
+
const { id, ...data } = input;
|
|
779
|
+
|
|
780
|
+
if (!ctx.user) {
|
|
781
|
+
throw new AuthError('Authentication required', 401, 'NOT_AUTHENTICATED');
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const isOwner = ctx.user.id === id;
|
|
785
|
+
const isAdmin = Array.isArray(ctx.user.roles) && ctx.user.roles.includes('admin');
|
|
786
|
+
|
|
787
|
+
if (!isOwner && !isAdmin) {
|
|
788
|
+
throw new GuardError('ownership', 'You can only update your own profile', 403);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const updated = await db.user.update({ where: { id }, data });
|
|
792
|
+
return toUserResponse(updated);
|
|
793
|
+
}),
|
|
794
|
+
|
|
795
|
+
deleteUser: procedure()
|
|
796
|
+
.guard(hasRole('admin'))
|
|
797
|
+
.input(z.object({ id: z.string().uuid() }))
|
|
798
|
+
.output(z.object({ success: z.boolean() }))
|
|
799
|
+
.mutation(async ({ input, ctx }) => {
|
|
800
|
+
const db = getDb(ctx);
|
|
801
|
+
await db.user.delete({ where: { id: input.id } });
|
|
802
|
+
return { success: true };
|
|
803
|
+
}),
|
|
804
|
+
});
|
|
805
|
+
`;
|
|
806
|
+
}
|
|
807
|
+
// ============================================================================
|
|
808
|
+
// Entry Point (with auth plugin)
|
|
809
|
+
// ============================================================================
|
|
810
|
+
function generateIndexTs() {
|
|
811
|
+
return `/**
|
|
812
|
+
* Application Entry Point
|
|
813
|
+
*/
|
|
814
|
+
|
|
815
|
+
import 'dotenv/config';
|
|
816
|
+
|
|
817
|
+
import path from 'node:path';
|
|
818
|
+
|
|
819
|
+
import fastifyStatic from '@fastify/static';
|
|
820
|
+
import {
|
|
821
|
+
veloxApp,
|
|
822
|
+
VELOX_VERSION,
|
|
823
|
+
databasePlugin,
|
|
824
|
+
authPlugin,
|
|
825
|
+
rest,
|
|
826
|
+
getRouteSummary,
|
|
827
|
+
} from '@veloxts/velox';
|
|
828
|
+
|
|
829
|
+
import { authConfig, config } from './config/index.js';
|
|
830
|
+
import { prisma } from './database/index.js';
|
|
831
|
+
import { authProcedures, healthProcedures, userProcedures } from './procedures/index.js';
|
|
832
|
+
|
|
833
|
+
// ============================================================================
|
|
834
|
+
// Application Bootstrap
|
|
835
|
+
// ============================================================================
|
|
836
|
+
|
|
837
|
+
async function createApp() {
|
|
838
|
+
const app = await veloxApp({
|
|
839
|
+
port: config.port,
|
|
840
|
+
host: config.host,
|
|
841
|
+
logger: config.logger,
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
// Register database plugin
|
|
845
|
+
await app.register(databasePlugin({ client: prisma }));
|
|
846
|
+
|
|
847
|
+
// Register auth plugin
|
|
848
|
+
await app.register(authPlugin(authConfig));
|
|
849
|
+
console.log('[Auth] JWT authentication enabled');
|
|
850
|
+
|
|
851
|
+
// Register static file serving
|
|
852
|
+
await app.server.register(fastifyStatic, {
|
|
853
|
+
root: path.join(process.cwd(), 'public'),
|
|
854
|
+
prefix: '/',
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// Register REST API routes
|
|
858
|
+
const collections = [authProcedures, userProcedures, healthProcedures];
|
|
859
|
+
app.routes(rest(collections, { prefix: config.apiPrefix }));
|
|
860
|
+
|
|
861
|
+
return { app, collections };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function printBanner(collections: Parameters<typeof getRouteSummary>[0]) {
|
|
865
|
+
const divider = '═'.repeat(50);
|
|
866
|
+
|
|
867
|
+
console.log(\`\\n\${divider}\`);
|
|
868
|
+
console.log(\` VeloxTS Application v\${VELOX_VERSION}\`);
|
|
869
|
+
console.log(\` Environment: \${config.env}\`);
|
|
870
|
+
console.log(divider);
|
|
871
|
+
|
|
872
|
+
const routes = getRouteSummary(collections);
|
|
873
|
+
console.log('\\n📍 Registered Routes:\\n');
|
|
874
|
+
|
|
875
|
+
for (const route of routes) {
|
|
876
|
+
const method = route.method.padEnd(6);
|
|
877
|
+
const path = route.path.padEnd(25);
|
|
878
|
+
console.log(\` \${method} \${path} → \${route.namespace}.\${route.procedure}\`);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
console.log(\`\\n\${divider}\`);
|
|
882
|
+
console.log(\` Frontend: http://localhost:\${config.port}\`);
|
|
883
|
+
console.log(\` REST API: http://localhost:\${config.port}\${config.apiPrefix}\`);
|
|
884
|
+
console.log(\`\${divider}\\n\`);
|
|
885
|
+
|
|
886
|
+
console.log('📝 Example requests:\\n');
|
|
887
|
+
console.log(' # Register');
|
|
888
|
+
console.log(\` curl -X POST http://localhost:\${config.port}\${config.apiPrefix}/auth/register \\\\\`);
|
|
889
|
+
console.log(' -H "Content-Type: application/json" \\\\');
|
|
890
|
+
console.log(' -d \\'{"name":"John Doe","email":"john@example.com","password":"SecurePass123"}\\'');
|
|
891
|
+
console.log('');
|
|
892
|
+
console.log(' # Login');
|
|
893
|
+
console.log(\` curl -X POST http://localhost:\${config.port}\${config.apiPrefix}/auth/login \\\\\`);
|
|
894
|
+
console.log(' -H "Content-Type: application/json" \\\\');
|
|
895
|
+
console.log(' -d \\'{"email":"john@example.com","password":"SecurePass123"}\\'');
|
|
896
|
+
console.log('');
|
|
897
|
+
console.log(' # Protected endpoint');
|
|
898
|
+
console.log(\` curl http://localhost:\${config.port}\${config.apiPrefix}/auth/me \\\\\`);
|
|
899
|
+
console.log(' -H "Authorization: Bearer <your-access-token>"');
|
|
900
|
+
console.log('');
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function main() {
|
|
904
|
+
try {
|
|
905
|
+
const { app, collections } = await createApp();
|
|
906
|
+
await app.start();
|
|
907
|
+
printBanner(collections);
|
|
908
|
+
} catch (error) {
|
|
909
|
+
console.error('Failed to start application:', error);
|
|
910
|
+
process.exit(1);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
main();
|
|
915
|
+
`;
|
|
916
|
+
}
|
|
917
|
+
function generateDatabaseIndex() {
|
|
918
|
+
return `/**
|
|
919
|
+
* Database Client (Prisma 7.x)
|
|
920
|
+
*/
|
|
921
|
+
|
|
922
|
+
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
|
|
923
|
+
|
|
924
|
+
import { PrismaClient } from '../generated/prisma/client.js';
|
|
925
|
+
|
|
926
|
+
if (!process.env.DATABASE_URL) {
|
|
927
|
+
throw new Error('DATABASE_URL environment variable is required');
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL });
|
|
931
|
+
|
|
932
|
+
export const prisma = new PrismaClient({ adapter });
|
|
933
|
+
`;
|
|
934
|
+
}
|
|
935
|
+
function generateProceduresIndex() {
|
|
936
|
+
return `/**
|
|
937
|
+
* Procedure Exports
|
|
938
|
+
*/
|
|
939
|
+
|
|
940
|
+
export * from './auth.js';
|
|
941
|
+
export * from './health.js';
|
|
942
|
+
export * from './users.js';
|
|
943
|
+
`;
|
|
944
|
+
}
|
|
945
|
+
function generateSchemasIndex() {
|
|
946
|
+
return `/**
|
|
947
|
+
* Schema Exports
|
|
948
|
+
*/
|
|
949
|
+
|
|
950
|
+
export * from './user.js';
|
|
951
|
+
`;
|
|
952
|
+
}
|
|
953
|
+
function generateUserSchema() {
|
|
954
|
+
return `/**
|
|
955
|
+
* User Schemas
|
|
956
|
+
*/
|
|
957
|
+
|
|
958
|
+
import { createIdSchema, emailSchema, z } from '@veloxts/velox';
|
|
959
|
+
|
|
960
|
+
export const UserSchema = z.object({
|
|
961
|
+
id: createIdSchema('uuid'),
|
|
962
|
+
name: z.string().min(1).max(100),
|
|
963
|
+
email: emailSchema,
|
|
964
|
+
createdAt: z.coerce.date().transform((d) => d.toISOString()),
|
|
965
|
+
updatedAt: z.coerce.date().transform((d) => d.toISOString()),
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
export type User = z.infer<typeof UserSchema>;
|
|
969
|
+
|
|
970
|
+
export const CreateUserInput = z.object({
|
|
971
|
+
name: z.string().min(1).max(100),
|
|
972
|
+
email: emailSchema,
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
export type CreateUserData = z.infer<typeof CreateUserInput>;
|
|
976
|
+
|
|
977
|
+
export const UpdateUserInput = z.object({
|
|
978
|
+
name: z.string().min(1).max(100).optional(),
|
|
979
|
+
email: emailSchema.optional(),
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
export type UpdateUserData = z.infer<typeof UpdateUserInput>;
|
|
983
|
+
`;
|
|
984
|
+
}
|
|
985
|
+
// ============================================================================
|
|
986
|
+
// CLAUDE.md for Auth
|
|
987
|
+
// ============================================================================
|
|
988
|
+
function generateClaudeMd(config) {
|
|
989
|
+
return `# CLAUDE.md
|
|
990
|
+
|
|
991
|
+
This file provides guidance to Claude Code and other AI assistants when working with this VeloxTS project.
|
|
992
|
+
|
|
993
|
+
## Project Overview
|
|
994
|
+
|
|
995
|
+
**${config.projectName}** is a VeloxTS application with full JWT authentication.
|
|
996
|
+
|
|
997
|
+
**Features:**
|
|
998
|
+
- JWT authentication with access/refresh tokens
|
|
999
|
+
- Rate limiting on auth endpoints
|
|
1000
|
+
- Token rotation with reuse detection
|
|
1001
|
+
- Role-based authorization guards
|
|
1002
|
+
- Strong password requirements
|
|
1003
|
+
|
|
1004
|
+
## Commands
|
|
1005
|
+
|
|
1006
|
+
\`\`\`bash
|
|
1007
|
+
${config.packageManager} dev # Start development server
|
|
1008
|
+
${config.packageManager} build # Build for production
|
|
1009
|
+
${config.packageManager} db:push # Push database schema
|
|
1010
|
+
${config.packageManager} db:studio # Open Prisma Studio
|
|
1011
|
+
\`\`\`
|
|
1012
|
+
|
|
1013
|
+
## Authentication
|
|
1014
|
+
|
|
1015
|
+
### Endpoints
|
|
1016
|
+
|
|
1017
|
+
| Endpoint | Method | Description |
|
|
1018
|
+
|----------|--------|-------------|
|
|
1019
|
+
| \`/auth/register\` | POST | Create new account |
|
|
1020
|
+
| \`/auth/login\` | POST | Login and get tokens |
|
|
1021
|
+
| \`/auth/refresh\` | POST | Refresh access token |
|
|
1022
|
+
| \`/auth/logout\` | POST | Revoke current token |
|
|
1023
|
+
| \`/auth/me\` | GET | Get current user (protected) |
|
|
1024
|
+
|
|
1025
|
+
### Usage
|
|
1026
|
+
|
|
1027
|
+
1. **Register/Login** to get tokens
|
|
1028
|
+
2. **Include token** in Authorization header: \`Bearer <accessToken>\`
|
|
1029
|
+
3. **Refresh token** when access token expires
|
|
1030
|
+
|
|
1031
|
+
### Security Features
|
|
1032
|
+
|
|
1033
|
+
- **Rate Limiting**: Login 5/15min, Register 3/hour
|
|
1034
|
+
- **Token Rotation**: Refresh tokens are single-use
|
|
1035
|
+
- **Reuse Detection**: Token reuse triggers security alert
|
|
1036
|
+
- **Password Policy**: 12+ chars, uppercase, lowercase, number
|
|
1037
|
+
|
|
1038
|
+
## Guards
|
|
1039
|
+
|
|
1040
|
+
\`\`\`typescript
|
|
1041
|
+
// Require authentication
|
|
1042
|
+
procedure().guard(authenticated)
|
|
1043
|
+
|
|
1044
|
+
// Require specific role
|
|
1045
|
+
procedure().guard(hasRole('admin'))
|
|
1046
|
+
|
|
1047
|
+
// Custom owner-or-admin check
|
|
1048
|
+
if (!isOwner && !isAdmin) {
|
|
1049
|
+
throw new GuardError('ownership', 'Access denied', 403);
|
|
1050
|
+
}
|
|
1051
|
+
\`\`\`
|
|
1052
|
+
|
|
1053
|
+
## Alternative: Session-Based Authentication
|
|
1054
|
+
|
|
1055
|
+
VeloxTS also supports cookie-based session authentication as an alternative to JWT.
|
|
1056
|
+
Session auth is useful for:
|
|
1057
|
+
- Traditional web applications with server-side rendering
|
|
1058
|
+
- Applications where token storage in the browser is a concern
|
|
1059
|
+
- Simple authentication flows without refresh token management
|
|
1060
|
+
|
|
1061
|
+
\`\`\`typescript
|
|
1062
|
+
import { sessionMiddleware, loginSession, logoutSession } from '@veloxts/velox';
|
|
1063
|
+
|
|
1064
|
+
// Create session middleware
|
|
1065
|
+
const session = sessionMiddleware({
|
|
1066
|
+
secret: process.env.SESSION_SECRET!,
|
|
1067
|
+
cookie: { secure: true, httpOnly: true, sameSite: 'lax' },
|
|
1068
|
+
expiration: { ttl: 86400, sliding: true },
|
|
1069
|
+
userLoader: async (userId) => prisma.user.findUnique({ where: { id: userId } }),
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
// Use in procedures
|
|
1073
|
+
const getProfile = procedure()
|
|
1074
|
+
.use(session.requireAuth())
|
|
1075
|
+
.query(async ({ ctx }) => ctx.user);
|
|
1076
|
+
|
|
1077
|
+
// Login helper
|
|
1078
|
+
await loginSession(ctx.session, user); // Regenerates session ID
|
|
1079
|
+
|
|
1080
|
+
// Logout helper
|
|
1081
|
+
await logoutSession(ctx.session); // Destroys session
|
|
1082
|
+
\`\`\`
|
|
1083
|
+
|
|
1084
|
+
## Environment Variables
|
|
1085
|
+
|
|
1086
|
+
\`\`\`bash
|
|
1087
|
+
# Required for JWT authentication
|
|
1088
|
+
JWT_SECRET=<64+ chars>
|
|
1089
|
+
JWT_REFRESH_SECRET=<64+ chars>
|
|
1090
|
+
|
|
1091
|
+
# Generate with: openssl rand -base64 64
|
|
1092
|
+
|
|
1093
|
+
# Required for session-based authentication (alternative to JWT)
|
|
1094
|
+
SESSION_SECRET=<32+ chars>
|
|
1095
|
+
|
|
1096
|
+
# Generate with: openssl rand -base64 32
|
|
1097
|
+
\`\`\`
|
|
1098
|
+
|
|
1099
|
+
## Project Structure
|
|
1100
|
+
|
|
1101
|
+
\`\`\`
|
|
1102
|
+
src/
|
|
1103
|
+
├── config/
|
|
1104
|
+
│ ├── app.ts # App configuration
|
|
1105
|
+
│ └── auth.ts # Auth configuration + token store
|
|
1106
|
+
├── procedures/
|
|
1107
|
+
│ ├── auth.ts # Auth endpoints
|
|
1108
|
+
│ ├── users.ts # User CRUD with guards
|
|
1109
|
+
│ └── health.ts # Health check
|
|
1110
|
+
├── schemas/
|
|
1111
|
+
│ └── user.ts # Zod schemas
|
|
1112
|
+
└── index.ts # Entry point
|
|
1113
|
+
\`\`\`
|
|
1114
|
+
`;
|
|
1115
|
+
}
|
|
1116
|
+
// ============================================================================
|
|
1117
|
+
// Auth Template Generator
|
|
1118
|
+
// ============================================================================
|
|
1119
|
+
export function generateAuthTemplate(config) {
|
|
1120
|
+
const files = [
|
|
1121
|
+
// Root files
|
|
1122
|
+
{ path: 'package.json', content: generatePackageJson(config) },
|
|
1123
|
+
{ path: '.env.example', content: generateEnvExample() },
|
|
1124
|
+
{ path: '.env', content: generateEnvExample() },
|
|
1125
|
+
{ path: 'CLAUDE.md', content: generateClaudeMd(config) },
|
|
1126
|
+
// Prisma
|
|
1127
|
+
{ path: 'prisma/schema.prisma', content: generatePrismaSchema() },
|
|
1128
|
+
// Source files
|
|
1129
|
+
{ path: 'src/index.ts', content: generateIndexTs() },
|
|
1130
|
+
{ path: 'src/config/index.ts', content: generateConfigIndexWithAuth() },
|
|
1131
|
+
{ path: 'src/config/auth.ts', content: generateAuthConfig() },
|
|
1132
|
+
{ path: 'src/database/index.ts', content: generateDatabaseIndex() },
|
|
1133
|
+
{ path: 'src/procedures/index.ts', content: generateProceduresIndex() },
|
|
1134
|
+
{ path: 'src/procedures/auth.ts', content: generateAuthProcedures() },
|
|
1135
|
+
{ path: 'src/procedures/users.ts', content: generateUserProceduresWithAuth() },
|
|
1136
|
+
{ path: 'src/schemas/index.ts', content: generateSchemasIndex() },
|
|
1137
|
+
{ path: 'src/schemas/user.ts', content: generateUserSchema() },
|
|
1138
|
+
];
|
|
1139
|
+
// Add shared files, but filter out config/index.ts since auth template has its own
|
|
1140
|
+
const sharedFiles = generateSharedFiles(config).filter((file) => file.path !== 'src/config/index.ts');
|
|
1141
|
+
return [...files, ...sharedFiles];
|
|
1142
|
+
}
|
|
1143
|
+
//# sourceMappingURL=auth.js.map
|