@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.
Files changed (50) hide show
  1. package/CLAUDE.md +102 -0
  2. package/README.md +1458 -0
  3. package/bun.lock +170 -0
  4. package/package.json +47 -0
  5. package/src/adapters/memoryAuth.ts +240 -0
  6. package/src/adapters/mongoAuth.ts +91 -0
  7. package/src/adapters/sqliteAuth.ts +320 -0
  8. package/src/app.ts +368 -0
  9. package/src/cli.ts +265 -0
  10. package/src/index.ts +52 -0
  11. package/src/lib/HttpError.ts +5 -0
  12. package/src/lib/appConfig.ts +29 -0
  13. package/src/lib/authAdapter.ts +46 -0
  14. package/src/lib/authRateLimit.ts +104 -0
  15. package/src/lib/constants.ts +2 -0
  16. package/src/lib/context.ts +17 -0
  17. package/src/lib/emailVerification.ts +105 -0
  18. package/src/lib/fingerprint.ts +43 -0
  19. package/src/lib/jwt.ts +17 -0
  20. package/src/lib/logger.ts +9 -0
  21. package/src/lib/mongo.ts +70 -0
  22. package/src/lib/oauth.ts +114 -0
  23. package/src/lib/queue.ts +18 -0
  24. package/src/lib/redis.ts +45 -0
  25. package/src/lib/roles.ts +23 -0
  26. package/src/lib/session.ts +91 -0
  27. package/src/lib/validate.ts +14 -0
  28. package/src/lib/ws.ts +82 -0
  29. package/src/middleware/bearerAuth.ts +15 -0
  30. package/src/middleware/botProtection.ts +73 -0
  31. package/src/middleware/cacheResponse.ts +189 -0
  32. package/src/middleware/cors.ts +19 -0
  33. package/src/middleware/errorHandler.ts +14 -0
  34. package/src/middleware/identify.ts +36 -0
  35. package/src/middleware/index.ts +8 -0
  36. package/src/middleware/logger.ts +9 -0
  37. package/src/middleware/rateLimit.ts +37 -0
  38. package/src/middleware/requireRole.ts +42 -0
  39. package/src/middleware/requireVerifiedEmail.ts +31 -0
  40. package/src/middleware/userAuth.ts +9 -0
  41. package/src/models/AuthUser.ts +17 -0
  42. package/src/routes/auth.ts +245 -0
  43. package/src/routes/health.ts +27 -0
  44. package/src/routes/home.ts +21 -0
  45. package/src/routes/oauth.ts +174 -0
  46. package/src/schemas/auth.ts +14 -0
  47. package/src/server.ts +91 -0
  48. package/src/services/auth.ts +59 -0
  49. package/src/ws/index.ts +42 -0
  50. 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,5 @@
1
+ export class HttpError extends Error {
2
+ constructor(public status: number, message: string) {
3
+ super(message);
4
+ }
5
+ }
@@ -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,2 @@
1
+ export const COOKIE_TOKEN = "token";
2
+ export const HEADER_USER_TOKEN = "x-user-token";
@@ -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
+ }