@enderworld/onlyapi 1.6.0 → 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 +18 -313
- package/package.json +1 -1
- package/src/cli/commands/init.ts +3 -1
- package/src/cli/index.ts +1 -1
- package/src/cli/template.ts +548 -314
package/dist/cli.js
CHANGED
|
@@ -142,7 +142,9 @@ src/
|
|
|
142
142
|
main.ts # Entry point \u2014 wires everything together
|
|
143
143
|
config.ts # Environment config with validation
|
|
144
144
|
database.ts # SQLite setup + migrations
|
|
145
|
-
|
|
145
|
+
logger.ts # Colored structured logger
|
|
146
|
+
router.ts # Lightweight router with route table
|
|
147
|
+
server.ts # HTTP server, CORS, rate limiting
|
|
146
148
|
handlers/
|
|
147
149
|
auth.handler.ts # Register, login, logout
|
|
148
150
|
health.handler.ts # Health check
|
|
@@ -176,51 +178,10 @@ src/
|
|
|
176
178
|
docker build -t ${J} .
|
|
177
179
|
docker run -e JWT_SECRET="your-secret-here" -p 3000:3000 ${J}
|
|
178
180
|
\`\`\`
|
|
179
|
-
`},{path:"src/
|
|
180
|
-
|
|
181
|
-
import { createAuthService } from "./services/auth.service.js";
|
|
182
|
-
import {
|
|
183
|
-
import { createServer } from "./server.js";
|
|
184
|
-
import { createPasswordHasher } from "./utils/password.js";
|
|
185
|
-
import { createTokenService } from "./utils/token.js";
|
|
186
|
-
|
|
187
|
-
// \u2500\u2500 Load config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
188
|
-
const config = loadConfig();
|
|
189
|
-
|
|
190
|
-
// \u2500\u2500 Database \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
191
|
-
const db = createDatabase(config.databasePath);
|
|
192
|
-
|
|
193
|
-
// \u2500\u2500 Services \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
194
|
-
const passwordHasher = createPasswordHasher();
|
|
195
|
-
const tokenService = createTokenService(config.jwt.secret, config.jwt.expiresIn);
|
|
196
|
-
const authService = createAuthService(db, passwordHasher, tokenService);
|
|
197
|
-
const userService = createUserService(db, passwordHasher);
|
|
198
|
-
|
|
199
|
-
// \u2500\u2500 Server \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
200
|
-
const server = createServer({
|
|
201
|
-
config,
|
|
202
|
-
authService,
|
|
203
|
-
userService,
|
|
204
|
-
tokenService,
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
console.log(\`
|
|
208
|
-
\u26A1 \${config.nodeEnv === "production" ? "PRODUCTION" : "DEV"} server running
|
|
209
|
-
\u2192 http://\${config.host}:\${config.port}
|
|
210
|
-
\u2192 SQLite: \${config.databasePath}
|
|
211
|
-
\`);
|
|
212
|
-
|
|
213
|
-
// \u2500\u2500 Graceful shutdown \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
214
|
-
const shutdown = () => {
|
|
215
|
-
console.log("\\nShutting down...");
|
|
216
|
-
server.stop();
|
|
217
|
-
db.close();
|
|
218
|
-
process.exit(0);
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
process.on("SIGINT", shutdown);
|
|
222
|
-
process.on("SIGTERM", shutdown);
|
|
223
|
-
`},{path:"src/config.ts",content:`import { z } from "zod";
|
|
181
|
+
`},{path:"src/logger.ts",content:["/**"," * Colored structured logger \u2014 zero dependencies."," */","",'type LogLevel = "debug" | "info" | "warn" | "error";',"","const LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };","","// ANSI colors",'const esc = (c: string) => "\\x1b[" + c + "m";','const reset = esc("0");','const bold = (s: string) => esc("1") + s + reset;','const dim = (s: string) => esc("2") + s + reset;','const green = (s: string) => esc("32") + s + reset;','const yellow = (s: string) => esc("33") + s + reset;','const red = (s: string) => esc("31") + s + reset;','const cyan = (s: string) => esc("36") + s + reset;','const gray = (s: string) => esc("90") + s + reset;','const white = (s: string) => esc("97") + s + reset;','const magenta = (s: string) => esc("35") + s + reset;','const blue = (s: string) => esc("34") + s + reset;',"","const timestamp = (): string => {"," const d = new Date();",' const h = String(d.getHours()).padStart(2, "0");',' const m = String(d.getMinutes()).padStart(2, "0");',' const s = String(d.getSeconds()).padStart(2, "0");',' const ms = String(d.getMilliseconds()).padStart(3, "0");',' return h + ":" + m + ":" + s + "." + ms;',"};","","const badge = (level: LogLevel): string => {"," switch (level) {",' case "debug": return gray("DBG");',' case "info": return green("INF");',' case "warn": return yellow("WRN");',' case "error": return red("ERR");'," }","};","","export interface Logger {"," debug(msg: string, extra?: Record<string, unknown>): void;"," info(msg: string, extra?: Record<string, unknown>): void;"," warn(msg: string, extra?: Record<string, unknown>): void;"," error(msg: string, extra?: Record<string, unknown>): void;","}","",'export const createLogger = (minLevel: LogLevel = "debug"): Logger => {'," const threshold = LEVELS[minLevel];",""," const log = (level: LogLevel, msg: string, extra?: Record<string, unknown>) => {"," if (LEVELS[level] < threshold) return;",' let line = " " + badge(level) + " " + dim(timestamp()) + " " + msg;'," if (extra) {"," const parts = Object.entries(extra)",' .map(([k, v]) => gray(k + "=") + white(String(v)));',' line += " " + parts.join(" ");'," }"," console.log(line);"," };",""," return {",' debug: (msg, extra) => log("debug", msg, extra),',' info: (msg, extra) => log("info", msg, extra),',' warn: (msg, extra) => log("warn", msg, extra),',' error: (msg, extra) => log("error", msg, extra),'," };","};","","// \u2500\u2500 Request logging \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500","","const methodColor = (method: string): string => {"," switch (method) {",' case "GET": return green(bold(method.padEnd(7)));',' case "POST": return cyan(bold(method.padEnd(7)));',' case "PATCH": return yellow(bold(method.padEnd(7)));',' case "PUT": return yellow(bold(method.padEnd(7)));',' case "DELETE": return red(bold(method.padEnd(7)));'," default: return white(bold(method.padEnd(7)));"," }","};","","const statusColor = (status: number): string => {"," if (status < 300) return green(String(status));"," if (status < 400) return cyan(String(status));"," if (status < 500) return yellow(String(status));"," return red(String(status));","};","","export const formatRequest = ("," method: string,"," path: string,"," status: number,"," durationMs: number,"," ip: string,","): string => {",' const arrow = gray("\u2190");',' const dur = dim(durationMs.toFixed(2) + "ms");'," return (",' " " + arrow + " " +',' dim(timestamp()) + " " +',' methodColor(method) + " " +',' statusColor(status) + " " +',' path + " " +',' dur + " " +',' gray("ip=" + ip)'," );","};","","// \u2500\u2500 Startup banner \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500","","export { bold, cyan, dim, gray, green, magenta, blue, white, yellow, red };",""].join(`
|
|
182
|
+
`)},{path:"src/router.ts",content:["/**"," * Lightweight router with O(1) static route lookup."," */","","type Handler = (req: Request, params?: Record<string, string>) => Promise<Response> | Response;","","interface Route {"," method: string;"," path: string;"," handler: Handler;"," auth: boolean;"," description: string;","}","","export interface Router {"," add(method: string, path: string, handler: Handler, opts?: { auth?: boolean; description?: string }): void;"," match(method: string, path: string): { handler: Handler; auth: boolean } | null;"," routes(): ReadonlyArray<{ method: string; path: string; auth: boolean; description: string }>;","}","","export const createRouter = (): Router => {"," const table = new Map<string, Route>();"," const list: Route[] = [];",""," return {"," add(method, path, handler, opts = {}) {",' const key = method + " " + path;',' const route: Route = { method, path, handler, auth: opts.auth ?? false, description: opts.description ?? "" };'," table.set(key, route);"," list.push(route);"," },",""," match(method, path) {",' const route = table.get(method + " " + path);'," if (route) return { handler: route.handler, auth: route.auth };"," return null;"," },",""," routes() {"," return list.map(r => ({ method: r.method, path: r.path, auth: r.auth, description: r.description }));"," },"," };","};",""].join(`
|
|
183
|
+
`)},{path:"src/main.ts",content:['import { loadConfig } from "./config.js";','import { createDatabase } from "./database.js";','import { createLogger, bold, cyan, dim, gray, green, white, magenta, blue, yellow } from "./logger.js";','import { createRouter } from "./router.js";','import { createAuthService } from "./services/auth.service.js";','import { createUserService } from "./services/user.service.js";','import { createServer } from "./server.js";','import { createPasswordHasher } from "./utils/password.js";','import { createTokenService } from "./utils/token.js";',"","const startTime = performance.now();","","// \u2500\u2500 Config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500","const config = loadConfig();","const log = createLogger(config.logLevel);","","// \u2500\u2500 Database \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500","const db = createDatabase(config.databasePath, log);","","// \u2500\u2500 Services \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500","const passwordHasher = createPasswordHasher();","const tokenService = createTokenService(config.jwt.secret, config.jwt.expiresIn);","const authService = createAuthService(db, passwordHasher, tokenService);","const userService = createUserService(db, passwordHasher);","","// \u2500\u2500 Router \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500","const router = createRouter();","",'import { authHandlers } from "./handlers/auth.handler.js";','import { healthHandler } from "./handlers/health.handler.js";','import { userHandlers } from "./handlers/user.handler.js";',"","const auth = authHandlers(authService);","const health = healthHandler();","const users = userHandlers(userService);","","// Public",'router.add("GET", "/health", health.check, { description: "Health check" });','router.add("POST", "/api/v1/auth/register", auth.register, { description: "Register user" });','router.add("POST", "/api/v1/auth/login", auth.login, { description: "Login" });',"","// Protected",'router.add("POST", "/api/v1/auth/logout", auth.logout, { auth: true, description: "Logout" });','router.add("GET", "/api/v1/users/me", users.getProfile, { auth: true, description: "Get profile" });','router.add("PATCH", "/api/v1/users/me", users.updateProfile, { auth: true, description: "Update profile" });','router.add("DELETE", "/api/v1/users/me", users.deleteAccount, { auth: true, description: "Delete account" });',"","// \u2500\u2500 Server \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500","const server = createServer({ config, router, tokenService, log });","","// \u2500\u2500 Startup banner \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500","const bootMs = performance.now() - startTime;","",'const envBadge = config.nodeEnv === "production"',' ? "\\x1b[42m\\x1b[30m PRODUCTION \\x1b[0m"',' : "\\x1b[46m\\x1b[30m DEVELOPMENT \\x1b[0m";',"",'console.log("");','console.log(bold(cyan(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510")));','console.log(bold(cyan(" \u2502")) + " " + bold(cyan("\u2502")));',`console.log(bold(cyan(" \u2502")) + " " + bold(white("\u26A1 " + ${JSON.stringify(J)})) + " " + bold(cyan("\u2502")));`,'console.log(bold(cyan(" \u2502")) + " " + dim(gray("Built with onlyApi")) + " " + bold(cyan("\u2502")));','console.log(bold(cyan(" \u2502")) + " " + bold(cyan("\u2502")));','console.log(bold(cyan(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518")));','console.log("");','console.log(" " + envBadge + " " + dim("booted in") + " " + bold(green(bootMs.toFixed(0) + "ms")));','console.log("");','console.log(" " + bold(white("\u2192")) + " " + dim("Local:") + " " + bold(cyan("http://localhost:" + config.port)));','console.log(" " + bold(white("\u2192")) + " " + dim("Network:") + " " + bold(cyan("http://" + config.host + ":" + config.port)));','console.log(" " + bold(white("\u2192")) + " " + dim("SQLite:") + " " + dim(config.databasePath));','console.log("");',"","// Process info",'console.log(" " + gray("\u251C\u2500") + " " + dim("PID") + " " + white(String(process.pid)));','console.log(" " + gray("\u251C\u2500") + " " + dim("Runtime") + " " + magenta("Bun " + Bun.version));','console.log(" " + gray("\u251C\u2500") + " " + dim("TypeScript") + " " + blue("strict"));','console.log(" " + gray("\u251C\u2500") + " " + dim("Rate limit") + " " + white(config.rateLimitMax + " req/" + (config.rateLimitWindowMs / 1000) + "s"));','console.log(" " + gray("\u2514\u2500") + " " + dim("Log level") + " " + white(config.logLevel));','console.log("");',"","// Route table","const allRoutes = router.routes();",'console.log(" " + bold(white("Routes")) + " " + dim("(" + allRoutes.length + ")"));','console.log(" " + gray("\u2500".repeat(60)));',"for (const r of allRoutes) {",' const lock = r.auth ? yellow("\uD83D\uDD12") : " ";'," const mc = (() => {"," switch (r.method) {",' case "GET": return green(bold(r.method.padEnd(7)));',' case "POST": return cyan(bold(r.method.padEnd(7)));',' case "PATCH": return yellow(bold(r.method.padEnd(7)));',' case "DELETE": return "\\x1b[31m\\x1b[1m" + r.method.padEnd(7) + "\\x1b[0m";'," default: return white(bold(r.method.padEnd(7)));"," }"," })();",' console.log(" " + lock + " " + mc + " " + r.path.padEnd(30) + " " + dim(gray(r.description)));',"}",'console.log(" " + gray("\u2500".repeat(60)));','console.log("");','console.log(" " + dim("press Ctrl+C to stop"));','console.log("");',"","// \u2500\u2500 Graceful shutdown \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500","const shutdown = () => {",' log.info("Shutting down...");'," server.stop();"," db.close();"," process.exit(0);","};","",'process.on("SIGINT", shutdown);','process.on("SIGTERM", shutdown);',""].join(`
|
|
184
|
+
`)},{path:"src/config.ts",content:`import { z } from "zod";
|
|
224
185
|
|
|
225
186
|
const configSchema = z.object({
|
|
226
187
|
port: z.coerce.number().default(3000),
|
|
@@ -268,175 +229,9 @@ export const loadConfig = (): AppConfig => {
|
|
|
268
229
|
|
|
269
230
|
return result.data;
|
|
270
231
|
};
|
|
271
|
-
`},{path:"src/database.ts",content
|
|
272
|
-
import {
|
|
273
|
-
import {
|
|
274
|
-
|
|
275
|
-
export const createDatabase = (dbPath: string): Database => {
|
|
276
|
-
// Ensure data directory exists
|
|
277
|
-
const dir = dirname(dbPath);
|
|
278
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
279
|
-
|
|
280
|
-
const db = new Database(dbPath, { create: true });
|
|
281
|
-
|
|
282
|
-
// Performance settings
|
|
283
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
284
|
-
db.exec("PRAGMA synchronous = NORMAL");
|
|
285
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
286
|
-
|
|
287
|
-
// Run migrations
|
|
288
|
-
migrate(db);
|
|
289
|
-
|
|
290
|
-
return db;
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
const migrate = (db: Database): void => {
|
|
294
|
-
db.exec(\`
|
|
295
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
296
|
-
id TEXT PRIMARY KEY,
|
|
297
|
-
email TEXT NOT NULL UNIQUE,
|
|
298
|
-
password TEXT NOT NULL,
|
|
299
|
-
name TEXT NOT NULL DEFAULT '',
|
|
300
|
-
role TEXT NOT NULL DEFAULT 'user',
|
|
301
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
302
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
303
|
-
)
|
|
304
|
-
\`);
|
|
305
|
-
|
|
306
|
-
db.exec(\`
|
|
307
|
-
CREATE TABLE IF NOT EXISTS token_blacklist (
|
|
308
|
-
token_id TEXT PRIMARY KEY,
|
|
309
|
-
expires_at TEXT NOT NULL
|
|
310
|
-
)
|
|
311
|
-
\`);
|
|
312
|
-
|
|
313
|
-
console.log(" \u2713 Database ready");
|
|
314
|
-
};
|
|
315
|
-
`},{path:"src/server.ts",content:`import type { AuthService } from "./services/auth.service.js";
|
|
316
|
-
import type { UserService } from "./services/user.service.js";
|
|
317
|
-
import type { TokenService } from "./utils/token.js";
|
|
318
|
-
import type { AppConfig } from "./config.js";
|
|
319
|
-
import { authHandlers } from "./handlers/auth.handler.js";
|
|
320
|
-
import { healthHandler } from "./handlers/health.handler.js";
|
|
321
|
-
import { userHandlers } from "./handlers/user.handler.js";
|
|
322
|
-
import { authenticate } from "./middleware/auth.js";
|
|
323
|
-
import { json } from "./utils/response.js";
|
|
324
|
-
|
|
325
|
-
interface ServerDeps {
|
|
326
|
-
config: AppConfig;
|
|
327
|
-
authService: AuthService;
|
|
328
|
-
userService: UserService;
|
|
329
|
-
tokenService: TokenService;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
export const createServer = (deps: ServerDeps) => {
|
|
333
|
-
const { config, tokenService } = deps;
|
|
334
|
-
const auth = authHandlers(deps.authService);
|
|
335
|
-
const health = healthHandler();
|
|
336
|
-
const users = userHandlers(deps.userService);
|
|
337
|
-
|
|
338
|
-
// \u2500\u2500 Rate limiting (in-memory) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
339
|
-
const hits = new Map<string, { count: number; resetAt: number }>();
|
|
340
|
-
|
|
341
|
-
const isRateLimited = (ip: string): boolean => {
|
|
342
|
-
const now = Date.now();
|
|
343
|
-
const entry = hits.get(ip);
|
|
344
|
-
|
|
345
|
-
if (!entry || now > entry.resetAt) {
|
|
346
|
-
hits.set(ip, { count: 1, resetAt: now + config.rateLimitWindowMs });
|
|
347
|
-
return false;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
entry.count++;
|
|
351
|
-
return entry.count > config.rateLimitMax;
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
// \u2500\u2500 CORS headers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
355
|
-
const corsHeaders = (origin: string | null): Record<string, string> => {
|
|
356
|
-
const allowed = config.corsOrigins === "*" || (origin && config.corsOrigins.includes(origin));
|
|
357
|
-
return {
|
|
358
|
-
"Access-Control-Allow-Origin": allowed ? (origin ?? "*") : "",
|
|
359
|
-
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
360
|
-
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
361
|
-
"Access-Control-Max-Age": "86400",
|
|
362
|
-
};
|
|
363
|
-
};
|
|
364
|
-
|
|
365
|
-
// \u2500\u2500 Request handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
366
|
-
const server = Bun.serve({
|
|
367
|
-
port: config.port,
|
|
368
|
-
hostname: config.host,
|
|
369
|
-
|
|
370
|
-
async fetch(req) {
|
|
371
|
-
const url = new URL(req.url);
|
|
372
|
-
const { pathname } = url;
|
|
373
|
-
const method = req.method;
|
|
374
|
-
const origin = req.headers.get("origin");
|
|
375
|
-
|
|
376
|
-
// CORS preflight
|
|
377
|
-
if (method === "OPTIONS") {
|
|
378
|
-
return new Response(null, { status: 204, headers: corsHeaders(origin) });
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Rate limiting
|
|
382
|
-
const ip = server.requestIP(req)?.address ?? "unknown";
|
|
383
|
-
if (isRateLimited(ip)) {
|
|
384
|
-
return json({ error: "Too many requests" }, 429, corsHeaders(origin));
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
try {
|
|
388
|
-
let response: Response;
|
|
389
|
-
|
|
390
|
-
// \u2500\u2500 Public routes \u2500\u2500
|
|
391
|
-
if (pathname === "/health" && method === "GET") {
|
|
392
|
-
response = health.check();
|
|
393
|
-
} else if (pathname === "/api/v1/auth/register" && method === "POST") {
|
|
394
|
-
response = await auth.register(req);
|
|
395
|
-
} else if (pathname === "/api/v1/auth/login" && method === "POST") {
|
|
396
|
-
response = await auth.login(req);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// \u2500\u2500 Protected routes \u2500\u2500
|
|
400
|
-
else if (pathname === "/api/v1/auth/logout" && method === "POST") {
|
|
401
|
-
const authResult = await authenticate(req, tokenService);
|
|
402
|
-
if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
|
|
403
|
-
response = await auth.logout(authResult.userId, req);
|
|
404
|
-
} else if (pathname === "/api/v1/users/me" && method === "GET") {
|
|
405
|
-
const authResult = await authenticate(req, tokenService);
|
|
406
|
-
if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
|
|
407
|
-
response = await users.getProfile(authResult.userId);
|
|
408
|
-
} else if (pathname === "/api/v1/users/me" && method === "PATCH") {
|
|
409
|
-
const authResult = await authenticate(req, tokenService);
|
|
410
|
-
if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
|
|
411
|
-
response = await users.updateProfile(authResult.userId, req);
|
|
412
|
-
} else if (pathname === "/api/v1/users/me" && method === "DELETE") {
|
|
413
|
-
const authResult = await authenticate(req, tokenService);
|
|
414
|
-
if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
|
|
415
|
-
response = await users.deleteAccount(authResult.userId);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// \u2500\u2500 404 \u2500\u2500
|
|
419
|
-
else {
|
|
420
|
-
response = json({ error: "Not found" }, 404);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Add CORS headers to every response
|
|
424
|
-
const headers = corsHeaders(origin);
|
|
425
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
426
|
-
response.headers.set(k, v);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
return response;
|
|
430
|
-
} catch (err) {
|
|
431
|
-
console.error("Unhandled error:", err);
|
|
432
|
-
return json({ error: "Internal server error" }, 500, corsHeaders(origin));
|
|
433
|
-
}
|
|
434
|
-
},
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
return server;
|
|
438
|
-
};
|
|
439
|
-
`},{path:"src/handlers/health.handler.ts",content:`import { json } from "../utils/response.js";
|
|
232
|
+
`},{path:"src/database.ts",content:['import { Database } from "bun:sqlite";','import { existsSync, mkdirSync } from "node:fs";','import { dirname } from "node:path";','import type { Logger } from "./logger.js";',"","export const createDatabase = (dbPath: string, log: Logger): Database => {"," const dir = dirname(dbPath);"," if (!existsSync(dir)) mkdirSync(dir, { recursive: true });",""," const db = new Database(dbPath, { create: true });","",' db.exec("PRAGMA journal_mode = WAL");',' db.exec("PRAGMA synchronous = NORMAL");',' db.exec("PRAGMA foreign_keys = ON");',""," migrate(db, log, dbPath);"," return db;","};","","const migrate = (db: Database, log: Logger, dbPath: string): void => {"," db.exec(`"," CREATE TABLE IF NOT EXISTS users ("," id TEXT PRIMARY KEY,"," email TEXT NOT NULL UNIQUE,"," password TEXT NOT NULL,"," name TEXT NOT NULL DEFAULT '',"," role TEXT NOT NULL DEFAULT 'user',"," created_at TEXT NOT NULL DEFAULT (datetime('now')),"," updated_at TEXT NOT NULL DEFAULT (datetime('now'))"," )"," `);",""," db.exec(`"," CREATE TABLE IF NOT EXISTS token_blacklist ("," token_id TEXT PRIMARY KEY,"," expires_at TEXT NOT NULL"," )"," `);","",' log.info("SQLite database ready", { path: dbPath });',"};",""].join(`
|
|
233
|
+
`)},{path:"src/server.ts",content:['import type { AppConfig } from "./config.js";','import type { Logger } from "./logger.js";','import { formatRequest } from "./logger.js";','import type { Router } from "./router.js";','import type { TokenService } from "./utils/token.js";','import { authenticate } from "./middleware/auth.js";','import { json } from "./utils/response.js";',"","interface ServerDeps {"," config: AppConfig;"," router: Router;"," tokenService: TokenService;"," log: Logger;","}","","export const createServer = (deps: ServerDeps) => {"," const { config, router, tokenService, log } = deps;",""," // \u2500\u2500 Rate limiting (in-memory) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"," const hits = new Map<string, { count: number; resetAt: number }>();",""," const isRateLimited = (ip: string): boolean => {"," const now = Date.now();"," const entry = hits.get(ip);"," if (!entry || now > entry.resetAt) {"," hits.set(ip, { count: 1, resetAt: now + config.rateLimitWindowMs });"," return false;"," }"," entry.count++;"," return entry.count > config.rateLimitMax;"," };",""," // \u2500\u2500 CORS headers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"," const corsHeaders = (origin: string | null): Record<string, string> => {",' const allowed = config.corsOrigins === "*" || (origin && config.corsOrigins.includes(origin));'," return {",' "Access-Control-Allow-Origin": allowed ? (origin ?? "*") : "",',' "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",',' "Access-Control-Allow-Headers": "Content-Type, Authorization",',' "Access-Control-Max-Age": "86400",'," };"," };",""," // \u2500\u2500 Server \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"," const server = Bun.serve({"," port: config.port,"," hostname: config.host,",""," async fetch(req) {"," const start = performance.now();"," const url = new URL(req.url);"," const { pathname } = url;"," const method = req.method;",' const origin = req.headers.get("origin");',' const ip = server.requestIP(req)?.address ?? "unknown";',""," // CORS preflight",' if (method === "OPTIONS") {'," return new Response(null, { status: 204, headers: corsHeaders(origin) });"," }",""," // Rate limiting"," if (isRateLimited(ip)) {"," const dur = performance.now() - start;"," console.log(formatRequest(method, pathname, 429, dur, ip));",' return json({ error: "Too many requests" }, 429, corsHeaders(origin));'," }",""," try {"," const match = router.match(method, pathname);",""," if (!match) {"," const dur = performance.now() - start;"," console.log(formatRequest(method, pathname, 404, dur, ip));",' return json({ error: "Not found" }, 404, corsHeaders(origin));'," }",""," // Auth check"," if (match.auth) {"," const authResult = await authenticate(req, tokenService);"," if (!authResult.ok) {"," const dur = performance.now() - start;"," console.log(formatRequest(method, pathname, 401, dur, ip));"," return json({ error: authResult.error }, 401, corsHeaders(origin));"," }"," // Inject userId into request via header",' req.headers.set("x-user-id", authResult.userId);'," }",""," const response = await match.handler(req);",""," // Add CORS headers"," const headers = corsHeaders(origin);"," for (const [k, v] of Object.entries(headers)) {"," response.headers.set(k, v);"," }",""," const dur = performance.now() - start;"," console.log(formatRequest(method, pathname, response.status, dur, ip));"," return response;"," } catch (err) {",' log.error("Unhandled error", { path: pathname, error: String(err) });'," const dur = performance.now() - start;"," console.log(formatRequest(method, pathname, 500, dur, ip));",' return json({ error: "Internal server error" }, 500, corsHeaders(origin));'," }"," },"," });",""," return server;","};",""].join(`
|
|
234
|
+
`)},{path:"src/handlers/health.handler.ts",content:`import { json } from "../utils/response.js";
|
|
440
235
|
|
|
441
236
|
export const healthHandler = () => ({
|
|
442
237
|
check: (): Response =>
|
|
@@ -478,7 +273,7 @@ export const authHandlers = (authService: AuthService) => ({
|
|
|
478
273
|
return json({ data: result.data });
|
|
479
274
|
},
|
|
480
275
|
|
|
481
|
-
logout: async (
|
|
276
|
+
logout: async (req: Request): Promise<Response> => {
|
|
482
277
|
const header = req.headers.get("authorization") ?? "";
|
|
483
278
|
const token = header.replace("Bearer ", "");
|
|
484
279
|
|
|
@@ -486,43 +281,8 @@ export const authHandlers = (authService: AuthService) => ({
|
|
|
486
281
|
return json({ data: { message: "Logged out" } });
|
|
487
282
|
},
|
|
488
283
|
});
|
|
489
|
-
`},{path:"src/handlers/user.handler.ts",content
|
|
490
|
-
import {
|
|
491
|
-
import { z } from "zod";
|
|
492
|
-
|
|
493
|
-
const updateSchema = z.object({
|
|
494
|
-
name: z.string().min(1).optional(),
|
|
495
|
-
email: z.string().email().optional(),
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
export const userHandlers = (userService: UserService) => ({
|
|
499
|
-
getProfile: async (userId: string): Promise<Response> => {
|
|
500
|
-
const user = userService.findById(userId);
|
|
501
|
-
if (!user) return json({ error: "User not found" }, 404);
|
|
502
|
-
|
|
503
|
-
const { password: _, ...profile } = user;
|
|
504
|
-
return json({ data: profile });
|
|
505
|
-
},
|
|
506
|
-
|
|
507
|
-
updateProfile: async (userId: string, req: Request): Promise<Response> => {
|
|
508
|
-
const body = updateSchema.safeParse(await req.json());
|
|
509
|
-
if (!body.success) return json({ error: body.error.issues }, 400);
|
|
510
|
-
|
|
511
|
-
const updated = userService.update(userId, body.data);
|
|
512
|
-
if (!updated) return json({ error: "User not found" }, 404);
|
|
513
|
-
|
|
514
|
-
const { password: _, ...profile } = updated;
|
|
515
|
-
return json({ data: profile });
|
|
516
|
-
},
|
|
517
|
-
|
|
518
|
-
deleteAccount: async (userId: string): Promise<Response> => {
|
|
519
|
-
const deleted = userService.delete(userId);
|
|
520
|
-
if (!deleted) return json({ error: "User not found" }, 404);
|
|
521
|
-
|
|
522
|
-
return json({ data: { message: "Account deleted" } });
|
|
523
|
-
},
|
|
524
|
-
});
|
|
525
|
-
`},{path:"src/middleware/auth.ts",content:`import type { TokenService } from "../utils/token.js";
|
|
284
|
+
`},{path:"src/handlers/user.handler.ts",content:['import type { UserService } from "../services/user.service.js";','import { json } from "../utils/response.js";','import { z } from "zod";',"","const updateSchema = z.object({"," name: z.string().min(1).optional(),"," email: z.string().email().optional(),","});","","export const userHandlers = (userService: UserService) => ({"," getProfile: async (req: Request): Promise<Response> => {",' const userId = req.headers.get("x-user-id") ?? "";'," const user = userService.findById(userId);",' if (!user) return json({ error: "User not found" }, 404);',""," const { password: _, ...profile } = user;"," return json({ data: profile });"," },",""," updateProfile: async (req: Request): Promise<Response> => {",' const userId = req.headers.get("x-user-id") ?? "";'," const body = updateSchema.safeParse(await req.json());"," if (!body.success) return json({ error: body.error.issues }, 400);",""," const updated = userService.update(userId, body.data);",' if (!updated) return json({ error: "User not found" }, 404);',""," const { password: _, ...profile } = updated;"," return json({ data: profile });"," },",""," deleteAccount: async (req: Request): Promise<Response> => {",' const userId = req.headers.get("x-user-id") ?? "";'," const deleted = userService.delete(userId);",' if (!deleted) return json({ error: "User not found" }, 404);',"",' return json({ data: { message: "Account deleted" } });'," },","});",""].join(`
|
|
285
|
+
`)},{path:"src/middleware/auth.ts",content:`import type { TokenService } from "../utils/token.js";
|
|
526
286
|
|
|
527
287
|
type AuthResult =
|
|
528
288
|
| { ok: true; userId: string }
|
|
@@ -574,7 +334,6 @@ export const createAuthService = (
|
|
|
574
334
|
tokenService: TokenService,
|
|
575
335
|
): AuthService => ({
|
|
576
336
|
async register(input) {
|
|
577
|
-
// Check if user exists
|
|
578
337
|
const existing = db.query("SELECT id FROM users WHERE email = ?").get(input.email);
|
|
579
338
|
if (existing) return { ok: false, error: "Email already registered" };
|
|
580
339
|
|
|
@@ -582,10 +341,7 @@ export const createAuthService = (
|
|
|
582
341
|
const hashedPassword = await passwordHasher.hash(input.password);
|
|
583
342
|
|
|
584
343
|
db.query("INSERT INTO users (id, email, password, name) VALUES (?, ?, ?, ?)").run(
|
|
585
|
-
id,
|
|
586
|
-
input.email,
|
|
587
|
-
hashedPassword,
|
|
588
|
-
input.name ?? "",
|
|
344
|
+
id, input.email, hashedPassword, input.name ?? "",
|
|
589
345
|
);
|
|
590
346
|
|
|
591
347
|
const token = tokenService.sign({ sub: id, email: input.email, role: "user" });
|
|
@@ -616,58 +372,8 @@ export const createAuthService = (
|
|
|
616
372
|
}
|
|
617
373
|
},
|
|
618
374
|
});
|
|
619
|
-
`},{path:"src/services/user.service.ts",content
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
interface User {
|
|
623
|
-
id: string;
|
|
624
|
-
email: string;
|
|
625
|
-
password: string;
|
|
626
|
-
name: string;
|
|
627
|
-
role: string;
|
|
628
|
-
created_at: string;
|
|
629
|
-
updated_at: string;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
export interface UserService {
|
|
633
|
-
findById(id: string): User | null;
|
|
634
|
-
update(id: string, data: { name?: string; email?: string }): User | null;
|
|
635
|
-
delete(id: string): boolean;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
export const createUserService = (db: Database, _passwordHasher: PasswordHasher): UserService => ({
|
|
639
|
-
findById(id) {
|
|
640
|
-
return db.query("SELECT * FROM users WHERE id = ?").get(id) as User | null;
|
|
641
|
-
},
|
|
642
|
-
|
|
643
|
-
update(id, data) {
|
|
644
|
-
const fields: string[] = [];
|
|
645
|
-
const values: unknown[] = [];
|
|
646
|
-
|
|
647
|
-
if (data.name !== undefined) {
|
|
648
|
-
fields.push("name = ?");
|
|
649
|
-
values.push(data.name);
|
|
650
|
-
}
|
|
651
|
-
if (data.email !== undefined) {
|
|
652
|
-
fields.push("email = ?");
|
|
653
|
-
values.push(data.email);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
if (fields.length === 0) return this.findById(id);
|
|
657
|
-
|
|
658
|
-
fields.push("updated_at = datetime('now')");
|
|
659
|
-
values.push(id);
|
|
660
|
-
|
|
661
|
-
db.query(\`UPDATE users SET \${fields.join(", ")} WHERE id = ?\`).run(...values);
|
|
662
|
-
return this.findById(id);
|
|
663
|
-
},
|
|
664
|
-
|
|
665
|
-
delete(id) {
|
|
666
|
-
const result = db.query("DELETE FROM users WHERE id = ?").run(id);
|
|
667
|
-
return result.changes > 0;
|
|
668
|
-
},
|
|
669
|
-
});
|
|
670
|
-
`},{path:"src/utils/password.ts",content:`/**
|
|
375
|
+
`},{path:"src/services/user.service.ts",content:['import type { Database } from "bun:sqlite";','import type { PasswordHasher } from "../utils/password.js";',"","interface User {"," id: string;"," email: string;"," password: string;"," name: string;"," role: string;"," created_at: string;"," updated_at: string;","}","","export interface UserService {"," findById(id: string): User | null;"," update(id: string, data: { name?: string; email?: string }): User | null;"," delete(id: string): boolean;","}","","export const createUserService = (db: Database, _passwordHasher: PasswordHasher): UserService => ({"," findById(id) {",' return db.query("SELECT * FROM users WHERE id = ?").get(id) as User | null;'," },",""," update(id, data) {"," const fields: string[] = [];"," const values: unknown[] = [];",""," if (data.name !== undefined) {",' fields.push("name = ?");'," values.push(data.name);"," }"," if (data.email !== undefined) {",' fields.push("email = ?");'," values.push(data.email);"," }",""," if (fields.length === 0) return this.findById(id);","",` fields.push("updated_at = datetime('now')");`," values.push(id);","",' db.query("UPDATE users SET " + fields.join(", ") + " WHERE id = ?").run(...values);'," return this.findById(id);"," },",""," delete(id) {",' const result = db.query("DELETE FROM users WHERE id = ?").run(id);'," return result.changes > 0;"," },","});",""].join(`
|
|
376
|
+
`)},{path:"src/utils/password.ts",content:`/**
|
|
671
377
|
* Password hashing using Bun's built-in Argon2id.
|
|
672
378
|
*/
|
|
673
379
|
export interface PasswordHasher {
|
|
@@ -704,7 +410,6 @@ export const json = (
|
|
|
704
410
|
|
|
705
411
|
describe("Health check", () => {
|
|
706
412
|
it("should return ok", async () => {
|
|
707
|
-
// Start server for test
|
|
708
413
|
const response = await fetch("http://localhost:3000/health");
|
|
709
414
|
expect(response.status).toBe(200);
|
|
710
415
|
|
|
@@ -712,5 +417,5 @@ describe("Health check", () => {
|
|
|
712
417
|
expect(data.status).toBe("ok");
|
|
713
418
|
});
|
|
714
419
|
});
|
|
715
|
-
`}];var DJ=/^[a-zA-Z0-9_-]+$/,xJ=(J)=>{if(!J)return"Project name is required.";if(!DJ.test(J))return"Project name can only contain letters, numbers, hyphens, and underscores.";if(J.length>214)return"Project name is too long (max 214 chars).";return null},c=async(J,$=process.cwd())=>{let K=Bun.spawn(J,{cwd:$,stdout:"pipe",stderr:"pipe"}),[W,G]=await Promise.all([new Response(K.stdout).text(),new Response(K.stderr).text()]),z=await K.exited;return{stdout:W.trim(),stderr:G.trim(),exitCode:z}},PJ=async(J)=>{try{let{exitCode:$}=await c(["which",J]);return $===0}catch{return!1}},YJ=async(J,$)=>{let K=performance.now();Z(),X(g($)),Z();let W=J[0]??"",G=J.includes("--cwd")||J.includes(".");if(!W&&!G)W=await zJ("Project name","my-api");if(G)W=".";if(W!=="."){let U=xJ(W);if(U)v(U),process.exit(1)}let z=W==="."?process.cwd():NJ(process.cwd(),W);if(W!=="."&&t(z)){if((await Array.fromAsync(new Bun.Glob("*").scan({cwd:z}))).length>0){if(!await m(`Directory ${H(E(W))} already exists and is not empty. Continue?`,!1))C("Aborted."),process.exit(0)}}if(V("Creating project"),W!==".")qJ(z,{recursive:!0}),b(`Created directory ${H(q(W))}`);let w=y("Generating project files...");w.start();let D=W==="."?"my-api":W,Y=KJ(D);for(let U of Y){let T=QJ(z,U.path),O=VJ(T);if(!t(O))qJ(O,{recursive:!0});CJ(T,U.content,"utf-8")}w.stop(`Generated ${H(q(String(Y.length)))} files`);let L=QJ(z,".env.example"),S=QJ(z,".env");if(t(L)&&!t(S))try{let U=await Bun.file(L).text(),T=HJ(64);U=U.replace("change-me-to-a-64-char-random-string",T),await Bun.write(S,U),b(`Generated ${H(q(".env"))} with secure JWT_SECRET`)}catch{k("Could not generate .env \u2014 copy .env.example manually")}V("Installing dependencies");let _=y("Running bun install...");_.start();let{exitCode:h,stderr:o}=await c(["bun","install"],z);if(h!==0)_.stop(),v("Failed to install dependencies:"),X(` ${Q(o)}`),Z(),C(`Run ${H(q("bun install"))} manually in the project directory.`);else _.stop("Dependencies installed");if(await PJ("git"))await c(["git","init"],z),await c(["git","add","-A"],z),await c(["git","commit","-m","Initial commit from onlyApi CLI","--no-verify"],z),b("Initialized git repository");let a=performance.now()-K;Z(),X(` ${I.rocket} ${H(R("Project created successfully!"))} ${Q(`(${i(a)})`)}`),Z(),V("Project structure"),Z();let s=[`${H(q(D))}/`,"\u251C\u2500\u2500 src/",`\u2502 \u251C\u2500\u2500 main.ts ${Q("\u2190 entry point")}`,`\u2502 \u251C\u2500\u2500 config.ts ${Q("\u2190 env config")}`,`\u2502 \u251C\u2500\u2500 database.ts ${Q("\u2190 SQLite + migrations")}`,`\u2502 \u251C\u2500\u2500 server.ts ${Q("\u2190 HTTP server +
|
|
716
|
-
`);if(U)b(`${f?`${Q("[dry-run]")} `:""}Updated dependencies in ${H(q("package.json"))}`)}catch{k("Could not merge package.json dependencies")}if(N(_)){let{rmSync:B}=await import("fs");B(_,{recursive:!0,force:!0})}if(!f&&h>0){V("Installing dependencies");let B=y("Running bun install...");B.start();let{exitCode:F}=await d(["bun","install"],W);if(F!==0)B.stop(),k("bun install failed \u2014 run it manually.");else B.stop("Dependencies installed")}if(L&&!f&&h>0){if(await m("Create a git commit for this upgrade?")){let F=Y?`chore: upgrade onlyApi to v${Y}`:"chore: upgrade onlyApi to latest";await d(["git","add","-A"],W),await d(["git","commit","-m",F,"--no-verify"],W),b("Created upgrade commit")}}let s=performance.now()-K;if(Z(),h>0)X(` ${I.rocket} ${H(R("Upgrade complete!"))} ${Q(`(${i(s)})`)}`),Z(),e([["Files updated",String(h)],["Files unchanged",String(o)]]);else if(f)X(` ${I.info} ${H(q("Dry run complete"))} \u2014 no files were modified.`);else n("All files are already up to date!");if(Z(),h>0)X(` ${Q("Note: The following files are NOT auto-upgraded (your custom code):")}`),X(` ${Q(" - src/application/ (your services & DTOs)")}`),X(` ${Q(" - src/core/entities/ (your domain entities)")}`),X(` ${Q(" - src/core/ports/ (your port interfaces)")}`),X(` ${Q(" - src/presentation/handlers/ (your route handlers)")}`),X(` ${Q(" - src/presentation/routes/ (your routes)")}`),X(` ${Q(" - src/main.ts (your bootstrap)")}`),Z(),X(` ${Q("Review the")} ${q("CHANGELOG.md")} ${Q("for breaking changes.")}`),Z()};var l="1.
|
|
420
|
+
`}];var DJ=/^[a-zA-Z0-9_-]+$/,xJ=(J)=>{if(!J)return"Project name is required.";if(!DJ.test(J))return"Project name can only contain letters, numbers, hyphens, and underscores.";if(J.length>214)return"Project name is too long (max 214 chars).";return null},c=async(J,$=process.cwd())=>{let K=Bun.spawn(J,{cwd:$,stdout:"pipe",stderr:"pipe"}),[W,G]=await Promise.all([new Response(K.stdout).text(),new Response(K.stderr).text()]),z=await K.exited;return{stdout:W.trim(),stderr:G.trim(),exitCode:z}},PJ=async(J)=>{try{let{exitCode:$}=await c(["which",J]);return $===0}catch{return!1}},YJ=async(J,$)=>{let K=performance.now();Z(),X(g($)),Z();let W=J[0]??"",G=J.includes("--cwd")||J.includes(".");if(!W&&!G)W=await zJ("Project name","my-api");if(G)W=".";if(W!=="."){let U=xJ(W);if(U)v(U),process.exit(1)}let z=W==="."?process.cwd():NJ(process.cwd(),W);if(W!=="."&&t(z)){if((await Array.fromAsync(new Bun.Glob("*").scan({cwd:z}))).length>0){if(!await m(`Directory ${H(E(W))} already exists and is not empty. Continue?`,!1))C("Aborted."),process.exit(0)}}if(V("Creating project"),W!==".")qJ(z,{recursive:!0}),b(`Created directory ${H(q(W))}`);let w=y("Generating project files...");w.start();let D=W==="."?"my-api":W,Y=KJ(D);for(let U of Y){let T=QJ(z,U.path),O=VJ(T);if(!t(O))qJ(O,{recursive:!0});CJ(T,U.content,"utf-8")}w.stop(`Generated ${H(q(String(Y.length)))} files`);let L=QJ(z,".env.example"),S=QJ(z,".env");if(t(L)&&!t(S))try{let U=await Bun.file(L).text(),T=HJ(64);U=U.replace("change-me-to-a-64-char-random-string",T),await Bun.write(S,U),b(`Generated ${H(q(".env"))} with secure JWT_SECRET`)}catch{k("Could not generate .env \u2014 copy .env.example manually")}V("Installing dependencies");let _=y("Running bun install...");_.start();let{exitCode:h,stderr:o}=await c(["bun","install"],z);if(h!==0)_.stop(),v("Failed to install dependencies:"),X(` ${Q(o)}`),Z(),C(`Run ${H(q("bun install"))} manually in the project directory.`);else _.stop("Dependencies installed");if(await PJ("git"))await c(["git","init"],z),await c(["git","add","-A"],z),await c(["git","commit","-m","Initial commit from onlyApi CLI","--no-verify"],z),b("Initialized git repository");let a=performance.now()-K;Z(),X(` ${I.rocket} ${H(R("Project created successfully!"))} ${Q(`(${i(a)})`)}`),Z(),V("Project structure"),Z();let s=[`${H(q(D))}/`,"\u251C\u2500\u2500 src/",`\u2502 \u251C\u2500\u2500 main.ts ${Q("\u2190 entry point")}`,`\u2502 \u251C\u2500\u2500 config.ts ${Q("\u2190 env config")}`,`\u2502 \u251C\u2500\u2500 database.ts ${Q("\u2190 SQLite + migrations")}`,`\u2502 \u251C\u2500\u2500 logger.ts ${Q("\u2190 colored structured logger")}`,`\u2502 \u251C\u2500\u2500 router.ts ${Q("\u2190 route table + matching")}`,`\u2502 \u251C\u2500\u2500 server.ts ${Q("\u2190 HTTP server + middleware")}`,"\u2502 \u251C\u2500\u2500 handlers/",`\u2502 \u2502 \u251C\u2500\u2500 auth.handler.ts ${Q("\u2190 register/login/logout")}`,"\u2502 \u2502 \u251C\u2500\u2500 health.handler.ts",`\u2502 \u2502 \u2514\u2500\u2500 user.handler.ts ${Q("\u2190 profile CRUD")}`,"\u2502 \u251C\u2500\u2500 middleware/",`\u2502 \u2502 \u2514\u2500\u2500 auth.ts ${Q("\u2190 JWT guard")}`,"\u2502 \u251C\u2500\u2500 services/","\u2502 \u2502 \u251C\u2500\u2500 auth.service.ts","\u2502 \u2502 \u2514\u2500\u2500 user.service.ts","\u2502 \u2514\u2500\u2500 utils/",`\u2502 \u251C\u2500\u2500 password.ts ${Q("\u2190 Argon2id")}`,`\u2502 \u251C\u2500\u2500 token.ts ${Q("\u2190 JWT sign/verify")}`,"\u2502 \u2514\u2500\u2500 response.ts","\u251C\u2500\u2500 tests/","\u251C\u2500\u2500 Dockerfile",`\u251C\u2500\u2500 .env ${Q("\u2190 auto-generated")}`,"\u2514\u2500\u2500 package.json"];for(let U of s)X(` ${U}`);Z(),V("Next steps"),Z();let B=W!=="."?`cd ${W}`:null,F=[...B?[B]:[],"bun run dev # Start dev server (hot-reload)","bun test # Run tests","bun run check # Type-check"];for(let U of F)X(` ${Q("$")} ${H(q(U))}`);Z(),V("API endpoints"),Z(),X(` ${Q("GET")} /health ${Q("\u2190 health check")}`),X(` ${Q("POST")} /api/v1/auth/register ${Q("\u2190 create account")}`),X(` ${Q("POST")} /api/v1/auth/login ${Q("\u2190 get JWT token")}`),X(` ${Q("POST")} /api/v1/auth/logout ${Q("\u2190 revoke token")} ${Q("\uD83D\uDD12")}`),X(` ${Q("GET")} /api/v1/users/me ${Q("\u2190 get profile")} ${Q("\uD83D\uDD12")}`),X(` ${Q("PATCH")} /api/v1/users/me ${Q("\u2190 update profile")} ${Q("\uD83D\uDD12")}`),X(` ${Q("DELETE")} /api/v1/users/me ${Q("\u2190 delete account")} ${Q("\uD83D\uDD12")}`),Z(),X(` ${Q("Docs:")} ${q("https://github.com/lysari/onlyapi#readme")}`),X(` ${Q("Issues:")} ${q("https://github.com/lysari/onlyapi/issues")}`),Z(),X(` ${Q("Happy hacking!")} ${I.bolt}`),Z()};import{existsSync as N}from"fs";import{join as P,resolve as wJ}from"path";var BJ="https://api.github.com/repos/lysari/onlyapi",SJ=(J)=>`https://github.com/lysari/onlyapi/archive/refs/tags/${J}.tar.gz`,GJ="https://github.com/lysari/onlyapi/archive/refs/heads/main.tar.gz",hJ="https://registry.npmjs.org/only-api",fJ=["src/core/errors/app-error.ts","src/core/types/brand.ts","src/core/types/result.ts","src/infrastructure/logging/logger.ts","src/infrastructure/security/password-hasher.ts","src/infrastructure/security/token-service.ts","src/presentation/middleware/cors.ts","src/presentation/middleware/rate-limit.ts","src/presentation/middleware/security-headers.ts","src/presentation/server.ts","src/presentation/context.ts","src/shared/cli.ts","src/shared/container.ts","src/shared/utils/id.ts","src/shared/utils/timing-safe.ts","src/shared/log-format.ts","src/cluster.ts","tsconfig.json","biome.json"],d=async(J,$=process.cwd())=>{let K=Bun.spawn(J,{cwd:$,stdout:"pipe",stderr:"pipe"}),[W,G]=await Promise.all([new Response(K.stdout).text(),new Response(K.stderr).text()]),z=await K.exited;return{stdout:W.trim(),stderr:G.trim(),exitCode:z}},UJ=(J)=>{let $=J.replace(/^v/,"").split(".").map(Number);return[$[0]??0,$[1]??0,$[2]??0]},FJ=(J,$)=>{let[K,W,G]=UJ(J),[z,w,D]=UJ($);if(K!==z)return K>z;if(W!==w)return W>w;return G>D},jJ=async()=>{try{let J=await fetch(`${BJ}/releases/latest`,{headers:{Accept:"application/vnd.github.v3+json"}});if(J.ok)return(await J.json()).tag_name.replace(/^v/,"")}catch{}try{let J=await fetch(`${BJ}/tags?per_page=1`,{headers:{Accept:"application/vnd.github.v3+json"}});if(J.ok){let $=await J.json();if($.length>0){let K=$[0];return K?K.name.replace(/^v/,""):null}}}catch{}try{let J=await fetch(hJ);if(J.ok)return(await J.json())["dist-tags"].latest}catch{}return null},TJ=async(J,$)=>{let K=performance.now(),W=wJ(process.cwd());Z(),X(g($)),Z();let G=P(W,"package.json");if(!N(G))v("No package.json found in current directory."),C("Run this command from the root of your onlyApi project."),process.exit(1);let z;try{z=JSON.parse(await Bun.file(G).text()).version??"0.0.0"}catch{v("Could not read package.json."),process.exit(1)}if(!(N(P(W,"src/main.ts"))&&N(P(W,"src/core"))&&N(P(W,"src/presentation"))))v("This doesn't appear to be an onlyApi project."),C("Expected to find src/main.ts, src/core/, and src/presentation/"),process.exit(1);V("Checking for updates");let D=y("Fetching latest version...");D.start();let Y=await jJ();if(!Y){if(D.stop(),k("Could not determine the latest version."),C("This may be due to network issues or API rate limits."),Z(),!(J.includes("--force")||J.includes("-f"))){if(!await m("Continue with upgrade from main branch?",!1))C("Aborted."),process.exit(0)}}else{if(D.stop("Version check complete"),Z(),e([["Current version",z],["Latest version",Y]]),Z(),!FJ(Y,z)&&!J.includes("--force")&&!J.includes("-f"))n("You're already on the latest version!"),Z(),process.exit(0);if(FJ(Y,z))C(`Update available: ${H(j(z))} ${Q("\u2192")} ${H(R(Y))}`);else C(`Re-applying latest version ${Q("(--force)")}`)}let L=N(P(W,".git"));if(L){let{stdout:B}=await d(["git","status","--porcelain"],W);if(B){if(Z(),k("You have uncommitted changes."),!await m("Continue anyway?",!1))C("Commit your changes first, then retry."),process.exit(0)}}V("Downloading update");let S=y("Downloading latest source...");S.start();let _=P(W,".onlyapi-upgrade-tmp");try{if(N(_)){let{rmSync:LJ}=await import("fs");LJ(_,{recursive:!0,force:!0})}let{mkdirSync:B}=await import("fs");B(_,{recursive:!0});let F=Y?SJ(`v${Y}`):GJ,U=await fetch(F),T=U;if(!U.ok&&Y)S.update("Tag not found, trying main branch..."),T=await fetch(GJ);if(!T.ok)throw Error(`HTTP ${T.status}`);let O=P(_,"update.tar.gz");await Bun.write(O,T),S.update("Extracting..."),await d(["tar","xzf",O,"--strip-components=1"],_);let{rmSync:r}=await import("fs");r(O,{force:!0}),S.stop("Download complete")}catch(B){if(S.stop(),v(`Failed to download update: ${B instanceof Error?B.message:String(B)}`),N(_)){let{rmSync:F}=await import("fs");F(_,{recursive:!0,force:!0})}process.exit(1)}V("Applying updates");let h=0,o=0,f=J.includes("--dry-run");for(let B of fJ){let F=P(_,B),U=P(W,B);if(!N(F))continue;try{let T=await Bun.file(F).text();if(N(U)){if(await Bun.file(U).text()===T){o++;continue}}if(!f){let O=U.substring(0,U.lastIndexOf("/")),{mkdirSync:r}=await import("fs");r(O,{recursive:!0}),await Bun.write(U,T)}b(`${f?`${Q("[dry-run]")} `:""}Updated ${H(q(B))}`),h++}catch{k(`Could not update ${B}`)}}let a=P(_,"package.json");if(N(a))try{let B=JSON.parse(await Bun.file(a).text()),F=JSON.parse(await Bun.file(G).text()),U=!1;if(B.dependencies){F.dependencies=F.dependencies??{};for(let[T,O]of Object.entries(B.dependencies))if(F.dependencies[T]!==O)F.dependencies[T]=O,U=!0}if(B.devDependencies){F.devDependencies=F.devDependencies??{};for(let[T,O]of Object.entries(B.devDependencies))if(F.devDependencies[T]!==O)F.devDependencies[T]=O,U=!0}if(Y)F.version=Y;if(!f)await Bun.write(G,`${JSON.stringify(F,null,2)}
|
|
421
|
+
`);if(U)b(`${f?`${Q("[dry-run]")} `:""}Updated dependencies in ${H(q("package.json"))}`)}catch{k("Could not merge package.json dependencies")}if(N(_)){let{rmSync:B}=await import("fs");B(_,{recursive:!0,force:!0})}if(!f&&h>0){V("Installing dependencies");let B=y("Running bun install...");B.start();let{exitCode:F}=await d(["bun","install"],W);if(F!==0)B.stop(),k("bun install failed \u2014 run it manually.");else B.stop("Dependencies installed")}if(L&&!f&&h>0){if(await m("Create a git commit for this upgrade?")){let F=Y?`chore: upgrade onlyApi to v${Y}`:"chore: upgrade onlyApi to latest";await d(["git","add","-A"],W),await d(["git","commit","-m",F,"--no-verify"],W),b("Created upgrade commit")}}let s=performance.now()-K;if(Z(),h>0)X(` ${I.rocket} ${H(R("Upgrade complete!"))} ${Q(`(${i(s)})`)}`),Z(),e([["Files updated",String(h)],["Files unchanged",String(o)]]);else if(f)X(` ${I.info} ${H(q("Dry run complete"))} \u2014 no files were modified.`);else n("All files are already up to date!");if(Z(),h>0)X(` ${Q("Note: The following files are NOT auto-upgraded (your custom code):")}`),X(` ${Q(" - src/application/ (your services & DTOs)")}`),X(` ${Q(" - src/core/entities/ (your domain entities)")}`),X(` ${Q(" - src/core/ports/ (your port interfaces)")}`),X(` ${Q(" - src/presentation/handlers/ (your route handlers)")}`),X(` ${Q(" - src/presentation/routes/ (your routes)")}`),X(` ${Q(" - src/main.ts (your bootstrap)")}`),Z(),X(` ${Q("Review the")} ${q("CHANGELOG.md")} ${Q("for breaking changes.")}`),Z()};var l="1.7.0",IJ=process.argv.slice(2),_J=IJ[0]?.toLowerCase()??"",OJ=IJ.slice(1),kJ=async()=>{try{switch(_J){case"init":case"create":case"new":await YJ(OJ,l);break;case"upgrade":case"update":await TJ(OJ,l);break;case"version":case"-v":case"--version":X(`onlyapi v${l}`);break;case"help":case"-h":case"--help":JJ(l);break;case"":JJ(l);break;default:Z(),v(`Unknown command: ${H(E(_J))}`),Z(),X(` ${Q("Run")} ${q("onlyapi help")} ${Q("to see available commands.")}`),Z(),process.exit(1)}}catch(J){Z(),v(J instanceof Error?J.message:String(J)),Z(),process.exit(1)}};kJ();
|
package/package.json
CHANGED
package/src/cli/commands/init.ts
CHANGED
|
@@ -220,7 +220,9 @@ export const initCommand = async (args: string[], version: string): Promise<void
|
|
|
220
220
|
`│ ├── main.ts ${dim("← entry point")}`,
|
|
221
221
|
`│ ├── config.ts ${dim("← env config")}`,
|
|
222
222
|
`│ ├── database.ts ${dim("← SQLite + migrations")}`,
|
|
223
|
-
`│ ├──
|
|
223
|
+
`│ ├── logger.ts ${dim("← colored structured logger")}`,
|
|
224
|
+
`│ ├── router.ts ${dim("← route table + matching")}`,
|
|
225
|
+
`│ ├── server.ts ${dim("← HTTP server + middleware")}`,
|
|
224
226
|
"│ ├── handlers/",
|
|
225
227
|
`│ │ ├── auth.handler.ts ${dim("← register/login/logout")}`,
|
|
226
228
|
"│ │ ├── health.handler.ts",
|
package/src/cli/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { blank, bold, cyan, dim, error, log, white } from "./ui.js";
|
|
|
17
17
|
|
|
18
18
|
// ── Version ─────────────────────────────────────────────────────────────
|
|
19
19
|
|
|
20
|
-
const VERSION = "1.
|
|
20
|
+
const VERSION = "1.7.0";
|
|
21
21
|
|
|
22
22
|
// ── Arg parsing ─────────────────────────────────────────────────────────
|
|
23
23
|
|