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