@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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { HttpError } from "@lib/HttpError";
|
|
3
|
+
import type { AuthAdapter } from "@lib/authAdapter";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// DB singleton — call setSqliteDb(path) once at startup
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
let _db: Database | null = null;
|
|
10
|
+
|
|
11
|
+
export const setSqliteDb = (path: string): void => {
|
|
12
|
+
_db = new Database(path, { create: true });
|
|
13
|
+
_db.run("PRAGMA journal_mode = WAL");
|
|
14
|
+
_db.run("PRAGMA foreign_keys = ON");
|
|
15
|
+
initSchema(_db);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function getDb(): Database {
|
|
19
|
+
if (!_db) throw new Error("SQLite not initialized — call setSqliteDb(path) before using sqliteAuthAdapter or sessionStore: 'sqlite'");
|
|
20
|
+
return _db;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Schema
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function initSchema(db: Database): void {
|
|
28
|
+
db.run(`CREATE TABLE IF NOT EXISTS users (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
email TEXT UNIQUE,
|
|
31
|
+
passwordHash TEXT,
|
|
32
|
+
providerIds TEXT NOT NULL DEFAULT '[]',
|
|
33
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
34
|
+
emailVerified INTEGER NOT NULL DEFAULT 0
|
|
35
|
+
)`);
|
|
36
|
+
// Add emailVerified to pre-existing databases that lack the column
|
|
37
|
+
try { db.run("ALTER TABLE users ADD COLUMN emailVerified INTEGER NOT NULL DEFAULT 0"); } catch { /* already exists */ }
|
|
38
|
+
db.run(`CREATE TABLE IF NOT EXISTS sessions (
|
|
39
|
+
userId TEXT PRIMARY KEY,
|
|
40
|
+
token TEXT NOT NULL,
|
|
41
|
+
expiresAt INTEGER NOT NULL
|
|
42
|
+
)`);
|
|
43
|
+
db.run(`CREATE TABLE IF NOT EXISTS oauth_states (
|
|
44
|
+
state TEXT PRIMARY KEY,
|
|
45
|
+
codeVerifier TEXT,
|
|
46
|
+
linkUserId TEXT,
|
|
47
|
+
expiresAt INTEGER NOT NULL
|
|
48
|
+
)`);
|
|
49
|
+
db.run(`CREATE TABLE IF NOT EXISTS cache_entries (
|
|
50
|
+
key TEXT PRIMARY KEY,
|
|
51
|
+
value TEXT NOT NULL,
|
|
52
|
+
expiresAt INTEGER -- NULL = indefinite
|
|
53
|
+
)`);
|
|
54
|
+
db.run(`CREATE TABLE IF NOT EXISTS email_verifications (
|
|
55
|
+
token TEXT PRIMARY KEY,
|
|
56
|
+
userId TEXT NOT NULL,
|
|
57
|
+
email TEXT NOT NULL,
|
|
58
|
+
expiresAt INTEGER NOT NULL
|
|
59
|
+
)`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Auth adapter
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export const sqliteAuthAdapter: AuthAdapter = {
|
|
67
|
+
async findByEmail(email) {
|
|
68
|
+
const row = getDb().query<{ id: string; passwordHash: string }, [string]>(
|
|
69
|
+
"SELECT id, passwordHash FROM users WHERE email = ?"
|
|
70
|
+
).get(email);
|
|
71
|
+
return row ?? null;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async create(email, passwordHash) {
|
|
75
|
+
const id = crypto.randomUUID();
|
|
76
|
+
try {
|
|
77
|
+
getDb().run("INSERT INTO users (id, email, passwordHash) VALUES (?, ?, ?)", [id, email, passwordHash]);
|
|
78
|
+
return { id };
|
|
79
|
+
} catch (err: any) {
|
|
80
|
+
if (err?.code === "SQLITE_CONSTRAINT_UNIQUE") throw new HttpError(409, "Email already registered");
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async setPassword(userId, passwordHash) {
|
|
86
|
+
getDb().run("UPDATE users SET passwordHash = ? WHERE id = ?", [passwordHash, userId]);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async findOrCreateByProvider(provider, providerId, profile) {
|
|
90
|
+
const key = `${provider}:${providerId}`;
|
|
91
|
+
const db = getDb();
|
|
92
|
+
|
|
93
|
+
// Find by provider key using json_each
|
|
94
|
+
const existing = db.query<{ id: string }, [string]>(
|
|
95
|
+
"SELECT u.id FROM users u, json_each(u.providerIds) p WHERE p.value = ?"
|
|
96
|
+
).get(key);
|
|
97
|
+
if (existing) return { id: existing.id, created: false };
|
|
98
|
+
|
|
99
|
+
// Reject if email belongs to a credential account
|
|
100
|
+
if (profile.email) {
|
|
101
|
+
const emailUser = db.query<{ id: string }, [string]>(
|
|
102
|
+
"SELECT id FROM users WHERE email = ?"
|
|
103
|
+
).get(profile.email);
|
|
104
|
+
if (emailUser) throw new HttpError(409, "An account with this email already exists. Sign in with your credentials, then link Google from your account settings.");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const id = crypto.randomUUID();
|
|
108
|
+
db.run(
|
|
109
|
+
"INSERT INTO users (id, email, providerIds) VALUES (?, ?, ?)",
|
|
110
|
+
[id, profile.email ?? null, JSON.stringify([key])]
|
|
111
|
+
);
|
|
112
|
+
return { id, created: true };
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async linkProvider(userId, provider, providerId) {
|
|
116
|
+
const key = `${provider}:${providerId}`;
|
|
117
|
+
const db = getDb();
|
|
118
|
+
const row = db.query<{ id: string; providerIds: string }, [string]>(
|
|
119
|
+
"SELECT id, providerIds FROM users WHERE id = ?"
|
|
120
|
+
).get(userId);
|
|
121
|
+
if (!row) throw new HttpError(404, "User not found");
|
|
122
|
+
const ids: string[] = JSON.parse(row.providerIds);
|
|
123
|
+
if (!ids.includes(key)) {
|
|
124
|
+
db.run("UPDATE users SET providerIds = ? WHERE id = ?", [JSON.stringify([...ids, key]), userId]);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async getRoles(userId) {
|
|
129
|
+
const row = getDb().query<{ roles: string }, [string]>(
|
|
130
|
+
"SELECT roles FROM users WHERE id = ?"
|
|
131
|
+
).get(userId);
|
|
132
|
+
return row ? JSON.parse(row.roles) : [];
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async setRoles(userId, roles) {
|
|
136
|
+
getDb().run("UPDATE users SET roles = ? WHERE id = ?", [JSON.stringify(roles), userId]);
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async addRole(userId, role) {
|
|
140
|
+
const db = getDb();
|
|
141
|
+
const row = db.query<{ roles: string }, [string]>("SELECT roles FROM users WHERE id = ?").get(userId);
|
|
142
|
+
if (!row) return;
|
|
143
|
+
const roles: string[] = JSON.parse(row.roles);
|
|
144
|
+
if (!roles.includes(role)) {
|
|
145
|
+
db.run("UPDATE users SET roles = ? WHERE id = ?", [JSON.stringify([...roles, role]), userId]);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async removeRole(userId, role) {
|
|
150
|
+
const db = getDb();
|
|
151
|
+
const row = db.query<{ roles: string }, [string]>("SELECT roles FROM users WHERE id = ?").get(userId);
|
|
152
|
+
if (!row) return;
|
|
153
|
+
const roles: string[] = JSON.parse(row.roles);
|
|
154
|
+
db.run("UPDATE users SET roles = ? WHERE id = ?", [JSON.stringify(roles.filter((r) => r !== role)), userId]);
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async getUser(userId) {
|
|
158
|
+
const row = getDb().query<{ email: string | null; providerIds: string; emailVerified: number }, [string]>(
|
|
159
|
+
"SELECT email, providerIds, emailVerified FROM users WHERE id = ?"
|
|
160
|
+
).get(userId);
|
|
161
|
+
if (!row) return null;
|
|
162
|
+
return {
|
|
163
|
+
email: row.email ?? undefined,
|
|
164
|
+
providerIds: JSON.parse(row.providerIds),
|
|
165
|
+
emailVerified: row.emailVerified === 1,
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async unlinkProvider(userId, provider) {
|
|
170
|
+
const db = getDb();
|
|
171
|
+
const row = db.query<{ providerIds: string }, [string]>(
|
|
172
|
+
"SELECT providerIds FROM users WHERE id = ?"
|
|
173
|
+
).get(userId);
|
|
174
|
+
if (!row) throw new HttpError(404, "User not found");
|
|
175
|
+
const ids: string[] = JSON.parse(row.providerIds);
|
|
176
|
+
db.run(
|
|
177
|
+
"UPDATE users SET providerIds = ? WHERE id = ?",
|
|
178
|
+
[JSON.stringify(ids.filter((id) => !id.startsWith(`${provider}:`))), userId]
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async findByIdentifier(value) {
|
|
183
|
+
const row = getDb().query<{ id: string; passwordHash: string }, [string]>(
|
|
184
|
+
"SELECT id, passwordHash FROM users WHERE email = ?"
|
|
185
|
+
).get(value);
|
|
186
|
+
return row ?? null;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async setEmailVerified(userId, verified) {
|
|
190
|
+
getDb().run("UPDATE users SET emailVerified = ? WHERE id = ?", [verified ? 1 : 0, userId]);
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async getEmailVerified(userId) {
|
|
194
|
+
const row = getDb().query<{ emailVerified: number }, [string]>(
|
|
195
|
+
"SELECT emailVerified FROM users WHERE id = ?"
|
|
196
|
+
).get(userId);
|
|
197
|
+
return row?.emailVerified === 1;
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Session helpers (used by src/lib/session.ts)
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
const SESSION_TTL_MS = 60 * 60 * 24 * 7 * 1000; // 7 days
|
|
206
|
+
|
|
207
|
+
export const sqliteCreateSession = (userId: string, token: string): void => {
|
|
208
|
+
const expiresAt = Date.now() + SESSION_TTL_MS;
|
|
209
|
+
getDb().run(
|
|
210
|
+
"INSERT INTO sessions (userId, token, expiresAt) VALUES (?, ?, ?) ON CONFLICT(userId) DO UPDATE SET token = excluded.token, expiresAt = excluded.expiresAt",
|
|
211
|
+
[userId, token, expiresAt]
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export const sqliteGetSession = (userId: string): string | null => {
|
|
216
|
+
const row = getDb().query<{ token: string }, [string, number]>(
|
|
217
|
+
"SELECT token FROM sessions WHERE userId = ? AND expiresAt > ?"
|
|
218
|
+
).get(userId, Date.now());
|
|
219
|
+
return row?.token ?? null;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export const sqliteDeleteSession = (userId: string): void => {
|
|
223
|
+
getDb().run("DELETE FROM sessions WHERE userId = ?", [userId]);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// OAuth state helpers (used by src/lib/oauth.ts)
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
const OAUTH_STATE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
231
|
+
|
|
232
|
+
export const sqliteStoreOAuthState = (state: string, codeVerifier?: string, linkUserId?: string): void => {
|
|
233
|
+
const expiresAt = Date.now() + OAUTH_STATE_TTL_MS;
|
|
234
|
+
getDb().run(
|
|
235
|
+
"INSERT INTO oauth_states (state, codeVerifier, linkUserId, expiresAt) VALUES (?, ?, ?, ?)",
|
|
236
|
+
[state, codeVerifier ?? null, linkUserId ?? null, expiresAt]
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export const sqliteConsumeOAuthState = (state: string): { codeVerifier?: string; linkUserId?: string } | null => {
|
|
241
|
+
const row = getDb().query<{ codeVerifier: string | null; linkUserId: string | null }, [string, number]>(
|
|
242
|
+
"DELETE FROM oauth_states WHERE state = ? AND expiresAt > ? RETURNING codeVerifier, linkUserId"
|
|
243
|
+
).get(state, Date.now());
|
|
244
|
+
if (!row) return null;
|
|
245
|
+
return {
|
|
246
|
+
codeVerifier: row.codeVerifier ?? undefined,
|
|
247
|
+
linkUserId: row.linkUserId ?? undefined,
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Cache helpers (used by src/middleware/cacheResponse.ts)
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
export const isSqliteReady = (): boolean => _db !== null;
|
|
256
|
+
|
|
257
|
+
export const sqliteGetCache = (key: string): string | null => {
|
|
258
|
+
const row = getDb().query<{ value: string }, [string, number]>(
|
|
259
|
+
"SELECT value FROM cache_entries WHERE key = ? AND (expiresAt IS NULL OR expiresAt > ?)"
|
|
260
|
+
).get(key, Date.now());
|
|
261
|
+
return row?.value ?? null;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export const sqliteSetCache = (key: string, value: string, ttlSeconds?: number): void => {
|
|
265
|
+
const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : null;
|
|
266
|
+
getDb().run(
|
|
267
|
+
"INSERT INTO cache_entries (key, value, expiresAt) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expiresAt = excluded.expiresAt",
|
|
268
|
+
[key, value, expiresAt]
|
|
269
|
+
);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export const sqliteDelCache = (key: string): void => {
|
|
273
|
+
getDb().run("DELETE FROM cache_entries WHERE key = ?", [key]);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export const sqliteDelCachePattern = (pattern: string): void => {
|
|
277
|
+
// Convert glob pattern (* wildcard) to a SQL LIKE pattern (% wildcard)
|
|
278
|
+
const likePattern = pattern.replace(/%/g, "\\%").replace(/_/g, "\\_").replace(/\*/g, "%");
|
|
279
|
+
getDb().run("DELETE FROM cache_entries WHERE key LIKE ? ESCAPE '\\'", [likePattern]);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Email verification token helpers (used by src/lib/emailVerification.ts)
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
const EMAIL_VERIFICATION_TTL_MS = 60 * 60 * 24 * 1000; // 24 hours
|
|
287
|
+
|
|
288
|
+
export const sqliteCreateVerificationToken = (token: string, userId: string, email: string): void => {
|
|
289
|
+
const expiresAt = Date.now() + EMAIL_VERIFICATION_TTL_MS;
|
|
290
|
+
getDb().run(
|
|
291
|
+
"INSERT INTO email_verifications (token, userId, email, expiresAt) VALUES (?, ?, ?, ?)",
|
|
292
|
+
[token, userId, email, expiresAt]
|
|
293
|
+
);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export const sqliteGetVerificationToken = (token: string): { userId: string; email: string } | null => {
|
|
297
|
+
const row = getDb().query<{ userId: string; email: string }, [string, number]>(
|
|
298
|
+
"SELECT userId, email FROM email_verifications WHERE token = ? AND expiresAt > ?"
|
|
299
|
+
).get(token, Date.now());
|
|
300
|
+
return row ?? null;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
export const sqliteDeleteVerificationToken = (token: string): void => {
|
|
304
|
+
getDb().run("DELETE FROM email_verifications WHERE token = ?", [token]);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Optional periodic cleanup of expired rows
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
export const startSqliteCleanup = (intervalMs = 3_600_000): ReturnType<typeof setInterval> => {
|
|
312
|
+
return setInterval(() => {
|
|
313
|
+
const db = getDb();
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
db.run("DELETE FROM sessions WHERE expiresAt <= ?", [now]);
|
|
316
|
+
db.run("DELETE FROM oauth_states WHERE expiresAt <= ?", [now]);
|
|
317
|
+
db.run("DELETE FROM cache_entries WHERE expiresAt IS NOT NULL AND expiresAt <= ?", [now]);
|
|
318
|
+
db.run("DELETE FROM email_verifications WHERE expiresAt <= ?", [now]);
|
|
319
|
+
}, intervalMs);
|
|
320
|
+
};
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import { cors } from "hono/cors";
|
|
3
|
+
import { logger } from "hono/logger";
|
|
4
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
5
|
+
import { Scalar } from "@scalar/hono-api-reference";
|
|
6
|
+
import type { MiddlewareHandler } from "hono";
|
|
7
|
+
import { HttpError } from "@lib/HttpError";
|
|
8
|
+
import { rateLimit } from "@middleware/rateLimit";
|
|
9
|
+
import { bearerAuth } from "@middleware/bearerAuth";
|
|
10
|
+
import { identify } from "@middleware/identify";
|
|
11
|
+
import type { AppEnv } from "@lib/context";
|
|
12
|
+
import { HEADER_USER_TOKEN } from "@lib/constants";
|
|
13
|
+
import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig } from "@lib/appConfig";
|
|
14
|
+
import type { PrimaryField, EmailVerificationConfig } from "@lib/appConfig";
|
|
15
|
+
import { setEmailVerificationStore } from "@lib/emailVerification";
|
|
16
|
+
import { setAuthRateLimitStore } from "@lib/authRateLimit";
|
|
17
|
+
import { setAuthAdapter } from "@lib/authAdapter";
|
|
18
|
+
import { mongoAuthAdapter } from "./adapters/mongoAuth";
|
|
19
|
+
import type { AuthAdapter } from "@lib/authAdapter";
|
|
20
|
+
import { memoryAuthAdapter } from "./adapters/memoryAuth";
|
|
21
|
+
import { initOAuthProviders, getConfiguredOAuthProviders, setOAuthStateStore } from "@lib/oauth";
|
|
22
|
+
import type { OAuthProviderConfig } from "@lib/oauth";
|
|
23
|
+
import { createOAuthRouter } from "@routes/oauth";
|
|
24
|
+
import { connectMongo, connectAuthMongo, connectAppMongo } from "@lib/mongo";
|
|
25
|
+
import { connectRedis } from "@lib/redis";
|
|
26
|
+
import { setSessionStore } from "@lib/session";
|
|
27
|
+
import { setCacheStore } from "@middleware/cacheResponse";
|
|
28
|
+
|
|
29
|
+
type StoreType = "redis" | "mongo" | "sqlite" | "memory";
|
|
30
|
+
|
|
31
|
+
export interface DbConfig {
|
|
32
|
+
/**
|
|
33
|
+
* Absolute path to the SQLite database file.
|
|
34
|
+
* Required when any store is "sqlite".
|
|
35
|
+
* Example: import.meta.dir + "/../data.db"
|
|
36
|
+
*/
|
|
37
|
+
sqlite?: string;
|
|
38
|
+
/**
|
|
39
|
+
* MongoDB auto-connect mode.
|
|
40
|
+
* - "single" (default): calls connectMongo() — auth and app share one server (MONGO_* env vars)
|
|
41
|
+
* - "separate": calls connectAuthMongo() + connectAppMongo() — auth on MONGO_AUTH_* server, app on MONGO_* server
|
|
42
|
+
* - false: skip auto-connect (call connectMongo / connectAuthMongo / connectAppMongo yourself)
|
|
43
|
+
*/
|
|
44
|
+
mongo?: "single" | "separate" | false;
|
|
45
|
+
/**
|
|
46
|
+
* Auto-connect Redis before starting. Defaults to true.
|
|
47
|
+
* Set false to skip (e.g. when using sqlite or memory stores only).
|
|
48
|
+
*/
|
|
49
|
+
redis?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Where to store JWT sessions. Default: "redis".
|
|
52
|
+
* Sessions are stored on appConnection (not authConnection) so they are isolated per-app
|
|
53
|
+
* in "separate" mongo mode.
|
|
54
|
+
*/
|
|
55
|
+
sessions?: StoreType;
|
|
56
|
+
/**
|
|
57
|
+
* Where to store OAuth state (PKCE code verifier, link user ID). Default: follows `sessions`.
|
|
58
|
+
*/
|
|
59
|
+
oauthState?: StoreType;
|
|
60
|
+
/**
|
|
61
|
+
* Global default store for cacheResponse middleware. Default: "redis".
|
|
62
|
+
* Can be overridden per-route via cacheResponse({ store: "..." }).
|
|
63
|
+
*/
|
|
64
|
+
cache?: StoreType;
|
|
65
|
+
/**
|
|
66
|
+
* Which built-in auth adapter to use for /auth/* routes.
|
|
67
|
+
* - "mongo" (default when mongo is enabled): Mongoose adapter (requires connectMongo)
|
|
68
|
+
* - "sqlite": bun:sqlite adapter (requires sqlite path)
|
|
69
|
+
* - "memory": in-memory Maps (ephemeral, great for tests)
|
|
70
|
+
* When `mongo: false`, defaults to the same store as `sessions`.
|
|
71
|
+
* Ignored when `auth.adapter` is explicitly passed in CreateAppConfig.
|
|
72
|
+
*/
|
|
73
|
+
auth?: "mongo" | "sqlite" | "memory";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AppMeta {
|
|
77
|
+
/** App name shown in the root endpoint and OpenAPI docs title. Defaults to "Bun Core API" */
|
|
78
|
+
name?: string;
|
|
79
|
+
/** Version shown in OpenAPI docs. Defaults to "1.0.0" */
|
|
80
|
+
version?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface OAuthConfig {
|
|
84
|
+
/** OAuth provider credentials. Configured providers get automatic /auth/{provider} routes. */
|
|
85
|
+
providers?: OAuthProviderConfig;
|
|
86
|
+
/** Where to redirect after a successful OAuth login. Defaults to "/" */
|
|
87
|
+
postRedirect?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface AuthRateLimitConfig {
|
|
91
|
+
/** Max login failures per window before the account is locked. Default: 10 per 15 min. */
|
|
92
|
+
login?: { windowMs?: number; max?: number };
|
|
93
|
+
/** Max registration attempts per IP per window. Default: 5 per hour. */
|
|
94
|
+
register?: { windowMs?: number; max?: number };
|
|
95
|
+
/** Max email verification attempts per IP per window. Default: 10 per 15 min. */
|
|
96
|
+
verifyEmail?: { windowMs?: number; max?: number };
|
|
97
|
+
/** Max resend-verification attempts per user per window. Default: 3 per hour. */
|
|
98
|
+
resendVerification?: { windowMs?: number; max?: number };
|
|
99
|
+
/**
|
|
100
|
+
* Store backend for auth rate limit counters.
|
|
101
|
+
* Defaults to "redis" when Redis is enabled, otherwise "memory".
|
|
102
|
+
* Use "redis" for multi-instance deployments so limits are shared across servers.
|
|
103
|
+
*/
|
|
104
|
+
store?: "memory" | "redis";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface AuthConfig {
|
|
108
|
+
/** Set false to skip mounting /auth/* routes. Defaults to true */
|
|
109
|
+
enabled?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Custom auth adapter for the built-in /auth/* routes.
|
|
112
|
+
* Use this for fully custom backends (e.g. Postgres).
|
|
113
|
+
* For built-in backends prefer `db.auth: "mongo" | "sqlite" | "memory"`.
|
|
114
|
+
* When both are set, this takes precedence.
|
|
115
|
+
*/
|
|
116
|
+
adapter?: AuthAdapter;
|
|
117
|
+
/** Valid roles for this app (e.g. ["admin", "editor", "user"]). Used by requireRole middleware. */
|
|
118
|
+
roles?: string[];
|
|
119
|
+
/** Role automatically assigned to new users on registration. Must be one of roles. */
|
|
120
|
+
defaultRole?: string;
|
|
121
|
+
/** OAuth provider and redirect configuration */
|
|
122
|
+
oauth?: OAuthConfig;
|
|
123
|
+
/**
|
|
124
|
+
* The primary identifier field used for registration and login.
|
|
125
|
+
* Defaults to "email". Use "username" or "phone" for apps that identify users differently.
|
|
126
|
+
* Email verification is only available when primaryField is "email".
|
|
127
|
+
*/
|
|
128
|
+
primaryField?: PrimaryField;
|
|
129
|
+
/**
|
|
130
|
+
* Email verification configuration. Only active when primaryField is "email".
|
|
131
|
+
* Provide an onSend callback to send the verification email via any provider (Resend, SendGrid, etc.).
|
|
132
|
+
*/
|
|
133
|
+
emailVerification?: EmailVerificationConfig;
|
|
134
|
+
/** Rate limit configuration for built-in auth endpoints. */
|
|
135
|
+
rateLimit?: AuthRateLimitConfig;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type { PrimaryField, EmailVerificationConfig };
|
|
139
|
+
|
|
140
|
+
export interface BotProtectionConfig {
|
|
141
|
+
/**
|
|
142
|
+
* List of IPv4 CIDRs (e.g. "198.51.100.0/24"), IPv4 addresses, or IPv6 addresses to block outright.
|
|
143
|
+
* Matched requests receive a 403 before any other processing.
|
|
144
|
+
* Example: ["198.51.100.0/24", "203.0.113.42"]
|
|
145
|
+
*/
|
|
146
|
+
blockList?: string[];
|
|
147
|
+
/**
|
|
148
|
+
* Also rate-limit by HTTP fingerprint (User-Agent, Accept-*, Connection, browser header presence)
|
|
149
|
+
* in addition to IP. Bots that rotate IPs but use the same HTTP client share a bucket.
|
|
150
|
+
* Uses the same store as auth rate limiting (Redis or memory).
|
|
151
|
+
* Default: false
|
|
152
|
+
*/
|
|
153
|
+
fingerprintRateLimit?: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface SecurityConfig {
|
|
157
|
+
/** CORS origins. Defaults to "*" */
|
|
158
|
+
cors?: string | string[];
|
|
159
|
+
/** Global rate limit. Defaults to 100 req / 60s */
|
|
160
|
+
rateLimit?: { windowMs: number; max: number };
|
|
161
|
+
/**
|
|
162
|
+
* Bearer auth check. Set false to disable entirely.
|
|
163
|
+
* Pass an object with bypass paths (merged with built-in defaults: /docs, /health, /openapi.json, etc.).
|
|
164
|
+
* Defaults to enabled with no extra bypass paths.
|
|
165
|
+
*/
|
|
166
|
+
bearerAuth?: boolean | { bypass?: string[] };
|
|
167
|
+
/**
|
|
168
|
+
* Bot protection: CIDR blocklist and fingerprint-based rate limiting.
|
|
169
|
+
* Runs before IP rate limiting so blocked IPs are rejected immediately.
|
|
170
|
+
*/
|
|
171
|
+
botProtection?: BotProtectionConfig;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface CreateAppConfig {
|
|
175
|
+
/** Absolute path to the service's routes directory (use import.meta.dir + "/routes") */
|
|
176
|
+
routesDir: string;
|
|
177
|
+
/** App name and version for the root endpoint and OpenAPI docs */
|
|
178
|
+
app?: AppMeta;
|
|
179
|
+
/** Auth, roles, and OAuth configuration */
|
|
180
|
+
auth?: AuthConfig;
|
|
181
|
+
/** Security: CORS, rate limiting, bearer auth */
|
|
182
|
+
security?: SecurityConfig;
|
|
183
|
+
/** Extra middleware injected after identify, before route matching */
|
|
184
|
+
middleware?: MiddlewareHandler<AppEnv>[];
|
|
185
|
+
/** Database connection and store routing configuration */
|
|
186
|
+
db?: DbConfig;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const createApp = async (config: CreateAppConfig): Promise<OpenAPIHono<AppEnv>> => {
|
|
190
|
+
const {
|
|
191
|
+
routesDir,
|
|
192
|
+
app: appConfig = {},
|
|
193
|
+
auth: authConfig = {},
|
|
194
|
+
security: securityConfig = {},
|
|
195
|
+
middleware = [],
|
|
196
|
+
db = {},
|
|
197
|
+
} = config;
|
|
198
|
+
|
|
199
|
+
const appName = appConfig.name ?? "Bun Core API";
|
|
200
|
+
const openApiVersion = appConfig.version ?? "1.0.0";
|
|
201
|
+
|
|
202
|
+
const corsOrigins = securityConfig.cors ?? "*";
|
|
203
|
+
const rlConfig = securityConfig.rateLimit ?? { windowMs: 60_000, max: 100 };
|
|
204
|
+
const botCfg = securityConfig.botProtection ?? {};
|
|
205
|
+
const enableBearerAuth = securityConfig.bearerAuth !== false;
|
|
206
|
+
const extraBypass =
|
|
207
|
+
typeof securityConfig.bearerAuth === "object" && securityConfig.bearerAuth !== null
|
|
208
|
+
? (securityConfig.bearerAuth.bypass ?? [])
|
|
209
|
+
: [];
|
|
210
|
+
|
|
211
|
+
const enableAuthRoutes = authConfig.enabled !== false;
|
|
212
|
+
const explicitAuthAdapter = authConfig.adapter;
|
|
213
|
+
const oauthProviders = authConfig.oauth?.providers;
|
|
214
|
+
const postOAuthRedirect = authConfig.oauth?.postRedirect ?? "/";
|
|
215
|
+
const roles = authConfig.roles ?? [];
|
|
216
|
+
const defaultRole = authConfig.defaultRole;
|
|
217
|
+
const primaryField = authConfig.primaryField ?? "email";
|
|
218
|
+
const emailVerification = authConfig.emailVerification;
|
|
219
|
+
const authRateLimit = authConfig.rateLimit;
|
|
220
|
+
|
|
221
|
+
const { sqlite, mongo = "single", redis: enableRedis = true } = db;
|
|
222
|
+
|
|
223
|
+
// Smart fallback: pick the best available store rather than blindly defaulting to "redis"
|
|
224
|
+
const defaultStore: StoreType = enableRedis
|
|
225
|
+
? "redis"
|
|
226
|
+
: sqlite
|
|
227
|
+
? "sqlite"
|
|
228
|
+
: mongo !== false
|
|
229
|
+
? "mongo"
|
|
230
|
+
: "memory";
|
|
231
|
+
|
|
232
|
+
const sessions = db.sessions ?? defaultStore;
|
|
233
|
+
const oauthState = db.oauthState ?? sessions;
|
|
234
|
+
const cache = db.cache ?? defaultStore;
|
|
235
|
+
const authStore = db.auth ?? (mongo !== false ? "mongo" : sessions);
|
|
236
|
+
|
|
237
|
+
if (sqlite || sessions === "sqlite" || oauthState === "sqlite" || authStore === "sqlite") {
|
|
238
|
+
const { setSqliteDb } = await import("./adapters/sqliteAuth");
|
|
239
|
+
setSqliteDb(sqlite ?? "./data.db");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
setSessionStore(sessions);
|
|
243
|
+
setOAuthStateStore(oauthState);
|
|
244
|
+
setCacheStore(cache);
|
|
245
|
+
|
|
246
|
+
if (mongo === "single") await connectMongo();
|
|
247
|
+
else if (mongo === "separate") await Promise.all([connectAuthMongo(), connectAppMongo()]);
|
|
248
|
+
|
|
249
|
+
if (enableRedis) await connectRedis();
|
|
250
|
+
|
|
251
|
+
// Resolve auth adapter: explicit prop wins, then db.auth, then mongo default
|
|
252
|
+
let authAdapter: AuthAdapter;
|
|
253
|
+
if (explicitAuthAdapter) {
|
|
254
|
+
authAdapter = explicitAuthAdapter;
|
|
255
|
+
} else if (authStore === "sqlite") {
|
|
256
|
+
const { sqliteAuthAdapter } = await import("./adapters/sqliteAuth");
|
|
257
|
+
authAdapter = sqliteAuthAdapter;
|
|
258
|
+
} else if (authStore === "memory") {
|
|
259
|
+
authAdapter = memoryAuthAdapter;
|
|
260
|
+
} else {
|
|
261
|
+
authAdapter = mongoAuthAdapter;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
setAuthAdapter(authAdapter);
|
|
265
|
+
setAppRoles(roles);
|
|
266
|
+
setDefaultRole(defaultRole ?? null);
|
|
267
|
+
setPrimaryField(primaryField);
|
|
268
|
+
setEmailVerificationConfig(emailVerification ?? null);
|
|
269
|
+
setEmailVerificationStore(sessions);
|
|
270
|
+
setAuthRateLimitStore(authRateLimit?.store ?? (enableRedis ? "redis" : "memory"));
|
|
271
|
+
|
|
272
|
+
if (defaultRole && !authAdapter.setRoles) {
|
|
273
|
+
throw new Error(`createApp: "defaultRole" is set to "${defaultRole}" but the auth adapter does not implement setRoles. Add setRoles to your adapter or remove defaultRole.`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (oauthProviders) initOAuthProviders(oauthProviders);
|
|
277
|
+
const configuredOAuth = getConfiguredOAuthProviders();
|
|
278
|
+
|
|
279
|
+
// OAuth paths must bypass bearer auth — initiation and link routes are browser redirects,
|
|
280
|
+
// callbacks come from external providers; none can send a bearer token header.
|
|
281
|
+
const oauthBypass = configuredOAuth.flatMap((p) => [
|
|
282
|
+
`/auth/${p}`,
|
|
283
|
+
`/auth/${p}/callback`,
|
|
284
|
+
`/auth/${p}/link`,
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
const DEFAULT_BYPASS = ["/docs", "/openapi.json", "/sw.js", "/health", "/"];
|
|
288
|
+
const bearerAuthBypass = [...DEFAULT_BYPASS, ...oauthBypass, ...extraBypass];
|
|
289
|
+
|
|
290
|
+
const app = new OpenAPIHono<AppEnv>();
|
|
291
|
+
|
|
292
|
+
app.use(logger());
|
|
293
|
+
app.use(secureHeaders());
|
|
294
|
+
app.use(cors({ origin: corsOrigins, allowHeaders: ["Content-Type", "Authorization", HEADER_USER_TOKEN], exposeHeaders: ["x-cache"], credentials: true }));
|
|
295
|
+
if ((botCfg.blockList?.length ?? 0) > 0) {
|
|
296
|
+
const { botProtection } = await import("@middleware/botProtection");
|
|
297
|
+
app.use(botProtection({ blockList: botCfg.blockList }));
|
|
298
|
+
}
|
|
299
|
+
app.use(rateLimit({ ...rlConfig, fingerprintLimit: botCfg.fingerprintRateLimit ?? false }));
|
|
300
|
+
if (enableBearerAuth) {
|
|
301
|
+
app.use(async (c, next) => {
|
|
302
|
+
const path = c.req.path;
|
|
303
|
+
if (bearerAuthBypass.includes(path)) {
|
|
304
|
+
return next();
|
|
305
|
+
}
|
|
306
|
+
return bearerAuth(c, next);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
app.use(identify);
|
|
310
|
+
for (const mw of middleware) app.use(mw);
|
|
311
|
+
|
|
312
|
+
setAppName(appName);
|
|
313
|
+
|
|
314
|
+
// Core routes (auth, etc.)
|
|
315
|
+
const coreRoutesDir = import.meta.dir + "/routes";
|
|
316
|
+
const coreGlob = new Bun.Glob("*.ts");
|
|
317
|
+
for await (const file of coreGlob.scan({ cwd: coreRoutesDir })) {
|
|
318
|
+
if (file === "auth.ts") continue; // mounted separately below via createAuthRouter
|
|
319
|
+
if (file === "oauth.ts") continue; // mounted separately below
|
|
320
|
+
const mod = await import(`${coreRoutesDir}/${file}`);
|
|
321
|
+
if (mod.router) app.route("/", mod.router);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (enableAuthRoutes) {
|
|
325
|
+
const { createAuthRouter } = await import(`${coreRoutesDir}/auth`);
|
|
326
|
+
app.route("/", createAuthRouter({ primaryField, emailVerification, rateLimit: authRateLimit }));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (configuredOAuth.length > 0) {
|
|
330
|
+
app.route("/", createOAuthRouter(configuredOAuth, postOAuthRedirect));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Service routes — collect all, sort by optional exported `priority`, then mount
|
|
334
|
+
const serviceGlob = new Bun.Glob("**/*.ts");
|
|
335
|
+
const serviceFiles: string[] = [];
|
|
336
|
+
for await (const file of serviceGlob.scan({ cwd: routesDir })) {
|
|
337
|
+
serviceFiles.push(file);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const serviceMods = await Promise.all(
|
|
341
|
+
serviceFiles.map(async (file) => ({
|
|
342
|
+
file,
|
|
343
|
+
mod: await import(`${routesDir}/${file}`),
|
|
344
|
+
}))
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
serviceMods
|
|
348
|
+
.sort((a, b) => (a.mod.priority ?? Infinity) - (b.mod.priority ?? Infinity))
|
|
349
|
+
.forEach(({ mod }) => {
|
|
350
|
+
if (mod.router) app.route("/", mod.router);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
app.onError((err, c) => {
|
|
354
|
+
if (err instanceof HttpError) {
|
|
355
|
+
return c.json({ error: err.message }, err.status as 400 | 401 | 403 | 404 | 409 | 418 | 429 | 500);
|
|
356
|
+
}
|
|
357
|
+
console.error(err);
|
|
358
|
+
return c.json({ error: "Internal Server Error" }, 500);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
app.notFound((c) => c.json({ error: "Not Found" }, 404));
|
|
362
|
+
|
|
363
|
+
app.doc("/openapi.json", { openapi: "3.0.0", info: { title: appName, version: openApiVersion } });
|
|
364
|
+
app.get("/docs", Scalar({ url: "/openapi.json" }));
|
|
365
|
+
app.get("/sw.js", (c) => c.body("", 200, { "Content-Type": "application/javascript" }));
|
|
366
|
+
|
|
367
|
+
return app;
|
|
368
|
+
};
|