@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
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,716 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
|
-
var
|
|
4
|
-
`)},
|
|
3
|
+
var EJ=Object.create;var{getPrototypeOf:AJ,defineProperty:WJ,getOwnPropertyNames:MJ}=Object;var RJ=Object.prototype.hasOwnProperty;var u=(J,$,K)=>{K=J!=null?EJ(AJ(J)):{};let W=$||!J||!J.__esModule?WJ(K,"default",{value:J,enumerable:!0}):K;for(let G of MJ(J))if(!RJ.call(W,G))WJ(W,G,{get:()=>J[G],enumerable:!0});return W};var p=import.meta.require;var M=(J)=>`\x1B[${J}m`,x=M("0"),H=(J)=>`${M("1")}${J}${x}`,Q=(J)=>`${M("2")}${J}${x}`,q=(J)=>`${M("36")}${J}${x}`,R=(J)=>`${M("32")}${J}${x}`,j=(J)=>`${M("33")}${J}${x}`,XJ=(J)=>`${M("35")}${J}${x}`,vJ=(J)=>`${M("34")}${J}${x}`,$J=(J)=>`${M("31")}${J}${x}`,A=(J)=>`${M("90")}${J}${x}`,E=(J)=>`${M("97")}${J}${x}`;var I={success:R("\u2714"),error:$J("\u2717"),warning:j("\u26A0"),info:q("\u2139"),arrow:q("\u2192"),chevron:q("\u203A"),sparkle:XJ("\u2726"),bolt:j("\u26A1"),folder:vJ("\uD83D\uDCC1"),file:A("\uD83D\uDCC4"),gear:A("\u2699"),rocket:XJ("\uD83D\uDE80"),package:q("\uD83D\uDCE6")},g=(J)=>{return[`${H(q(" \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"))}`,`${H(q(" \u2502"))} ${H(q("\u2502"))}`,`${H(q(" \u2502"))} ${H(E("\u26A1 onlyApi CLI"))} ${Q(A(`v${J}`))} ${H(q("\u2502"))}`,`${H(q(" \u2502"))} ${Q(A("Zero-dep enterprise REST API on Bun"))} ${H(q("\u2502"))}`,`${H(q(" \u2502"))} ${H(q("\u2502"))}`,`${H(q(" \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"))}`].join(`
|
|
4
|
+
`)},X=(J)=>process.stdout.write(`${J}
|
|
5
5
|
`),Z=()=>process.stdout.write(`
|
|
6
|
-
`),
|
|
7
|
-
`),
|
|
8
|
-
`),C=(
|
|
9
|
-
`),
|
|
10
|
-
`),
|
|
11
|
-
`),
|
|
12
|
-
`)}}}
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
`),v=(J)=>process.stderr.write(` ${I.error} ${$J(J)}
|
|
7
|
+
`),k=(J)=>process.stdout.write(` ${I.warning} ${j(J)}
|
|
8
|
+
`),C=(J)=>process.stdout.write(` ${I.info} ${J}
|
|
9
|
+
`),n=(J)=>process.stdout.write(` ${I.success} ${R(J)}
|
|
10
|
+
`),b=(J)=>process.stdout.write(` ${I.chevron} ${J}
|
|
11
|
+
`),ZJ=["\u280B","\u2819","\u2839","\u2838","\u283C","\u2834","\u2826","\u2827","\u2807","\u280F"],y=(J)=>{let $=0,K=null,W=J,G=()=>{process.stdout.write("\r\x1B[K")};return{start(){K=setInterval(()=>{G();let z=ZJ[$%ZJ.length]??"\u280B";process.stdout.write(` ${q(z)} ${W}`),$++},80)},update(z){W=z},stop(z){if(K)clearInterval(K);if(G(),z)process.stdout.write(` ${I.success} ${R(z)}
|
|
12
|
+
`)}}},zJ=async(J,$)=>{let K=$?` ${Q(`(${$})`)}`:"";process.stdout.write(` ${I.chevron} ${J}${K}: `);let W=Bun.stdin.stream().getReader(),{value:G}=await W.read();return W.releaseLock(),(G?new TextDecoder().decode(G).trim():"")||$||""},m=async(J,$=!0)=>{let K=$?`${H("Y")}/n`:`y/${H("N")}`;process.stdout.write(` ${I.chevron} ${J} ${Q(`[${K}]`)}: `);let W=Bun.stdin.stream().getReader(),{value:G}=await W.read();W.releaseLock();let z=G?new TextDecoder().decode(G).trim().toLowerCase():"";if(z==="")return $;return z==="y"||z==="yes"},e=(J)=>{let $=Math.max(...J.map(([K])=>K.length));for(let[K,W]of J)X(` ${A("\u2502")} ${Q(K.padEnd($))} ${E(W)}`)},V=(J)=>{Z(),X(` ${H(E(J))}`),X(` ${A("\u2500".repeat(50))}`)};var i=(J)=>{if(J<1000)return`${Math.round(J)}ms`;if(J<60000)return`${(J/1000).toFixed(1)}s`;return`${Math.floor(J/60000)}m ${Math.round(J%60000/1000)}s`},HJ=(J=64)=>{let K=crypto.getRandomValues(new Uint8Array(J));return Array.from(K,(W)=>"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[W%62]).join("")};var JJ=(J)=>{Z(),X(g(J)),Z(),X(` ${H(E("USAGE"))}`),X(` ${A("\u2500".repeat(50))}`),X(` ${Q("$")} ${q("onlyapi")} ${R("<command>")} ${Q("[options]")}`),Z(),X(` ${H(E("COMMANDS"))}`),X(` ${A("\u2500".repeat(50))}`);let $=[["init <name>","Create a new onlyApi project"],["upgrade","Upgrade current project to latest version"],["version","Show CLI version"],["help","Show this help message"]],K=Math.max(...$.map(([Y])=>Y.length));for(let[Y,L]of $)X(` ${R(Y.padEnd(K+2))} ${Q(L)}`);Z(),X(` ${H(E("INIT OPTIONS"))}`),X(` ${A("\u2500".repeat(50))}`);let W=[[".","Initialize in the current directory"],["--cwd","Same as '.' \u2014 use current directory"]],G=Math.max(...W.map(([Y])=>Y.length));for(let[Y,L]of W)X(` ${j(Y.padEnd(G+2))} ${Q(L)}`);Z(),X(` ${H(E("UPGRADE OPTIONS"))}`),X(` ${A("\u2500".repeat(50))}`);let z=[["--force, -f","Force upgrade even if on latest version"],["--dry-run","Preview changes without applying them"]],w=Math.max(...z.map(([Y])=>Y.length));for(let[Y,L]of z)X(` ${j(Y.padEnd(w+2))} ${Q(L)}`);Z(),X(` ${H(E("EXAMPLES"))}`),X(` ${A("\u2500".repeat(50))}`);let D=[["onlyapi init my-api","Create project in ./my-api"],["onlyapi init .","Initialize in current directory"],["onlyapi upgrade","Upgrade to latest version"],["onlyapi upgrade --dry-run","Preview upgrade without changes"],["onlyapi upgrade --force","Force re-apply latest version"]];for(let[Y,L]of D)X(` ${Q("$")} ${q(Y)}`),X(` ${Q(L)}`);Z(),X(` ${Q("Docs:")} ${q("https://github.com/lysari/onlyapi#readme")}`),X(` ${Q("Issues:")} ${q("https://github.com/lysari/onlyapi/issues")}`),Z()};import{existsSync as t,mkdirSync as qJ,writeFileSync as CJ}from"fs";import{dirname as VJ,join as QJ,resolve as NJ}from"path";var KJ=(J)=>[{path:"package.json",content:`{
|
|
13
|
+
"name": ${JSON.stringify(J)},
|
|
14
|
+
"version": "0.1.0",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "bun --watch src/main.ts",
|
|
18
|
+
"start": "NODE_ENV=production bun src/main.ts",
|
|
19
|
+
"check": "tsc --noEmit",
|
|
20
|
+
"test": "bun test",
|
|
21
|
+
"lint": "bunx @biomejs/biome check src/"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"zod": "^3.24.2"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@biomejs/biome": "^1.9.4",
|
|
28
|
+
"@types/bun": "^1.2.2",
|
|
29
|
+
"typescript": "^5.7.3"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
`},{path:"tsconfig.json",content:`{
|
|
33
|
+
"compilerOptions": {
|
|
34
|
+
"target": "ESNext",
|
|
35
|
+
"module": "ESNext",
|
|
36
|
+
"moduleResolution": "bundler",
|
|
37
|
+
"lib": ["ESNext"],
|
|
38
|
+
"types": ["bun-types"],
|
|
39
|
+
"strict": true,
|
|
40
|
+
"noImplicitAny": true,
|
|
41
|
+
"noUnusedLocals": true,
|
|
42
|
+
"noUnusedParameters": true,
|
|
43
|
+
"esModuleInterop": true,
|
|
44
|
+
"skipLibCheck": true,
|
|
45
|
+
"outDir": "dist",
|
|
46
|
+
"rootDir": "."
|
|
47
|
+
},
|
|
48
|
+
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
|
49
|
+
"exclude": ["node_modules", "dist"]
|
|
50
|
+
}
|
|
51
|
+
`},{path:"biome.json",content:`{
|
|
52
|
+
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
|
53
|
+
"organizeImports": { "enabled": true },
|
|
54
|
+
"linter": {
|
|
55
|
+
"enabled": true,
|
|
56
|
+
"rules": { "recommended": true }
|
|
57
|
+
},
|
|
58
|
+
"formatter": {
|
|
59
|
+
"enabled": true,
|
|
60
|
+
"indentStyle": "space",
|
|
61
|
+
"indentWidth": 2,
|
|
62
|
+
"lineWidth": 100
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
`},{path:".env.example",content:`# Environment
|
|
66
|
+
NODE_ENV=development
|
|
67
|
+
PORT=3000
|
|
68
|
+
HOST=0.0.0.0
|
|
69
|
+
|
|
70
|
+
# Security
|
|
71
|
+
JWT_SECRET=change-me-to-a-64-char-random-string
|
|
72
|
+
JWT_EXPIRES_IN=15m
|
|
73
|
+
|
|
74
|
+
# Logging
|
|
75
|
+
LOG_LEVEL=debug
|
|
76
|
+
|
|
77
|
+
# Database
|
|
78
|
+
DATABASE_PATH=data/app.sqlite
|
|
79
|
+
`},{path:".gitignore",content:`node_modules/
|
|
80
|
+
dist/
|
|
81
|
+
data/
|
|
82
|
+
.env
|
|
83
|
+
*.sqlite
|
|
84
|
+
*.log
|
|
85
|
+
`},{path:".dockerignore",content:`node_modules/
|
|
86
|
+
dist/
|
|
87
|
+
data/
|
|
88
|
+
.env
|
|
89
|
+
.git/
|
|
90
|
+
*.md
|
|
91
|
+
`},{path:"Dockerfile",content:`FROM oven/bun:1.3-alpine AS builder
|
|
92
|
+
WORKDIR /app
|
|
93
|
+
COPY package.json bun.lock* ./
|
|
94
|
+
RUN bun install --frozen-lockfile
|
|
95
|
+
COPY tsconfig.json ./
|
|
96
|
+
COPY src/ src/
|
|
97
|
+
RUN bun run check
|
|
98
|
+
RUN mkdir -p /app/data
|
|
99
|
+
|
|
100
|
+
FROM oven/bun:1.3-alpine
|
|
101
|
+
WORKDIR /app
|
|
102
|
+
COPY --from=builder /app/src/ src/
|
|
103
|
+
COPY --from=builder /app/node_modules/ node_modules/
|
|
104
|
+
COPY --from=builder /app/package.json ./
|
|
105
|
+
COPY --from=builder /app/tsconfig.json ./
|
|
106
|
+
COPY --from=builder /app/data/ data/
|
|
107
|
+
RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app
|
|
108
|
+
USER app
|
|
109
|
+
EXPOSE 3000
|
|
110
|
+
ENV NODE_ENV=production
|
|
111
|
+
ENV HOST=0.0.0.0
|
|
112
|
+
ENV PORT=3000
|
|
113
|
+
CMD ["bun", "src/main.ts"]
|
|
114
|
+
`},{path:"README.md",content:`# ${J}
|
|
115
|
+
|
|
116
|
+
Built with [onlyApi](https://github.com/lysari/onlyapi) \u2014 zero-dependency REST API on Bun.
|
|
117
|
+
|
|
118
|
+
## Quick Start
|
|
119
|
+
|
|
120
|
+
\`\`\`bash
|
|
121
|
+
bun run dev # Start dev server (hot-reload)
|
|
122
|
+
bun test # Run tests
|
|
123
|
+
bun run check # Type-check
|
|
124
|
+
\`\`\`
|
|
125
|
+
|
|
126
|
+
## API Endpoints
|
|
127
|
+
|
|
128
|
+
| Method | Path | Description | Auth |
|
|
129
|
+
|--------|-------------------------|-------------------|------|
|
|
130
|
+
| GET | /health | Health check | No |
|
|
131
|
+
| POST | /api/v1/auth/register | Register | No |
|
|
132
|
+
| POST | /api/v1/auth/login | Login | No |
|
|
133
|
+
| POST | /api/v1/auth/logout | Logout | Yes |
|
|
134
|
+
| GET | /api/v1/users/me | Get profile | Yes |
|
|
135
|
+
| PATCH | /api/v1/users/me | Update profile | Yes |
|
|
136
|
+
| DELETE | /api/v1/users/me | Delete account | Yes |
|
|
137
|
+
|
|
138
|
+
## Project Structure
|
|
139
|
+
|
|
140
|
+
\`\`\`
|
|
141
|
+
src/
|
|
142
|
+
main.ts # Entry point \u2014 wires everything together
|
|
143
|
+
config.ts # Environment config with validation
|
|
144
|
+
database.ts # SQLite setup + migrations
|
|
145
|
+
server.ts # HTTP server + routing
|
|
146
|
+
handlers/
|
|
147
|
+
auth.handler.ts # Register, login, logout
|
|
148
|
+
health.handler.ts # Health check
|
|
149
|
+
user.handler.ts # User profile CRUD
|
|
150
|
+
middleware/
|
|
151
|
+
auth.ts # JWT authentication guard
|
|
152
|
+
services/
|
|
153
|
+
auth.service.ts # Auth business logic
|
|
154
|
+
user.service.ts # User business logic
|
|
155
|
+
utils/
|
|
156
|
+
password.ts # Argon2id hashing
|
|
157
|
+
token.ts # JWT sign/verify
|
|
158
|
+
response.ts # JSON response helpers
|
|
159
|
+
\`\`\`
|
|
160
|
+
|
|
161
|
+
## Environment Variables
|
|
162
|
+
|
|
163
|
+
| Variable | Default | Description |
|
|
164
|
+
|----------------|----------------------|-------------------------|
|
|
165
|
+
| PORT | 3000 | Server port |
|
|
166
|
+
| HOST | 0.0.0.0 | Bind address |
|
|
167
|
+
| JWT_SECRET | \u2014 | **Required** JWT secret |
|
|
168
|
+
| JWT_EXPIRES_IN | 15m | Token expiration |
|
|
169
|
+
| DATABASE_PATH | data/app.sqlite | SQLite file path |
|
|
170
|
+
| LOG_LEVEL | debug | info / debug / warn |
|
|
171
|
+
| NODE_ENV | development | development / production|
|
|
172
|
+
|
|
173
|
+
## Docker
|
|
174
|
+
|
|
175
|
+
\`\`\`bash
|
|
176
|
+
docker build -t ${J} .
|
|
177
|
+
docker run -e JWT_SECRET="your-secret-here" -p 3000:3000 ${J}
|
|
178
|
+
\`\`\`
|
|
179
|
+
`},{path:"src/main.ts",content:`import { loadConfig } from "./config.js";
|
|
180
|
+
import { createDatabase } from "./database.js";
|
|
181
|
+
import { createAuthService } from "./services/auth.service.js";
|
|
182
|
+
import { createUserService } from "./services/user.service.js";
|
|
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";
|
|
224
|
+
|
|
225
|
+
const configSchema = z.object({
|
|
226
|
+
port: z.coerce.number().default(3000),
|
|
227
|
+
host: z.string().default("0.0.0.0"),
|
|
228
|
+
nodeEnv: z.enum(["development", "production"]).default("development"),
|
|
229
|
+
jwt: z.object({
|
|
230
|
+
secret: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
|
|
231
|
+
expiresIn: z.string().default("15m"),
|
|
232
|
+
}),
|
|
233
|
+
databasePath: z.string().default("data/app.sqlite"),
|
|
234
|
+
logLevel: z.enum(["debug", "info", "warn", "error"]).default("debug"),
|
|
235
|
+
corsOrigins: z.string().default("*"),
|
|
236
|
+
rateLimitMax: z.coerce.number().default(100),
|
|
237
|
+
rateLimitWindowMs: z.coerce.number().default(60_000),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
export type AppConfig = z.infer<typeof configSchema>;
|
|
241
|
+
|
|
242
|
+
export const loadConfig = (): AppConfig => {
|
|
243
|
+
const result = configSchema.safeParse({
|
|
244
|
+
port: Bun.env.PORT,
|
|
245
|
+
host: Bun.env.HOST,
|
|
246
|
+
nodeEnv: Bun.env.NODE_ENV,
|
|
247
|
+
jwt: {
|
|
248
|
+
secret: Bun.env.JWT_SECRET,
|
|
249
|
+
expiresIn: Bun.env.JWT_EXPIRES_IN,
|
|
250
|
+
},
|
|
251
|
+
databasePath: Bun.env.DATABASE_PATH,
|
|
252
|
+
logLevel: Bun.env.LOG_LEVEL,
|
|
253
|
+
corsOrigins: Bun.env.CORS_ORIGINS,
|
|
254
|
+
rateLimitMax: Bun.env.RATE_LIMIT_MAX_REQUESTS,
|
|
255
|
+
rateLimitWindowMs: Bun.env.RATE_LIMIT_WINDOW_MS,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (!result.success) {
|
|
259
|
+
const errors = result.error.issues
|
|
260
|
+
.map((i) => \` \u2717 \${i.path.join(".")} \u2192 \${i.message}\`)
|
|
261
|
+
.join("\\n");
|
|
262
|
+
|
|
263
|
+
console.error(\`\\n CONFIG ERROR Invalid configuration\\n\\n\${errors}\\n\`);
|
|
264
|
+
console.error(" Hint: Copy .env.example to .env and set the required values:\\n");
|
|
265
|
+
console.error(" $ cp .env.example .env\\n");
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return result.data;
|
|
270
|
+
};
|
|
271
|
+
`},{path:"src/database.ts",content:`import { Database } from "bun:sqlite";
|
|
272
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
273
|
+
import { dirname } from "node:path";
|
|
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";
|
|
440
|
+
|
|
441
|
+
export const healthHandler = () => ({
|
|
442
|
+
check: (): Response =>
|
|
443
|
+
json({ status: "ok", uptime: process.uptime() }),
|
|
444
|
+
});
|
|
445
|
+
`},{path:"src/handlers/auth.handler.ts",content:`import type { AuthService } from "../services/auth.service.js";
|
|
446
|
+
import { json } from "../utils/response.js";
|
|
447
|
+
import { z } from "zod";
|
|
448
|
+
|
|
449
|
+
const registerSchema = z.object({
|
|
450
|
+
email: z.string().email(),
|
|
451
|
+
password: z.string().min(8),
|
|
452
|
+
name: z.string().min(1).optional(),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const loginSchema = z.object({
|
|
456
|
+
email: z.string().email(),
|
|
457
|
+
password: z.string(),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
export const authHandlers = (authService: AuthService) => ({
|
|
461
|
+
register: async (req: Request): Promise<Response> => {
|
|
462
|
+
const body = registerSchema.safeParse(await req.json());
|
|
463
|
+
if (!body.success) return json({ error: body.error.issues }, 400);
|
|
464
|
+
|
|
465
|
+
const result = await authService.register(body.data);
|
|
466
|
+
if (!result.ok) return json({ error: result.error }, 409);
|
|
467
|
+
|
|
468
|
+
return json({ data: result.data }, 201);
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
login: async (req: Request): Promise<Response> => {
|
|
472
|
+
const body = loginSchema.safeParse(await req.json());
|
|
473
|
+
if (!body.success) return json({ error: body.error.issues }, 400);
|
|
474
|
+
|
|
475
|
+
const result = await authService.login(body.data.email, body.data.password);
|
|
476
|
+
if (!result.ok) return json({ error: result.error }, 401);
|
|
477
|
+
|
|
478
|
+
return json({ data: result.data });
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
logout: async (userId: string, req: Request): Promise<Response> => {
|
|
482
|
+
const header = req.headers.get("authorization") ?? "";
|
|
483
|
+
const token = header.replace("Bearer ", "");
|
|
484
|
+
|
|
485
|
+
await authService.logout(token);
|
|
486
|
+
return json({ data: { message: "Logged out" } });
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
`},{path:"src/handlers/user.handler.ts",content:`import type { UserService } from "../services/user.service.js";
|
|
490
|
+
import { json } from "../utils/response.js";
|
|
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";
|
|
526
|
+
|
|
527
|
+
type AuthResult =
|
|
528
|
+
| { ok: true; userId: string }
|
|
529
|
+
| { ok: false; error: string };
|
|
530
|
+
|
|
531
|
+
export const authenticate = async (
|
|
532
|
+
req: Request,
|
|
533
|
+
tokenService: TokenService,
|
|
534
|
+
): Promise<AuthResult> => {
|
|
535
|
+
const header = req.headers.get("authorization");
|
|
536
|
+
if (!header?.startsWith("Bearer ")) {
|
|
537
|
+
return { ok: false, error: "Missing or invalid Authorization header" };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const token = header.slice(7);
|
|
541
|
+
const payload = tokenService.verify(token);
|
|
542
|
+
|
|
543
|
+
if (!payload) {
|
|
544
|
+
return { ok: false, error: "Invalid or expired token" };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (tokenService.isBlacklisted(payload.jti)) {
|
|
548
|
+
return { ok: false, error: "Token has been revoked" };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return { ok: true, userId: payload.sub };
|
|
552
|
+
};
|
|
553
|
+
`},{path:"src/services/auth.service.ts",content:`import type { Database } from "bun:sqlite";
|
|
554
|
+
import type { PasswordHasher } from "../utils/password.js";
|
|
555
|
+
import type { TokenService } from "../utils/token.js";
|
|
556
|
+
|
|
557
|
+
type Result<T> = { ok: true; data: T } | { ok: false; error: string };
|
|
558
|
+
|
|
559
|
+
interface RegisterInput {
|
|
560
|
+
email: string;
|
|
561
|
+
password: string;
|
|
562
|
+
name?: string;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export interface AuthService {
|
|
566
|
+
register(input: RegisterInput): Promise<Result<{ id: string; email: string; token: string }>>;
|
|
567
|
+
login(email: string, password: string): Promise<Result<{ token: string; user: { id: string; email: string; name: string } }>>;
|
|
568
|
+
logout(token: string): Promise<void>;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export const createAuthService = (
|
|
572
|
+
db: Database,
|
|
573
|
+
passwordHasher: PasswordHasher,
|
|
574
|
+
tokenService: TokenService,
|
|
575
|
+
): AuthService => ({
|
|
576
|
+
async register(input) {
|
|
577
|
+
// Check if user exists
|
|
578
|
+
const existing = db.query("SELECT id FROM users WHERE email = ?").get(input.email);
|
|
579
|
+
if (existing) return { ok: false, error: "Email already registered" };
|
|
580
|
+
|
|
581
|
+
const id = crypto.randomUUID();
|
|
582
|
+
const hashedPassword = await passwordHasher.hash(input.password);
|
|
583
|
+
|
|
584
|
+
db.query("INSERT INTO users (id, email, password, name) VALUES (?, ?, ?, ?)").run(
|
|
585
|
+
id,
|
|
586
|
+
input.email,
|
|
587
|
+
hashedPassword,
|
|
588
|
+
input.name ?? "",
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const token = tokenService.sign({ sub: id, email: input.email, role: "user" });
|
|
592
|
+
return { ok: true, data: { id, email: input.email, token } };
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
async login(email, password) {
|
|
596
|
+
const user = db
|
|
597
|
+
.query("SELECT id, email, password, name, role FROM users WHERE email = ?")
|
|
598
|
+
.get(email) as { id: string; email: string; password: string; name: string; role: string } | null;
|
|
599
|
+
|
|
600
|
+
if (!user) return { ok: false, error: "Invalid credentials" };
|
|
601
|
+
|
|
602
|
+
const valid = await passwordHasher.verify(user.password, password);
|
|
603
|
+
if (!valid) return { ok: false, error: "Invalid credentials" };
|
|
604
|
+
|
|
605
|
+
const token = tokenService.sign({ sub: user.id, email: user.email, role: user.role });
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
data: { token, user: { id: user.id, email: user.email, name: user.name } },
|
|
609
|
+
};
|
|
610
|
+
},
|
|
611
|
+
|
|
612
|
+
async logout(token) {
|
|
613
|
+
const payload = tokenService.verify(token);
|
|
614
|
+
if (payload?.jti) {
|
|
615
|
+
tokenService.blacklist(payload.jti, payload.exp);
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
`},{path:"src/services/user.service.ts",content:`import type { Database } from "bun:sqlite";
|
|
620
|
+
import type { PasswordHasher } from "../utils/password.js";
|
|
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:`/**
|
|
671
|
+
* Password hashing using Bun's built-in Argon2id.
|
|
672
|
+
*/
|
|
673
|
+
export interface PasswordHasher {
|
|
674
|
+
hash(password: string): Promise<string>;
|
|
675
|
+
verify(hash: string, password: string): Promise<boolean>;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export const createPasswordHasher = (): PasswordHasher => ({
|
|
679
|
+
async hash(password) {
|
|
680
|
+
return Bun.password.hash(password, { algorithm: "argon2id" });
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
async verify(hash, password) {
|
|
684
|
+
return Bun.password.verify(password, hash);
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
`},{path:"src/utils/token.ts",content:["/**"," * JWT token service using Bun's native HMAC-SHA256."," */","","export interface TokenPayload {"," sub: string;"," jti: string;"," exp: number;"," [key: string]: unknown;","}","","export interface TokenService {"," sign(claims: Record<string, unknown>): string;"," verify(token: string): TokenPayload | null;"," blacklist(jti: string, exp: number): void;"," isBlacklisted(jti: string): boolean;","}","",'// Parse duration string to seconds: "15m" \u2192 900, "1h" \u2192 3600, "7d" \u2192 604800',"const parseDuration = (duration: string): number => {"," const match = duration.match(/^(\\d+)([smhd])$/);"," if (!match) return 900; // default 15 minutes"," const value = Number.parseInt(match[1], 10);"," const unit = match[2];"," const multipliers: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };"," return value * (multipliers[unit] ?? 60);","};","","export const createTokenService = (secret: string, expiresIn: string): TokenService => {"," const key = new TextEncoder().encode(secret);"," const expiresInSec = parseDuration(expiresIn);",""," // In-memory blacklist with auto-cleanup"," const blacklisted = new Map<string, number>();",""," // Clean expired entries every 5 minutes"," setInterval(() => {"," const now = Math.floor(Date.now() / 1000);"," for (const [jti, exp] of blacklisted) {"," if (exp < now) blacklisted.delete(jti);"," }"," }, 5 * 60 * 1000).unref();",""," return {"," sign(claims) {"," const now = Math.floor(Date.now() / 1000);"," const jti = crypto.randomUUID();"," const payload = { ...claims, jti, iat: now, exp: now + expiresInSec };",""," // HMAC-SHA256 JWT",' const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }))',' .replace(/=/g, "").replace(/\\+/g, "-").replace(/\\//g, "_");',""," const body = btoa(JSON.stringify(payload))",' .replace(/=/g, "").replace(/\\+/g, "-").replace(/\\//g, "_");',"",' const data = header + "." + body;',' const hmac = new Bun.CryptoHasher("sha256", key);'," hmac.update(data);",' const sig = Buffer.from(hmac.digest()).toString("base64url");',"",' return data + "." + sig;'," },",""," verify(token) {"," try {",' const parts = token.split(".");'," if (parts.length !== 3) return null;",""," // Verify signature",' const data = parts[0] + "." + parts[1];',' const hmac = new Bun.CryptoHasher("sha256", key);'," hmac.update(data);",' const expected = Buffer.from(hmac.digest()).toString("base64url");',""," if (expected !== parts[2]) return null;",""," // Decode payload"," const payload = JSON.parse(",' Buffer.from(parts[1], "base64url").toString()'," ) as TokenPayload;",""," // Check expiry"," if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {"," return null;"," }",""," return payload;"," } catch {"," return null;"," }"," },",""," blacklist(jti, exp) {"," blacklisted.set(jti, exp);"," },",""," isBlacklisted(jti) {"," return blacklisted.has(jti);"," },"," };","};",""].join(`
|
|
688
|
+
`)},{path:"src/utils/response.ts",content:`/**
|
|
689
|
+
* JSON response helper.
|
|
690
|
+
*/
|
|
691
|
+
export const json = (
|
|
692
|
+
body: unknown,
|
|
693
|
+
status = 200,
|
|
694
|
+
extraHeaders?: Record<string, string>,
|
|
695
|
+
): Response => {
|
|
696
|
+
const headers: Record<string, string> = {
|
|
697
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
698
|
+
...extraHeaders,
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
return new Response(JSON.stringify(body), { status, headers });
|
|
702
|
+
};
|
|
703
|
+
`},{path:"tests/health.test.ts",content:`import { describe, expect, it } from "bun:test";
|
|
704
|
+
|
|
705
|
+
describe("Health check", () => {
|
|
706
|
+
it("should return ok", async () => {
|
|
707
|
+
// Start server for test
|
|
708
|
+
const response = await fetch("http://localhost:3000/health");
|
|
709
|
+
expect(response.status).toBe(200);
|
|
710
|
+
|
|
711
|
+
const data = await response.json();
|
|
712
|
+
expect(data.status).toBe("ok");
|
|
713
|
+
});
|
|
714
|
+
});
|
|
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 + routing")}`,"\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)}
|
|
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.6.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();
|