@lastshotlabs/bunshot 0.0.1
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/CLAUDE.md +102 -0
- package/README.md +1458 -0
- package/bun.lock +170 -0
- package/package.json +47 -0
- package/src/adapters/memoryAuth.ts +240 -0
- package/src/adapters/mongoAuth.ts +91 -0
- package/src/adapters/sqliteAuth.ts +320 -0
- package/src/app.ts +368 -0
- package/src/cli.ts +265 -0
- package/src/index.ts +52 -0
- package/src/lib/HttpError.ts +5 -0
- package/src/lib/appConfig.ts +29 -0
- package/src/lib/authAdapter.ts +46 -0
- package/src/lib/authRateLimit.ts +104 -0
- package/src/lib/constants.ts +2 -0
- package/src/lib/context.ts +17 -0
- package/src/lib/emailVerification.ts +105 -0
- package/src/lib/fingerprint.ts +43 -0
- package/src/lib/jwt.ts +17 -0
- package/src/lib/logger.ts +9 -0
- package/src/lib/mongo.ts +70 -0
- package/src/lib/oauth.ts +114 -0
- package/src/lib/queue.ts +18 -0
- package/src/lib/redis.ts +45 -0
- package/src/lib/roles.ts +23 -0
- package/src/lib/session.ts +91 -0
- package/src/lib/validate.ts +14 -0
- package/src/lib/ws.ts +82 -0
- package/src/middleware/bearerAuth.ts +15 -0
- package/src/middleware/botProtection.ts +73 -0
- package/src/middleware/cacheResponse.ts +189 -0
- package/src/middleware/cors.ts +19 -0
- package/src/middleware/errorHandler.ts +14 -0
- package/src/middleware/identify.ts +36 -0
- package/src/middleware/index.ts +8 -0
- package/src/middleware/logger.ts +9 -0
- package/src/middleware/rateLimit.ts +37 -0
- package/src/middleware/requireRole.ts +42 -0
- package/src/middleware/requireVerifiedEmail.ts +31 -0
- package/src/middleware/userAuth.ts +9 -0
- package/src/models/AuthUser.ts +17 -0
- package/src/routes/auth.ts +245 -0
- package/src/routes/health.ts +27 -0
- package/src/routes/home.ts +21 -0
- package/src/routes/oauth.ts +174 -0
- package/src/schemas/auth.ts +14 -0
- package/src/server.ts +91 -0
- package/src/services/auth.ts +59 -0
- package/src/ws/index.ts +42 -0
- package/tsconfig.json +43 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, readSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
5
|
+
|
|
6
|
+
function ask(question: string): string {
|
|
7
|
+
process.stdout.write(question);
|
|
8
|
+
const buf = Buffer.alloc(1024);
|
|
9
|
+
const n = readSync(0, buf, 0, buf.length, null);
|
|
10
|
+
return buf.subarray(0, n).toString().trim().replace(/\r/g, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// --- prompts (or argv) ---
|
|
14
|
+
// Usage: bunshot "App Name" [dir-name]
|
|
15
|
+
const argTitle = process.argv[2];
|
|
16
|
+
const argDir = process.argv[3];
|
|
17
|
+
|
|
18
|
+
const appTitle = argTitle || ask("App name: ");
|
|
19
|
+
if (!appTitle) {
|
|
20
|
+
console.error("App name is required.");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const dirDefault = appTitle.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
25
|
+
const dirName = argDir || (argTitle ? dirDefault : ask(`Directory (${dirDefault}): `)) || dirDefault;
|
|
26
|
+
|
|
27
|
+
// --- paths ---
|
|
28
|
+
const projectDir = join(process.cwd(), dirName);
|
|
29
|
+
const srcDir = join(projectDir, "src");
|
|
30
|
+
const routesDir = join(srcDir, "routes");
|
|
31
|
+
const workersDir = join(srcDir, "workers");
|
|
32
|
+
const queuesDir = join(srcDir, "queues");
|
|
33
|
+
const wsDir = join(srcDir, "ws");
|
|
34
|
+
const servicesDir = join(srcDir, "services");
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if (existsSync(projectDir)) {
|
|
38
|
+
console.error(`Directory "${dirName}" already exists.`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- templates ---
|
|
43
|
+
const indexContent = `import { createServer, type CreateServerConfig } from "@lastshotlabs/bunshot";
|
|
44
|
+
|
|
45
|
+
const roles = {
|
|
46
|
+
admin: "admin",
|
|
47
|
+
user: "user",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const config: CreateServerConfig = {
|
|
51
|
+
routesDir: import.meta.dir + "/routes",
|
|
52
|
+
workersDir: import.meta.dir + "/workers",
|
|
53
|
+
app: {
|
|
54
|
+
name: "${appTitle}",
|
|
55
|
+
version: "1.0.0",
|
|
56
|
+
},
|
|
57
|
+
auth: {
|
|
58
|
+
roles: Object.values(roles),
|
|
59
|
+
defaultRole: roles.user,
|
|
60
|
+
},
|
|
61
|
+
security: {
|
|
62
|
+
cors: ["*"],
|
|
63
|
+
},
|
|
64
|
+
db: {
|
|
65
|
+
mongo: "single",
|
|
66
|
+
},
|
|
67
|
+
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
await createServer(config);
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
const readmeContent = `# ${appTitle}
|
|
74
|
+
|
|
75
|
+
Built with [@lastshotlabs/bunshot](https://github.com/Last-Shot-Labs/bunshot).
|
|
76
|
+
|
|
77
|
+
## Getting started
|
|
78
|
+
|
|
79
|
+
\`\`\`bash
|
|
80
|
+
# fill in .env with your values
|
|
81
|
+
bun dev
|
|
82
|
+
\`\`\`
|
|
83
|
+
|
|
84
|
+
| Endpoint | Description |
|
|
85
|
+
|---|---|
|
|
86
|
+
| \`POST /auth/register\` | Create account |
|
|
87
|
+
| \`POST /auth/login\` | Sign in, returns JWT |
|
|
88
|
+
| \`GET /docs\` | OpenAPI docs (Scalar) |
|
|
89
|
+
| \`GET /health\` | Health check |
|
|
90
|
+
|
|
91
|
+
## Project structure
|
|
92
|
+
|
|
93
|
+
\`\`\`
|
|
94
|
+
src/
|
|
95
|
+
index.ts # server entry point
|
|
96
|
+
routes/ # file-based routing (each file = a router)
|
|
97
|
+
workers/ # BullMQ workers (auto-imported on start)
|
|
98
|
+
\`\`\`
|
|
99
|
+
|
|
100
|
+
## Adding routes
|
|
101
|
+
|
|
102
|
+
Create a file in \`src/routes/\`:
|
|
103
|
+
|
|
104
|
+
\`\`\`ts
|
|
105
|
+
// src/routes/products.ts
|
|
106
|
+
import { createRouter } from "@lastshotlabs/bunshot";
|
|
107
|
+
import { z } from "zod";
|
|
108
|
+
|
|
109
|
+
export const router = createRouter();
|
|
110
|
+
|
|
111
|
+
router.get("/products", (c) => c.json({ products: [] }));
|
|
112
|
+
\`\`\`
|
|
113
|
+
|
|
114
|
+
## Adding models
|
|
115
|
+
|
|
116
|
+
\`\`\`ts
|
|
117
|
+
// src/models/Product.ts
|
|
118
|
+
import { appConnection, mongoose } from "@lastshotlabs/bunshot";
|
|
119
|
+
|
|
120
|
+
const ProductSchema = new mongoose.Schema({
|
|
121
|
+
name: { type: String, required: true },
|
|
122
|
+
price: { type: Number, required: true },
|
|
123
|
+
}, { timestamps: true });
|
|
124
|
+
|
|
125
|
+
export const Product = appConnection.model("Product", ProductSchema);
|
|
126
|
+
\`\`\`
|
|
127
|
+
|
|
128
|
+
## Environment variables
|
|
129
|
+
|
|
130
|
+
See \`.env\` — fill in MongoDB, Redis, JWT, Bearer token, and OAuth provider values before running.
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
const envContent = `NODE_ENV=development
|
|
134
|
+
PORT=3000
|
|
135
|
+
|
|
136
|
+
# MongoDB (single connection)
|
|
137
|
+
MONGO_USER_DEV=
|
|
138
|
+
MONGO_PW_DEV=
|
|
139
|
+
MONGO_HOST_DEV=
|
|
140
|
+
MONGO_DB_DEV=
|
|
141
|
+
MONGO_USER_PROD=
|
|
142
|
+
MONGO_PW_PROD=
|
|
143
|
+
MONGO_HOST_PROD=
|
|
144
|
+
MONGO_DB_PROD=
|
|
145
|
+
|
|
146
|
+
# MongoDB auth connection (only needed if mongo: "separate")
|
|
147
|
+
MONGO_AUTH_USER_DEV=
|
|
148
|
+
MONGO_AUTH_PW_DEV=
|
|
149
|
+
MONGO_AUTH_HOST_DEV=
|
|
150
|
+
MONGO_AUTH_DB_DEV=
|
|
151
|
+
MONGO_AUTH_USER_PROD=
|
|
152
|
+
MONGO_AUTH_PW_PROD=
|
|
153
|
+
MONGO_AUTH_HOST_PROD=
|
|
154
|
+
MONGO_AUTH_DB_PROD=
|
|
155
|
+
|
|
156
|
+
# Redis
|
|
157
|
+
REDIS_HOST_DEV=
|
|
158
|
+
REDIS_USER_DEV=
|
|
159
|
+
REDIS_PW_DEV=
|
|
160
|
+
REDIS_HOST_PROD=
|
|
161
|
+
REDIS_USER_PROD=
|
|
162
|
+
REDIS_PW_PROD=
|
|
163
|
+
|
|
164
|
+
# JWT
|
|
165
|
+
JWT_SECRET_DEV=
|
|
166
|
+
JWT_SECRET_PROD=
|
|
167
|
+
|
|
168
|
+
# Bearer API key
|
|
169
|
+
BEARER_TOKEN_DEV=
|
|
170
|
+
BEARER_TOKEN_PROD=
|
|
171
|
+
|
|
172
|
+
# OAuth — Google (optional)
|
|
173
|
+
GOOGLE_CLIENT_ID=
|
|
174
|
+
GOOGLE_CLIENT_SECRET=
|
|
175
|
+
GOOGLE_REDIRECT_URI=
|
|
176
|
+
|
|
177
|
+
# OAuth — Apple (optional)
|
|
178
|
+
APPLE_CLIENT_ID=
|
|
179
|
+
APPLE_TEAM_ID=
|
|
180
|
+
APPLE_KEY_ID=
|
|
181
|
+
APPLE_PRIVATE_KEY=
|
|
182
|
+
APPLE_REDIRECT_URI=
|
|
183
|
+
`;
|
|
184
|
+
|
|
185
|
+
// --- scaffold ---
|
|
186
|
+
console.log(`\n@lastshotlabs/bunshot — creating ${dirName}\n`);
|
|
187
|
+
|
|
188
|
+
mkdirSync(projectDir, { recursive: true });
|
|
189
|
+
|
|
190
|
+
// bun init -y (handles package.json, tsconfig.json, .gitignore)
|
|
191
|
+
console.log(" Running bun init...");
|
|
192
|
+
spawnSync("bun", ["init", "-y"], { cwd: projectDir, stdio: "inherit" });
|
|
193
|
+
|
|
194
|
+
// Remove the root index.ts bun init creates — we use src/index.ts
|
|
195
|
+
const rootIndex = join(projectDir, "index.ts");
|
|
196
|
+
if (existsSync(rootIndex)) rmSync(rootIndex);
|
|
197
|
+
|
|
198
|
+
// Patch package.json: add dependency + fix scripts + module entry
|
|
199
|
+
const pkgPath = join(projectDir, "package.json");
|
|
200
|
+
const pkg = JSON.parse(require("fs").readFileSync(pkgPath, "utf-8"));
|
|
201
|
+
pkg.module = "src/index.ts";
|
|
202
|
+
pkg.scripts = { dev: "bun --watch src/index.ts", start: "bun src/index.ts" };
|
|
203
|
+
pkg.dependencies = { ...pkg.dependencies, "@lastshotlabs/bunshot": "*" };
|
|
204
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
|
|
205
|
+
|
|
206
|
+
// Patch tsconfig.json: add path aliases
|
|
207
|
+
const tsconfigPath = join(projectDir, "tsconfig.json");
|
|
208
|
+
const tsconfig = JSON.parse(require("fs").readFileSync(tsconfigPath, "utf-8"));
|
|
209
|
+
tsconfig.compilerOptions = {
|
|
210
|
+
...tsconfig.compilerOptions,
|
|
211
|
+
"paths": {
|
|
212
|
+
"@lib/*": ["./src/lib/*"],
|
|
213
|
+
"@middleware/*": ["./src/middleware/*"],
|
|
214
|
+
"@models/*": ["./src/models/*"],
|
|
215
|
+
"@queues/*": ["./src/queues/*"],
|
|
216
|
+
"@routes/*": ["./src/routes/*"],
|
|
217
|
+
"@scripts/*": ["./src/scripts/*"],
|
|
218
|
+
"@services/*": ["./src/services/*"],
|
|
219
|
+
"@workers/*": ["./src/workers/*"],
|
|
220
|
+
"@queues": ["./src/queues/index.ts"],
|
|
221
|
+
"@jobs": ["./src/queues/jobs.ts"],
|
|
222
|
+
"@ws": ["./src/ws/index.ts"],
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n", "utf-8");
|
|
226
|
+
|
|
227
|
+
// Create src structure
|
|
228
|
+
mkdirSync(routesDir, { recursive: true });
|
|
229
|
+
mkdirSync(workersDir, { recursive: true });
|
|
230
|
+
mkdirSync(queuesDir, { recursive: true });
|
|
231
|
+
mkdirSync(wsDir, { recursive: true });
|
|
232
|
+
mkdirSync(servicesDir, { recursive: true });
|
|
233
|
+
writeFileSync(join(srcDir, "index.ts"), indexContent, "utf-8");
|
|
234
|
+
writeFileSync(join(projectDir, ".env"), envContent, "utf-8");
|
|
235
|
+
writeFileSync(join(projectDir, "README.md"), readmeContent, "utf-8");
|
|
236
|
+
|
|
237
|
+
console.log(" Created:");
|
|
238
|
+
console.log(` + ${dirName}/src/index.ts`);
|
|
239
|
+
console.log(` + ${dirName}/src/routes/`);
|
|
240
|
+
console.log(` + ${dirName}/src/workers/`);
|
|
241
|
+
console.log(` + ${dirName}/src/queues/`);
|
|
242
|
+
console.log(` + ${dirName}/src/ws/`);
|
|
243
|
+
console.log(` + ${dirName}/src/services/`);
|
|
244
|
+
console.log(` + ${dirName}/.env`);
|
|
245
|
+
console.log(` + ${dirName}/README.md`);
|
|
246
|
+
|
|
247
|
+
// --- git init ---
|
|
248
|
+
console.log("\n Initializing git...");
|
|
249
|
+
const git = spawnSync("git", ["init"], { cwd: projectDir, stdio: "inherit" });
|
|
250
|
+
if (git.status !== 0) {
|
|
251
|
+
console.error(" git init failed — skipping.");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- bun install ---
|
|
255
|
+
console.log("\n Installing dependencies...");
|
|
256
|
+
const install = spawnSync("bun", ["install"], { cwd: projectDir, stdio: "inherit" });
|
|
257
|
+
if (install.status !== 0) {
|
|
258
|
+
console.error("\n bun install failed. Run it manually inside the directory.");
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log(`\nDone! Next steps:\n`);
|
|
263
|
+
console.log(` cd ${dirName}`);
|
|
264
|
+
console.log(` # fill in .env`);
|
|
265
|
+
console.log(` bun dev\n`);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// App factory
|
|
2
|
+
export { createApp } from "./app";
|
|
3
|
+
export { createServer } from "./server";
|
|
4
|
+
export type { CreateAppConfig, DbConfig, AppMeta, AuthConfig, AuthRateLimitConfig, OAuthConfig, SecurityConfig, BotProtectionConfig, PrimaryField, EmailVerificationConfig } from "./app";
|
|
5
|
+
export type { CreateServerConfig, WsConfig } from "./server";
|
|
6
|
+
|
|
7
|
+
// Lib utilities
|
|
8
|
+
export { getAppRoles } from "@lib/appConfig";
|
|
9
|
+
export { HttpError } from "@lib/HttpError";
|
|
10
|
+
export { COOKIE_TOKEN, HEADER_USER_TOKEN } from "@lib/constants";
|
|
11
|
+
export { createRouter } from "@lib/context";
|
|
12
|
+
export type { AppEnv, AppVariables } from "@lib/context";
|
|
13
|
+
export { signToken, verifyToken } from "@lib/jwt";
|
|
14
|
+
export { log } from "@lib/logger";
|
|
15
|
+
export { connectMongo, connectAuthMongo, connectAppMongo, authConnection, appConnection, mongoose } from "@lib/mongo";
|
|
16
|
+
export { connectRedis, getRedis } from "@lib/redis";
|
|
17
|
+
export { createQueue, createWorker } from "@lib/queue";
|
|
18
|
+
export type { Job } from "@lib/queue";
|
|
19
|
+
export { createSession, getSession, deleteSession, setSessionStore } from "@lib/session";
|
|
20
|
+
export { createVerificationToken, getVerificationToken, deleteVerificationToken } from "@lib/emailVerification";
|
|
21
|
+
export { bustAuthLimit, trackAttempt, isLimited } from "@lib/authRateLimit";
|
|
22
|
+
export type { LimitOpts } from "@lib/authRateLimit";
|
|
23
|
+
export { validate } from "@lib/validate";
|
|
24
|
+
|
|
25
|
+
// Middleware
|
|
26
|
+
export { bearerAuth } from "@middleware/bearerAuth";
|
|
27
|
+
export { botProtection } from "@middleware/botProtection";
|
|
28
|
+
export type { BotProtectionOptions } from "@middleware/botProtection";
|
|
29
|
+
export { identify } from "@middleware/identify";
|
|
30
|
+
export { rateLimit } from "@middleware/rateLimit";
|
|
31
|
+
export type { RateLimitOptions } from "@middleware/rateLimit";
|
|
32
|
+
export { userAuth } from "@middleware/userAuth";
|
|
33
|
+
export { requireRole } from "@middleware/requireRole";
|
|
34
|
+
export { requireVerifiedEmail } from "@middleware/requireVerifiedEmail";
|
|
35
|
+
export { cacheResponse, bustCache, bustCachePattern, setCacheStore } from "@middleware/cacheResponse";
|
|
36
|
+
|
|
37
|
+
// Lib utilities (bot protection)
|
|
38
|
+
export { buildFingerprint } from "@lib/fingerprint";
|
|
39
|
+
|
|
40
|
+
// Models
|
|
41
|
+
export { AuthUser } from "@models/AuthUser";
|
|
42
|
+
export { mongoAuthAdapter } from "./adapters/mongoAuth";
|
|
43
|
+
export { sqliteAuthAdapter, setSqliteDb, startSqliteCleanup } from "./adapters/sqliteAuth";
|
|
44
|
+
export { memoryAuthAdapter, clearMemoryStore } from "./adapters/memoryAuth";
|
|
45
|
+
export { setUserRoles, addUserRole, removeUserRole } from "@lib/roles";
|
|
46
|
+
export type { AuthAdapter, OAuthProfile } from "@lib/authAdapter";
|
|
47
|
+
export type { OAuthProviderConfig } from "@lib/oauth";
|
|
48
|
+
|
|
49
|
+
// WebSocket
|
|
50
|
+
export { websocket, createWsUpgradeHandler } from "@ws/index";
|
|
51
|
+
export type { SocketData } from "@ws/index";
|
|
52
|
+
export { publish, subscribe, unsubscribe, getSubscriptions, handleRoomActions, getRooms, getRoomSubscribers } from "@lib/ws";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type PrimaryField = "email" | "username" | "phone";
|
|
2
|
+
|
|
3
|
+
export interface EmailVerificationConfig {
|
|
4
|
+
/** Block login until email is verified. Defaults to false (soft gate — emailVerified returned in login response). */
|
|
5
|
+
required?: boolean;
|
|
6
|
+
/** Called after registration with the identifier and verification token. Use to send the email. */
|
|
7
|
+
onSend: (email: string, token: string) => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let appName = "Core API";
|
|
11
|
+
let appRoles: string[] = [];
|
|
12
|
+
let defaultRole: string | null = null;
|
|
13
|
+
let _primaryField: PrimaryField = "email";
|
|
14
|
+
let _emailVerificationConfig: EmailVerificationConfig | null = null;
|
|
15
|
+
|
|
16
|
+
export const setAppName = (name: string) => { appName = name; };
|
|
17
|
+
export const getAppName = () => appName;
|
|
18
|
+
|
|
19
|
+
export const setAppRoles = (roles: string[]) => { appRoles = roles; };
|
|
20
|
+
export const getAppRoles = () => appRoles;
|
|
21
|
+
|
|
22
|
+
export const setDefaultRole = (role: string | null) => { defaultRole = role; };
|
|
23
|
+
export const getDefaultRole = () => defaultRole;
|
|
24
|
+
|
|
25
|
+
export const setPrimaryField = (field: PrimaryField) => { _primaryField = field; };
|
|
26
|
+
export const getPrimaryField = () => _primaryField;
|
|
27
|
+
|
|
28
|
+
export const setEmailVerificationConfig = (config: EmailVerificationConfig | null) => { _emailVerificationConfig = config; };
|
|
29
|
+
export const getEmailVerificationConfig = () => _emailVerificationConfig;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface OAuthProfile {
|
|
2
|
+
email?: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
avatarUrl?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface AuthAdapter {
|
|
8
|
+
findByEmail(email: string): Promise<{ id: string; passwordHash: string } | null>;
|
|
9
|
+
create(email: string, passwordHash: string): Promise<{ id: string }>;
|
|
10
|
+
/** Required when using OAuth providers. Find or create a user by provider + provider user ID. */
|
|
11
|
+
findOrCreateByProvider?(provider: string, providerId: string, profile: OAuthProfile): Promise<{ id: string; created: boolean }>;
|
|
12
|
+
/** Optional. Set or update the password hash for a user (used by /auth/set-password). */
|
|
13
|
+
setPassword?(userId: string, passwordHash: string): Promise<void>;
|
|
14
|
+
/** Optional. Link a provider identity to an existing user (used by /auth/:provider/link). */
|
|
15
|
+
linkProvider?(userId: string, provider: string, providerId: string): Promise<void>;
|
|
16
|
+
/** Optional. Return the roles assigned to a user (used by requireRole middleware). */
|
|
17
|
+
getRoles?(userId: string): Promise<string[]>;
|
|
18
|
+
/** Optional. Set the roles for a user, replacing any existing roles. */
|
|
19
|
+
setRoles?(userId: string, roles: string[]): Promise<void>;
|
|
20
|
+
/** Optional. Add a single role to a user without affecting their other roles. */
|
|
21
|
+
addRole?(userId: string, role: string): Promise<void>;
|
|
22
|
+
/** Optional. Remove a single role from a user without affecting their other roles. */
|
|
23
|
+
removeRole?(userId: string, role: string): Promise<void>;
|
|
24
|
+
/** Optional. Return basic profile info for a user by ID (used by GET /auth/me). */
|
|
25
|
+
getUser?(userId: string): Promise<{ email?: string; providerIds?: string[]; emailVerified?: boolean } | null>;
|
|
26
|
+
/** Optional. Unlink a provider identity from a user (used by DELETE /auth/:provider/link). */
|
|
27
|
+
unlinkProvider?(userId: string, provider: string): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Optional. Look up a user by their primary identifier (email, username, or phone depending on config).
|
|
30
|
+
* When provided, used instead of findByEmail for credential login/register flows.
|
|
31
|
+
*/
|
|
32
|
+
findByIdentifier?(value: string): Promise<{ id: string; passwordHash: string } | null>;
|
|
33
|
+
/** Optional. Mark a user's email address as verified (used by POST /auth/verify-email). */
|
|
34
|
+
setEmailVerified?(userId: string, verified: boolean): Promise<void>;
|
|
35
|
+
/** Optional. Return whether a user's email address has been verified. */
|
|
36
|
+
getEmailVerified?(userId: string): Promise<boolean>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let _adapter: AuthAdapter | null = null;
|
|
40
|
+
|
|
41
|
+
export const setAuthAdapter = (adapter: AuthAdapter) => { _adapter = adapter; };
|
|
42
|
+
|
|
43
|
+
export const getAuthAdapter = (): AuthAdapter => {
|
|
44
|
+
if (!_adapter) throw new Error("No auth adapter set — pass authAdapter to createApp/createServer, or call setAuthAdapter()");
|
|
45
|
+
return _adapter;
|
|
46
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { getAppName } from "@lib/appConfig";
|
|
2
|
+
|
|
3
|
+
interface AuthRateLimitEntry {
|
|
4
|
+
count: number;
|
|
5
|
+
resetAt: number; // epoch ms
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface AuthRateLimitStore {
|
|
9
|
+
get(key: string): Promise<AuthRateLimitEntry | null>;
|
|
10
|
+
set(key: string, entry: AuthRateLimitEntry, ttlMs: number): Promise<void>;
|
|
11
|
+
delete(key: string): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Memory implementation
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const _memoryStore = new Map<string, AuthRateLimitEntry>();
|
|
19
|
+
|
|
20
|
+
const memoryStore: AuthRateLimitStore = {
|
|
21
|
+
async get(key) {
|
|
22
|
+
const entry = _memoryStore.get(key);
|
|
23
|
+
if (!entry) return null;
|
|
24
|
+
if (entry.resetAt <= Date.now()) {
|
|
25
|
+
_memoryStore.delete(key);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return entry;
|
|
29
|
+
},
|
|
30
|
+
async set(key, entry) {
|
|
31
|
+
_memoryStore.set(key, entry);
|
|
32
|
+
},
|
|
33
|
+
async delete(key) {
|
|
34
|
+
_memoryStore.delete(key);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Redis implementation
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const redisStore: AuthRateLimitStore = {
|
|
43
|
+
async get(key) {
|
|
44
|
+
const { getRedis } = await import("@lib/redis");
|
|
45
|
+
const raw = await getRedis().get(`rl:${getAppName()}:${key}`);
|
|
46
|
+
if (!raw) return null;
|
|
47
|
+
const entry: AuthRateLimitEntry = JSON.parse(raw);
|
|
48
|
+
if (entry.resetAt <= Date.now()) return null;
|
|
49
|
+
return entry;
|
|
50
|
+
},
|
|
51
|
+
async set(key, entry, ttlMs) {
|
|
52
|
+
const { getRedis } = await import("@lib/redis");
|
|
53
|
+
await getRedis().set(`rl:${getAppName()}:${key}`, JSON.stringify(entry), "PX", ttlMs);
|
|
54
|
+
},
|
|
55
|
+
async delete(key) {
|
|
56
|
+
const { getRedis } = await import("@lib/redis");
|
|
57
|
+
await getRedis().del(`rl:${getAppName()}:${key}`);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Active store + setter
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
let _store: AuthRateLimitStore = memoryStore;
|
|
66
|
+
|
|
67
|
+
export const setAuthRateLimitStore = (store: "memory" | "redis"): void => {
|
|
68
|
+
_store = store === "redis" ? redisStore : memoryStore;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Public API
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export interface LimitOpts {
|
|
76
|
+
windowMs: number;
|
|
77
|
+
max: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Returns true if the key is currently over the limit (read-only, no increment). */
|
|
81
|
+
export const isLimited = async (key: string, opts: LimitOpts): Promise<boolean> => {
|
|
82
|
+
const entry = await _store.get(key);
|
|
83
|
+
if (!entry) return false;
|
|
84
|
+
return entry.count >= opts.max;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Increments the counter and returns true if now over the limit. */
|
|
88
|
+
export const trackAttempt = async (key: string, opts: LimitOpts): Promise<boolean> => {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const existing = await _store.get(key);
|
|
91
|
+
if (!existing) {
|
|
92
|
+
await _store.set(key, { count: 1, resetAt: now + opts.windowMs }, opts.windowMs);
|
|
93
|
+
return 1 >= opts.max;
|
|
94
|
+
}
|
|
95
|
+
const updated: AuthRateLimitEntry = { count: existing.count + 1, resetAt: existing.resetAt };
|
|
96
|
+
const remaining = Math.max(1, existing.resetAt - now);
|
|
97
|
+
await _store.set(key, updated, remaining);
|
|
98
|
+
return updated.count >= opts.max;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Resets a rate limit key. Use on login success or for admin unlock. */
|
|
102
|
+
export const bustAuthLimit = async (key: string): Promise<void> => {
|
|
103
|
+
await _store.delete(key);
|
|
104
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { OpenAPIHono, type Hook } from "@hono/zod-openapi";
|
|
2
|
+
|
|
3
|
+
export type AppVariables = {
|
|
4
|
+
authUserId: string | null;
|
|
5
|
+
roles: string[] | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type AppEnv = { Variables: AppVariables };
|
|
9
|
+
|
|
10
|
+
const defaultHook: Hook<any, AppEnv, any, any> = (result, c) => {
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
const message = result.error.issues.map((i: { message: string }) => i.message).join(", ");
|
|
13
|
+
return c.json({ error: message }, 400);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const createRouter = () => new OpenAPIHono<AppEnv>({ defaultHook });
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getRedis } from "./redis";
|
|
2
|
+
import { appConnection } from "./mongo";
|
|
3
|
+
import { getAppName } from "./appConfig";
|
|
4
|
+
import { Schema } from "mongoose";
|
|
5
|
+
import {
|
|
6
|
+
sqliteCreateVerificationToken,
|
|
7
|
+
sqliteGetVerificationToken,
|
|
8
|
+
sqliteDeleteVerificationToken,
|
|
9
|
+
} from "../adapters/sqliteAuth";
|
|
10
|
+
import {
|
|
11
|
+
memoryCreateVerificationToken,
|
|
12
|
+
memoryGetVerificationToken,
|
|
13
|
+
memoryDeleteVerificationToken,
|
|
14
|
+
} from "../adapters/memoryAuth";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Mongo model
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
interface VerificationDoc {
|
|
21
|
+
token: string;
|
|
22
|
+
userId: string;
|
|
23
|
+
email: string;
|
|
24
|
+
expiresAt: Date;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const verificationSchema = new Schema<VerificationDoc>(
|
|
28
|
+
{
|
|
29
|
+
token: { type: String, required: true, unique: true },
|
|
30
|
+
userId: { type: String, required: true },
|
|
31
|
+
email: { type: String, required: true },
|
|
32
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
33
|
+
},
|
|
34
|
+
{ collection: "email_verifications" }
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
function getVerificationModel() {
|
|
38
|
+
return appConnection.models["EmailVerification"] ??
|
|
39
|
+
appConnection.model<VerificationDoc>("EmailVerification", verificationSchema);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Store configuration
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
type VerificationStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
47
|
+
let _store: VerificationStore = "redis";
|
|
48
|
+
export const setEmailVerificationStore = (store: VerificationStore) => { _store = store; };
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// TTL
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const TTL_SECONDS = 60 * 60 * 24; // 24 hours
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Public API
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export const createVerificationToken = async (userId: string, email: string): Promise<string> => {
|
|
61
|
+
const token = crypto.randomUUID();
|
|
62
|
+
if (_store === "memory") { memoryCreateVerificationToken(token, userId, email); return token; }
|
|
63
|
+
if (_store === "sqlite") { sqliteCreateVerificationToken(token, userId, email); return token; }
|
|
64
|
+
if (_store === "mongo") {
|
|
65
|
+
await getVerificationModel().create({
|
|
66
|
+
token,
|
|
67
|
+
userId,
|
|
68
|
+
email,
|
|
69
|
+
expiresAt: new Date(Date.now() + TTL_SECONDS * 1000),
|
|
70
|
+
});
|
|
71
|
+
return token;
|
|
72
|
+
}
|
|
73
|
+
await getRedis().set(
|
|
74
|
+
`verify:${getAppName()}:${token}`,
|
|
75
|
+
JSON.stringify({ userId, email }),
|
|
76
|
+
"EX",
|
|
77
|
+
TTL_SECONDS
|
|
78
|
+
);
|
|
79
|
+
return token;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const getVerificationToken = async (token: string): Promise<{ userId: string; email: string } | null> => {
|
|
83
|
+
if (_store === "memory") return memoryGetVerificationToken(token);
|
|
84
|
+
if (_store === "sqlite") return sqliteGetVerificationToken(token);
|
|
85
|
+
if (_store === "mongo") {
|
|
86
|
+
const doc = await getVerificationModel()
|
|
87
|
+
.findOne({ token, expiresAt: { $gt: new Date() } })
|
|
88
|
+
.lean();
|
|
89
|
+
if (!doc) return null;
|
|
90
|
+
return { userId: doc.userId, email: doc.email };
|
|
91
|
+
}
|
|
92
|
+
const raw = await getRedis().get(`verify:${getAppName()}:${token}`);
|
|
93
|
+
if (!raw) return null;
|
|
94
|
+
return JSON.parse(raw) as { userId: string; email: string };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const deleteVerificationToken = async (token: string): Promise<void> => {
|
|
98
|
+
if (_store === "memory") { memoryDeleteVerificationToken(token); return; }
|
|
99
|
+
if (_store === "sqlite") { sqliteDeleteVerificationToken(token); return; }
|
|
100
|
+
if (_store === "mongo") {
|
|
101
|
+
await getVerificationModel().deleteOne({ token });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await getRedis().del(`verify:${getAppName()}:${token}`);
|
|
105
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const BROWSER_HEADERS = [
|
|
2
|
+
"sec-fetch-site",
|
|
3
|
+
"sec-fetch-mode",
|
|
4
|
+
"sec-fetch-dest",
|
|
5
|
+
"sec-ch-ua",
|
|
6
|
+
"sec-ch-ua-mobile",
|
|
7
|
+
"sec-ch-ua-platform",
|
|
8
|
+
"origin",
|
|
9
|
+
"referer",
|
|
10
|
+
"x-requested-with",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Builds a 12-hex-char fingerprint from stable HTTP headers.
|
|
17
|
+
* IP-independent: bots that rotate IPs but use the same HTTP client
|
|
18
|
+
* will produce the same fingerprint and share a rate-limit bucket.
|
|
19
|
+
*/
|
|
20
|
+
export async function buildFingerprint(req: Request): Promise<string> {
|
|
21
|
+
const h = (name: string) => req.headers.get(name) ?? "";
|
|
22
|
+
|
|
23
|
+
// Encode which browser-only headers are present as a bitmask string.
|
|
24
|
+
// Real browsers send most of these; raw HTTP clients send none.
|
|
25
|
+
const bitmap = BROWSER_HEADERS.map((name) =>
|
|
26
|
+
req.headers.has(name) ? "1" : "0"
|
|
27
|
+
).join("");
|
|
28
|
+
|
|
29
|
+
const raw = [
|
|
30
|
+
h("user-agent"),
|
|
31
|
+
h("accept"),
|
|
32
|
+
h("accept-language"),
|
|
33
|
+
h("accept-encoding"),
|
|
34
|
+
h("connection"),
|
|
35
|
+
bitmap,
|
|
36
|
+
].join("|");
|
|
37
|
+
|
|
38
|
+
const buf = await crypto.subtle.digest("SHA-256", encoder.encode(raw));
|
|
39
|
+
const bytes = new Uint8Array(buf).slice(0, 6);
|
|
40
|
+
return Array.from(bytes)
|
|
41
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
42
|
+
.join("");
|
|
43
|
+
}
|