@enderworld/onlyapi 1.5.1 → 1.6.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.
@@ -0,0 +1,957 @@
1
+ /**
2
+ * Minimal project template — generated by `onlyapi init`.
3
+ *
4
+ * Produces a clean ~12-file project that boots a working API with:
5
+ * - Health check
6
+ * - Auth (register / login / logout)
7
+ * - User profile (me)
8
+ * - SQLite database (zero-config)
9
+ * - JWT authentication
10
+ * - CORS + rate limiting
11
+ * - Structured logging
12
+ */
13
+
14
+ export interface TemplateFile {
15
+ readonly path: string;
16
+ readonly content: string;
17
+ }
18
+
19
+ export const generateTemplate = (projectName: string): TemplateFile[] => [
20
+ // ── package.json ────────────────────────────────────────────────────
21
+ {
22
+ path: "package.json",
23
+ content: `{
24
+ "name": ${JSON.stringify(projectName)},
25
+ "version": "0.1.0",
26
+ "type": "module",
27
+ "scripts": {
28
+ "dev": "bun --watch src/main.ts",
29
+ "start": "NODE_ENV=production bun src/main.ts",
30
+ "check": "tsc --noEmit",
31
+ "test": "bun test",
32
+ "lint": "bunx @biomejs/biome check src/"
33
+ },
34
+ "dependencies": {
35
+ "zod": "^3.24.2"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "^1.9.4",
39
+ "@types/bun": "^1.2.2",
40
+ "typescript": "^5.7.3"
41
+ }
42
+ }
43
+ `,
44
+ },
45
+
46
+ // ── tsconfig.json ───────────────────────────────────────────────────
47
+ {
48
+ path: "tsconfig.json",
49
+ content: `{
50
+ "compilerOptions": {
51
+ "target": "ESNext",
52
+ "module": "ESNext",
53
+ "moduleResolution": "bundler",
54
+ "lib": ["ESNext"],
55
+ "types": ["bun-types"],
56
+ "strict": true,
57
+ "noImplicitAny": true,
58
+ "noUnusedLocals": true,
59
+ "noUnusedParameters": true,
60
+ "esModuleInterop": true,
61
+ "skipLibCheck": true,
62
+ "outDir": "dist",
63
+ "rootDir": "."
64
+ },
65
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
66
+ "exclude": ["node_modules", "dist"]
67
+ }
68
+ `,
69
+ },
70
+
71
+ // ── biome.json ──────────────────────────────────────────────────────
72
+ {
73
+ path: "biome.json",
74
+ content: `{
75
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
76
+ "organizeImports": { "enabled": true },
77
+ "linter": {
78
+ "enabled": true,
79
+ "rules": { "recommended": true }
80
+ },
81
+ "formatter": {
82
+ "enabled": true,
83
+ "indentStyle": "space",
84
+ "indentWidth": 2,
85
+ "lineWidth": 100
86
+ }
87
+ }
88
+ `,
89
+ },
90
+
91
+ // ── .env.example ────────────────────────────────────────────────────
92
+ {
93
+ path: ".env.example",
94
+ content: `# Environment
95
+ NODE_ENV=development
96
+ PORT=3000
97
+ HOST=0.0.0.0
98
+
99
+ # Security
100
+ JWT_SECRET=change-me-to-a-64-char-random-string
101
+ JWT_EXPIRES_IN=15m
102
+
103
+ # Logging
104
+ LOG_LEVEL=debug
105
+
106
+ # Database
107
+ DATABASE_PATH=data/app.sqlite
108
+ `,
109
+ },
110
+
111
+ // ── .gitignore ──────────────────────────────────────────────────────
112
+ {
113
+ path: ".gitignore",
114
+ content: `node_modules/
115
+ dist/
116
+ data/
117
+ .env
118
+ *.sqlite
119
+ *.log
120
+ `,
121
+ },
122
+
123
+ // ── .dockerignore ───────────────────────────────────────────────────
124
+ {
125
+ path: ".dockerignore",
126
+ content: `node_modules/
127
+ dist/
128
+ data/
129
+ .env
130
+ .git/
131
+ *.md
132
+ `,
133
+ },
134
+
135
+ // ── Dockerfile ──────────────────────────────────────────────────────
136
+ {
137
+ path: "Dockerfile",
138
+ content: `FROM oven/bun:1.3-alpine AS builder
139
+ WORKDIR /app
140
+ COPY package.json bun.lock* ./
141
+ RUN bun install --frozen-lockfile
142
+ COPY tsconfig.json ./
143
+ COPY src/ src/
144
+ RUN bun run check
145
+ RUN mkdir -p /app/data
146
+
147
+ FROM oven/bun:1.3-alpine
148
+ WORKDIR /app
149
+ COPY --from=builder /app/src/ src/
150
+ COPY --from=builder /app/node_modules/ node_modules/
151
+ COPY --from=builder /app/package.json ./
152
+ COPY --from=builder /app/tsconfig.json ./
153
+ COPY --from=builder /app/data/ data/
154
+ RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app
155
+ USER app
156
+ EXPOSE 3000
157
+ ENV NODE_ENV=production
158
+ ENV HOST=0.0.0.0
159
+ ENV PORT=3000
160
+ CMD ["bun", "src/main.ts"]
161
+ `,
162
+ },
163
+
164
+ // ── README.md ───────────────────────────────────────────────────────
165
+ {
166
+ path: "README.md",
167
+ content: `# ${projectName}
168
+
169
+ Built with [onlyApi](https://github.com/lysari/onlyapi) — zero-dependency REST API on Bun.
170
+
171
+ ## Quick Start
172
+
173
+ \`\`\`bash
174
+ bun run dev # Start dev server (hot-reload)
175
+ bun test # Run tests
176
+ bun run check # Type-check
177
+ \`\`\`
178
+
179
+ ## API Endpoints
180
+
181
+ | Method | Path | Description | Auth |
182
+ |--------|-------------------------|-------------------|------|
183
+ | GET | /health | Health check | No |
184
+ | POST | /api/v1/auth/register | Register | No |
185
+ | POST | /api/v1/auth/login | Login | No |
186
+ | POST | /api/v1/auth/logout | Logout | Yes |
187
+ | GET | /api/v1/users/me | Get profile | Yes |
188
+ | PATCH | /api/v1/users/me | Update profile | Yes |
189
+ | DELETE | /api/v1/users/me | Delete account | Yes |
190
+
191
+ ## Project Structure
192
+
193
+ \`\`\`
194
+ src/
195
+ main.ts # Entry point — wires everything together
196
+ config.ts # Environment config with validation
197
+ database.ts # SQLite setup + migrations
198
+ server.ts # HTTP server + routing
199
+ handlers/
200
+ auth.handler.ts # Register, login, logout
201
+ health.handler.ts # Health check
202
+ user.handler.ts # User profile CRUD
203
+ middleware/
204
+ auth.ts # JWT authentication guard
205
+ services/
206
+ auth.service.ts # Auth business logic
207
+ user.service.ts # User business logic
208
+ utils/
209
+ password.ts # Argon2id hashing
210
+ token.ts # JWT sign/verify
211
+ response.ts # JSON response helpers
212
+ \`\`\`
213
+
214
+ ## Environment Variables
215
+
216
+ | Variable | Default | Description |
217
+ |----------------|----------------------|-------------------------|
218
+ | PORT | 3000 | Server port |
219
+ | HOST | 0.0.0.0 | Bind address |
220
+ | JWT_SECRET | — | **Required** JWT secret |
221
+ | JWT_EXPIRES_IN | 15m | Token expiration |
222
+ | DATABASE_PATH | data/app.sqlite | SQLite file path |
223
+ | LOG_LEVEL | debug | info / debug / warn |
224
+ | NODE_ENV | development | development / production|
225
+
226
+ ## Docker
227
+
228
+ \`\`\`bash
229
+ docker build -t ${projectName} .
230
+ docker run -e JWT_SECRET="your-secret-here" -p 3000:3000 ${projectName}
231
+ \`\`\`
232
+ `,
233
+ },
234
+
235
+ // ── src/main.ts ─────────────────────────────────────────────────────
236
+ {
237
+ path: "src/main.ts",
238
+ content: `import { loadConfig } from "./config.js";
239
+ import { createDatabase } from "./database.js";
240
+ import { createAuthService } from "./services/auth.service.js";
241
+ import { createUserService } from "./services/user.service.js";
242
+ import { createServer } from "./server.js";
243
+ import { createPasswordHasher } from "./utils/password.js";
244
+ import { createTokenService } from "./utils/token.js";
245
+
246
+ // ── Load config ───────────────────────────────────────────────────────
247
+ const config = loadConfig();
248
+
249
+ // ── Database ──────────────────────────────────────────────────────────
250
+ const db = createDatabase(config.databasePath);
251
+
252
+ // ── Services ──────────────────────────────────────────────────────────
253
+ const passwordHasher = createPasswordHasher();
254
+ const tokenService = createTokenService(config.jwt.secret, config.jwt.expiresIn);
255
+ const authService = createAuthService(db, passwordHasher, tokenService);
256
+ const userService = createUserService(db, passwordHasher);
257
+
258
+ // ── Server ────────────────────────────────────────────────────────────
259
+ const server = createServer({
260
+ config,
261
+ authService,
262
+ userService,
263
+ tokenService,
264
+ });
265
+
266
+ console.log(\`
267
+ ⚡ \${config.nodeEnv === "production" ? "PRODUCTION" : "DEV"} server running
268
+ → http://\${config.host}:\${config.port}
269
+ → SQLite: \${config.databasePath}
270
+ \`);
271
+
272
+ // ── Graceful shutdown ─────────────────────────────────────────────────
273
+ const shutdown = () => {
274
+ console.log("\\nShutting down...");
275
+ server.stop();
276
+ db.close();
277
+ process.exit(0);
278
+ };
279
+
280
+ process.on("SIGINT", shutdown);
281
+ process.on("SIGTERM", shutdown);
282
+ `,
283
+ },
284
+
285
+ // ── src/config.ts ───────────────────────────────────────────────────
286
+ {
287
+ path: "src/config.ts",
288
+ content: `import { z } from "zod";
289
+
290
+ const configSchema = z.object({
291
+ port: z.coerce.number().default(3000),
292
+ host: z.string().default("0.0.0.0"),
293
+ nodeEnv: z.enum(["development", "production"]).default("development"),
294
+ jwt: z.object({
295
+ secret: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
296
+ expiresIn: z.string().default("15m"),
297
+ }),
298
+ databasePath: z.string().default("data/app.sqlite"),
299
+ logLevel: z.enum(["debug", "info", "warn", "error"]).default("debug"),
300
+ corsOrigins: z.string().default("*"),
301
+ rateLimitMax: z.coerce.number().default(100),
302
+ rateLimitWindowMs: z.coerce.number().default(60_000),
303
+ });
304
+
305
+ export type AppConfig = z.infer<typeof configSchema>;
306
+
307
+ export const loadConfig = (): AppConfig => {
308
+ const result = configSchema.safeParse({
309
+ port: Bun.env.PORT,
310
+ host: Bun.env.HOST,
311
+ nodeEnv: Bun.env.NODE_ENV,
312
+ jwt: {
313
+ secret: Bun.env.JWT_SECRET,
314
+ expiresIn: Bun.env.JWT_EXPIRES_IN,
315
+ },
316
+ databasePath: Bun.env.DATABASE_PATH,
317
+ logLevel: Bun.env.LOG_LEVEL,
318
+ corsOrigins: Bun.env.CORS_ORIGINS,
319
+ rateLimitMax: Bun.env.RATE_LIMIT_MAX_REQUESTS,
320
+ rateLimitWindowMs: Bun.env.RATE_LIMIT_WINDOW_MS,
321
+ });
322
+
323
+ if (!result.success) {
324
+ const errors = result.error.issues
325
+ .map((i) => \` ✗ \${i.path.join(".")} → \${i.message}\`)
326
+ .join("\\n");
327
+
328
+ console.error(\`\\n CONFIG ERROR Invalid configuration\\n\\n\${errors}\\n\`);
329
+ console.error(" Hint: Copy .env.example to .env and set the required values:\\n");
330
+ console.error(" $ cp .env.example .env\\n");
331
+ process.exit(1);
332
+ }
333
+
334
+ return result.data;
335
+ };
336
+ `,
337
+ },
338
+
339
+ // ── src/database.ts ─────────────────────────────────────────────────
340
+ {
341
+ path: "src/database.ts",
342
+ content: `import { Database } from "bun:sqlite";
343
+ import { existsSync, mkdirSync } from "node:fs";
344
+ import { dirname } from "node:path";
345
+
346
+ export const createDatabase = (dbPath: string): Database => {
347
+ // Ensure data directory exists
348
+ const dir = dirname(dbPath);
349
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
350
+
351
+ const db = new Database(dbPath, { create: true });
352
+
353
+ // Performance settings
354
+ db.exec("PRAGMA journal_mode = WAL");
355
+ db.exec("PRAGMA synchronous = NORMAL");
356
+ db.exec("PRAGMA foreign_keys = ON");
357
+
358
+ // Run migrations
359
+ migrate(db);
360
+
361
+ return db;
362
+ };
363
+
364
+ const migrate = (db: Database): void => {
365
+ db.exec(\`
366
+ CREATE TABLE IF NOT EXISTS users (
367
+ id TEXT PRIMARY KEY,
368
+ email TEXT NOT NULL UNIQUE,
369
+ password TEXT NOT NULL,
370
+ name TEXT NOT NULL DEFAULT '',
371
+ role TEXT NOT NULL DEFAULT 'user',
372
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
373
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
374
+ )
375
+ \`);
376
+
377
+ db.exec(\`
378
+ CREATE TABLE IF NOT EXISTS token_blacklist (
379
+ token_id TEXT PRIMARY KEY,
380
+ expires_at TEXT NOT NULL
381
+ )
382
+ \`);
383
+
384
+ console.log(" ✓ Database ready");
385
+ };
386
+ `,
387
+ },
388
+
389
+ // ── src/server.ts ───────────────────────────────────────────────────
390
+ {
391
+ path: "src/server.ts",
392
+ content: `import type { AuthService } from "./services/auth.service.js";
393
+ import type { UserService } from "./services/user.service.js";
394
+ import type { TokenService } from "./utils/token.js";
395
+ import type { AppConfig } from "./config.js";
396
+ import { authHandlers } from "./handlers/auth.handler.js";
397
+ import { healthHandler } from "./handlers/health.handler.js";
398
+ import { userHandlers } from "./handlers/user.handler.js";
399
+ import { authenticate } from "./middleware/auth.js";
400
+ import { json } from "./utils/response.js";
401
+
402
+ interface ServerDeps {
403
+ config: AppConfig;
404
+ authService: AuthService;
405
+ userService: UserService;
406
+ tokenService: TokenService;
407
+ }
408
+
409
+ export const createServer = (deps: ServerDeps) => {
410
+ const { config, tokenService } = deps;
411
+ const auth = authHandlers(deps.authService);
412
+ const health = healthHandler();
413
+ const users = userHandlers(deps.userService);
414
+
415
+ // ── Rate limiting (in-memory) ───────────────────────────────────────
416
+ const hits = new Map<string, { count: number; resetAt: number }>();
417
+
418
+ const isRateLimited = (ip: string): boolean => {
419
+ const now = Date.now();
420
+ const entry = hits.get(ip);
421
+
422
+ if (!entry || now > entry.resetAt) {
423
+ hits.set(ip, { count: 1, resetAt: now + config.rateLimitWindowMs });
424
+ return false;
425
+ }
426
+
427
+ entry.count++;
428
+ return entry.count > config.rateLimitMax;
429
+ };
430
+
431
+ // ── CORS headers ───────────────────────────────────────────────────
432
+ const corsHeaders = (origin: string | null): Record<string, string> => {
433
+ const allowed = config.corsOrigins === "*" || (origin && config.corsOrigins.includes(origin));
434
+ return {
435
+ "Access-Control-Allow-Origin": allowed ? (origin ?? "*") : "",
436
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
437
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
438
+ "Access-Control-Max-Age": "86400",
439
+ };
440
+ };
441
+
442
+ // ── Request handler ────────────────────────────────────────────────
443
+ const server = Bun.serve({
444
+ port: config.port,
445
+ hostname: config.host,
446
+
447
+ async fetch(req) {
448
+ const url = new URL(req.url);
449
+ const { pathname } = url;
450
+ const method = req.method;
451
+ const origin = req.headers.get("origin");
452
+
453
+ // CORS preflight
454
+ if (method === "OPTIONS") {
455
+ return new Response(null, { status: 204, headers: corsHeaders(origin) });
456
+ }
457
+
458
+ // Rate limiting
459
+ const ip = server.requestIP(req)?.address ?? "unknown";
460
+ if (isRateLimited(ip)) {
461
+ return json({ error: "Too many requests" }, 429, corsHeaders(origin));
462
+ }
463
+
464
+ try {
465
+ let response: Response;
466
+
467
+ // ── Public routes ──
468
+ if (pathname === "/health" && method === "GET") {
469
+ response = health.check();
470
+ } else if (pathname === "/api/v1/auth/register" && method === "POST") {
471
+ response = await auth.register(req);
472
+ } else if (pathname === "/api/v1/auth/login" && method === "POST") {
473
+ response = await auth.login(req);
474
+ }
475
+
476
+ // ── Protected routes ──
477
+ else if (pathname === "/api/v1/auth/logout" && method === "POST") {
478
+ const authResult = await authenticate(req, tokenService);
479
+ if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
480
+ response = await auth.logout(authResult.userId, req);
481
+ } else if (pathname === "/api/v1/users/me" && method === "GET") {
482
+ const authResult = await authenticate(req, tokenService);
483
+ if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
484
+ response = await users.getProfile(authResult.userId);
485
+ } else if (pathname === "/api/v1/users/me" && method === "PATCH") {
486
+ const authResult = await authenticate(req, tokenService);
487
+ if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
488
+ response = await users.updateProfile(authResult.userId, req);
489
+ } else if (pathname === "/api/v1/users/me" && method === "DELETE") {
490
+ const authResult = await authenticate(req, tokenService);
491
+ if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
492
+ response = await users.deleteAccount(authResult.userId);
493
+ }
494
+
495
+ // ── 404 ──
496
+ else {
497
+ response = json({ error: "Not found" }, 404);
498
+ }
499
+
500
+ // Add CORS headers to every response
501
+ const headers = corsHeaders(origin);
502
+ for (const [k, v] of Object.entries(headers)) {
503
+ response.headers.set(k, v);
504
+ }
505
+
506
+ return response;
507
+ } catch (err) {
508
+ console.error("Unhandled error:", err);
509
+ return json({ error: "Internal server error" }, 500, corsHeaders(origin));
510
+ }
511
+ },
512
+ });
513
+
514
+ return server;
515
+ };
516
+ `,
517
+ },
518
+
519
+ // ── src/handlers/health.handler.ts ──────────────────────────────────
520
+ {
521
+ path: "src/handlers/health.handler.ts",
522
+ content: `import { json } from "../utils/response.js";
523
+
524
+ export const healthHandler = () => ({
525
+ check: (): Response =>
526
+ json({ status: "ok", uptime: process.uptime() }),
527
+ });
528
+ `,
529
+ },
530
+
531
+ // ── src/handlers/auth.handler.ts ────────────────────────────────────
532
+ {
533
+ path: "src/handlers/auth.handler.ts",
534
+ content: `import type { AuthService } from "../services/auth.service.js";
535
+ import { json } from "../utils/response.js";
536
+ import { z } from "zod";
537
+
538
+ const registerSchema = z.object({
539
+ email: z.string().email(),
540
+ password: z.string().min(8),
541
+ name: z.string().min(1).optional(),
542
+ });
543
+
544
+ const loginSchema = z.object({
545
+ email: z.string().email(),
546
+ password: z.string(),
547
+ });
548
+
549
+ export const authHandlers = (authService: AuthService) => ({
550
+ register: async (req: Request): Promise<Response> => {
551
+ const body = registerSchema.safeParse(await req.json());
552
+ if (!body.success) return json({ error: body.error.issues }, 400);
553
+
554
+ const result = await authService.register(body.data);
555
+ if (!result.ok) return json({ error: result.error }, 409);
556
+
557
+ return json({ data: result.data }, 201);
558
+ },
559
+
560
+ login: async (req: Request): Promise<Response> => {
561
+ const body = loginSchema.safeParse(await req.json());
562
+ if (!body.success) return json({ error: body.error.issues }, 400);
563
+
564
+ const result = await authService.login(body.data.email, body.data.password);
565
+ if (!result.ok) return json({ error: result.error }, 401);
566
+
567
+ return json({ data: result.data });
568
+ },
569
+
570
+ logout: async (userId: string, req: Request): Promise<Response> => {
571
+ const header = req.headers.get("authorization") ?? "";
572
+ const token = header.replace("Bearer ", "");
573
+
574
+ await authService.logout(token);
575
+ return json({ data: { message: "Logged out" } });
576
+ },
577
+ });
578
+ `,
579
+ },
580
+
581
+ // ── src/handlers/user.handler.ts ────────────────────────────────────
582
+ {
583
+ path: "src/handlers/user.handler.ts",
584
+ content: `import type { UserService } from "../services/user.service.js";
585
+ import { json } from "../utils/response.js";
586
+ import { z } from "zod";
587
+
588
+ const updateSchema = z.object({
589
+ name: z.string().min(1).optional(),
590
+ email: z.string().email().optional(),
591
+ });
592
+
593
+ export const userHandlers = (userService: UserService) => ({
594
+ getProfile: async (userId: string): Promise<Response> => {
595
+ const user = userService.findById(userId);
596
+ if (!user) return json({ error: "User not found" }, 404);
597
+
598
+ const { password: _, ...profile } = user;
599
+ return json({ data: profile });
600
+ },
601
+
602
+ updateProfile: async (userId: string, req: Request): Promise<Response> => {
603
+ const body = updateSchema.safeParse(await req.json());
604
+ if (!body.success) return json({ error: body.error.issues }, 400);
605
+
606
+ const updated = userService.update(userId, body.data);
607
+ if (!updated) return json({ error: "User not found" }, 404);
608
+
609
+ const { password: _, ...profile } = updated;
610
+ return json({ data: profile });
611
+ },
612
+
613
+ deleteAccount: async (userId: string): Promise<Response> => {
614
+ const deleted = userService.delete(userId);
615
+ if (!deleted) return json({ error: "User not found" }, 404);
616
+
617
+ return json({ data: { message: "Account deleted" } });
618
+ },
619
+ });
620
+ `,
621
+ },
622
+
623
+ // ── src/middleware/auth.ts ──────────────────────────────────────────
624
+ {
625
+ path: "src/middleware/auth.ts",
626
+ content: `import type { TokenService } from "../utils/token.js";
627
+
628
+ type AuthResult =
629
+ | { ok: true; userId: string }
630
+ | { ok: false; error: string };
631
+
632
+ export const authenticate = async (
633
+ req: Request,
634
+ tokenService: TokenService,
635
+ ): Promise<AuthResult> => {
636
+ const header = req.headers.get("authorization");
637
+ if (!header?.startsWith("Bearer ")) {
638
+ return { ok: false, error: "Missing or invalid Authorization header" };
639
+ }
640
+
641
+ const token = header.slice(7);
642
+ const payload = tokenService.verify(token);
643
+
644
+ if (!payload) {
645
+ return { ok: false, error: "Invalid or expired token" };
646
+ }
647
+
648
+ if (tokenService.isBlacklisted(payload.jti)) {
649
+ return { ok: false, error: "Token has been revoked" };
650
+ }
651
+
652
+ return { ok: true, userId: payload.sub };
653
+ };
654
+ `,
655
+ },
656
+
657
+ // ── src/services/auth.service.ts ────────────────────────────────────
658
+ {
659
+ path: "src/services/auth.service.ts",
660
+ content: `import type { Database } from "bun:sqlite";
661
+ import type { PasswordHasher } from "../utils/password.js";
662
+ import type { TokenService } from "../utils/token.js";
663
+
664
+ type Result<T> = { ok: true; data: T } | { ok: false; error: string };
665
+
666
+ interface RegisterInput {
667
+ email: string;
668
+ password: string;
669
+ name?: string;
670
+ }
671
+
672
+ export interface AuthService {
673
+ register(input: RegisterInput): Promise<Result<{ id: string; email: string; token: string }>>;
674
+ login(email: string, password: string): Promise<Result<{ token: string; user: { id: string; email: string; name: string } }>>;
675
+ logout(token: string): Promise<void>;
676
+ }
677
+
678
+ export const createAuthService = (
679
+ db: Database,
680
+ passwordHasher: PasswordHasher,
681
+ tokenService: TokenService,
682
+ ): AuthService => ({
683
+ async register(input) {
684
+ // Check if user exists
685
+ const existing = db.query("SELECT id FROM users WHERE email = ?").get(input.email);
686
+ if (existing) return { ok: false, error: "Email already registered" };
687
+
688
+ const id = crypto.randomUUID();
689
+ const hashedPassword = await passwordHasher.hash(input.password);
690
+
691
+ db.query("INSERT INTO users (id, email, password, name) VALUES (?, ?, ?, ?)").run(
692
+ id,
693
+ input.email,
694
+ hashedPassword,
695
+ input.name ?? "",
696
+ );
697
+
698
+ const token = tokenService.sign({ sub: id, email: input.email, role: "user" });
699
+ return { ok: true, data: { id, email: input.email, token } };
700
+ },
701
+
702
+ async login(email, password) {
703
+ const user = db
704
+ .query("SELECT id, email, password, name, role FROM users WHERE email = ?")
705
+ .get(email) as { id: string; email: string; password: string; name: string; role: string } | null;
706
+
707
+ if (!user) return { ok: false, error: "Invalid credentials" };
708
+
709
+ const valid = await passwordHasher.verify(user.password, password);
710
+ if (!valid) return { ok: false, error: "Invalid credentials" };
711
+
712
+ const token = tokenService.sign({ sub: user.id, email: user.email, role: user.role });
713
+ return {
714
+ ok: true,
715
+ data: { token, user: { id: user.id, email: user.email, name: user.name } },
716
+ };
717
+ },
718
+
719
+ async logout(token) {
720
+ const payload = tokenService.verify(token);
721
+ if (payload?.jti) {
722
+ tokenService.blacklist(payload.jti, payload.exp);
723
+ }
724
+ },
725
+ });
726
+ `,
727
+ },
728
+
729
+ // ── src/services/user.service.ts ────────────────────────────────────
730
+ {
731
+ path: "src/services/user.service.ts",
732
+ content: `import type { Database } from "bun:sqlite";
733
+ import type { PasswordHasher } from "../utils/password.js";
734
+
735
+ interface User {
736
+ id: string;
737
+ email: string;
738
+ password: string;
739
+ name: string;
740
+ role: string;
741
+ created_at: string;
742
+ updated_at: string;
743
+ }
744
+
745
+ export interface UserService {
746
+ findById(id: string): User | null;
747
+ update(id: string, data: { name?: string; email?: string }): User | null;
748
+ delete(id: string): boolean;
749
+ }
750
+
751
+ export const createUserService = (db: Database, _passwordHasher: PasswordHasher): UserService => ({
752
+ findById(id) {
753
+ return db.query("SELECT * FROM users WHERE id = ?").get(id) as User | null;
754
+ },
755
+
756
+ update(id, data) {
757
+ const fields: string[] = [];
758
+ const values: unknown[] = [];
759
+
760
+ if (data.name !== undefined) {
761
+ fields.push("name = ?");
762
+ values.push(data.name);
763
+ }
764
+ if (data.email !== undefined) {
765
+ fields.push("email = ?");
766
+ values.push(data.email);
767
+ }
768
+
769
+ if (fields.length === 0) return this.findById(id);
770
+
771
+ fields.push("updated_at = datetime('now')");
772
+ values.push(id);
773
+
774
+ db.query(\`UPDATE users SET \${fields.join(", ")} WHERE id = ?\`).run(...values);
775
+ return this.findById(id);
776
+ },
777
+
778
+ delete(id) {
779
+ const result = db.query("DELETE FROM users WHERE id = ?").run(id);
780
+ return result.changes > 0;
781
+ },
782
+ });
783
+ `,
784
+ },
785
+
786
+ // ── src/utils/password.ts ───────────────────────────────────────────
787
+ {
788
+ path: "src/utils/password.ts",
789
+ content: `/**
790
+ * Password hashing using Bun's built-in Argon2id.
791
+ */
792
+ export interface PasswordHasher {
793
+ hash(password: string): Promise<string>;
794
+ verify(hash: string, password: string): Promise<boolean>;
795
+ }
796
+
797
+ export const createPasswordHasher = (): PasswordHasher => ({
798
+ async hash(password) {
799
+ return Bun.password.hash(password, { algorithm: "argon2id" });
800
+ },
801
+
802
+ async verify(hash, password) {
803
+ return Bun.password.verify(password, hash);
804
+ },
805
+ });
806
+ `,
807
+ },
808
+
809
+ // ── src/utils/token.ts ──────────────────────────────────────────────
810
+ {
811
+ path: "src/utils/token.ts",
812
+ content: [
813
+ "/**",
814
+ " * JWT token service using Bun's native HMAC-SHA256.",
815
+ " */",
816
+ "",
817
+ "export interface TokenPayload {",
818
+ " sub: string;",
819
+ " jti: string;",
820
+ " exp: number;",
821
+ " [key: string]: unknown;",
822
+ "}",
823
+ "",
824
+ "export interface TokenService {",
825
+ " sign(claims: Record<string, unknown>): string;",
826
+ " verify(token: string): TokenPayload | null;",
827
+ " blacklist(jti: string, exp: number): void;",
828
+ " isBlacklisted(jti: string): boolean;",
829
+ "}",
830
+ "",
831
+ '// Parse duration string to seconds: "15m" → 900, "1h" → 3600, "7d" → 604800',
832
+ "const parseDuration = (duration: string): number => {",
833
+ " const match = duration.match(/^(\\d+)([smhd])$/);",
834
+ " if (!match) return 900; // default 15 minutes",
835
+ " const value = Number.parseInt(match[1], 10);",
836
+ " const unit = match[2];",
837
+ " const multipliers: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };",
838
+ " return value * (multipliers[unit] ?? 60);",
839
+ "};",
840
+ "",
841
+ "export const createTokenService = (secret: string, expiresIn: string): TokenService => {",
842
+ " const key = new TextEncoder().encode(secret);",
843
+ " const expiresInSec = parseDuration(expiresIn);",
844
+ "",
845
+ " // In-memory blacklist with auto-cleanup",
846
+ " const blacklisted = new Map<string, number>();",
847
+ "",
848
+ " // Clean expired entries every 5 minutes",
849
+ " setInterval(() => {",
850
+ " const now = Math.floor(Date.now() / 1000);",
851
+ " for (const [jti, exp] of blacklisted) {",
852
+ " if (exp < now) blacklisted.delete(jti);",
853
+ " }",
854
+ " }, 5 * 60 * 1000).unref();",
855
+ "",
856
+ " return {",
857
+ " sign(claims) {",
858
+ " const now = Math.floor(Date.now() / 1000);",
859
+ " const jti = crypto.randomUUID();",
860
+ " const payload = { ...claims, jti, iat: now, exp: now + expiresInSec };",
861
+ "",
862
+ " // HMAC-SHA256 JWT",
863
+ ' const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }))',
864
+ ' .replace(/=/g, "").replace(/\\+/g, "-").replace(/\\//g, "_");',
865
+ "",
866
+ " const body = btoa(JSON.stringify(payload))",
867
+ ' .replace(/=/g, "").replace(/\\+/g, "-").replace(/\\//g, "_");',
868
+ "",
869
+ ' const data = header + "." + body;',
870
+ ' const hmac = new Bun.CryptoHasher("sha256", key);',
871
+ " hmac.update(data);",
872
+ ' const sig = Buffer.from(hmac.digest()).toString("base64url");',
873
+ "",
874
+ ' return data + "." + sig;',
875
+ " },",
876
+ "",
877
+ " verify(token) {",
878
+ " try {",
879
+ ' const parts = token.split(".");',
880
+ " if (parts.length !== 3) return null;",
881
+ "",
882
+ " // Verify signature",
883
+ ' const data = parts[0] + "." + parts[1];',
884
+ ' const hmac = new Bun.CryptoHasher("sha256", key);',
885
+ " hmac.update(data);",
886
+ ' const expected = Buffer.from(hmac.digest()).toString("base64url");',
887
+ "",
888
+ " if (expected !== parts[2]) return null;",
889
+ "",
890
+ " // Decode payload",
891
+ " const payload = JSON.parse(",
892
+ ' Buffer.from(parts[1], "base64url").toString()',
893
+ " ) as TokenPayload;",
894
+ "",
895
+ " // Check expiry",
896
+ " if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {",
897
+ " return null;",
898
+ " }",
899
+ "",
900
+ " return payload;",
901
+ " } catch {",
902
+ " return null;",
903
+ " }",
904
+ " },",
905
+ "",
906
+ " blacklist(jti, exp) {",
907
+ " blacklisted.set(jti, exp);",
908
+ " },",
909
+ "",
910
+ " isBlacklisted(jti) {",
911
+ " return blacklisted.has(jti);",
912
+ " },",
913
+ " };",
914
+ "};",
915
+ "",
916
+ ].join("\n"),
917
+ },
918
+
919
+ // ── src/utils/response.ts ───────────────────────────────────────────
920
+ {
921
+ path: "src/utils/response.ts",
922
+ content: `/**
923
+ * JSON response helper.
924
+ */
925
+ export const json = (
926
+ body: unknown,
927
+ status = 200,
928
+ extraHeaders?: Record<string, string>,
929
+ ): Response => {
930
+ const headers: Record<string, string> = {
931
+ "Content-Type": "application/json; charset=utf-8",
932
+ ...extraHeaders,
933
+ };
934
+
935
+ return new Response(JSON.stringify(body), { status, headers });
936
+ };
937
+ `,
938
+ },
939
+
940
+ // ── tests/health.test.ts ────────────────────────────────────────────
941
+ {
942
+ path: "tests/health.test.ts",
943
+ content: `import { describe, expect, it } from "bun:test";
944
+
945
+ describe("Health check", () => {
946
+ it("should return ok", async () => {
947
+ // Start server for test
948
+ const response = await fetch("http://localhost:3000/health");
949
+ expect(response.status).toBe(200);
950
+
951
+ const data = await response.json();
952
+ expect(data.status).toBe("ok");
953
+ });
954
+ });
955
+ `,
956
+ },
957
+ ];