@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.
- package/dist/cli.js +713 -11
- package/package.json +1 -1
- package/src/cli/commands/init.ts +77 -95
- package/src/cli/index.ts +1 -1
- package/src/cli/template.ts +957 -0
|
@@ -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
|
+
];
|