@lastshotlabs/bunshot 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/memoryAuth.js +207 -0
- package/dist/adapters/mongoAuth.js +93 -0
- package/dist/adapters/sqliteAuth.js +242 -0
- package/dist/app.js +175 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +37 -27
- package/dist/lib/HttpError.js +7 -0
- package/dist/lib/appConfig.js +17 -0
- package/dist/lib/authAdapter.js +7 -0
- package/dist/lib/authRateLimit.js +77 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/context.js +8 -0
- package/dist/lib/emailVerification.js +77 -0
- package/dist/lib/fingerprint.js +36 -0
- package/dist/lib/jwt.js +11 -0
- package/dist/lib/logger.js +7 -0
- package/dist/lib/mongo.js +73 -0
- package/dist/lib/oauth.js +82 -0
- package/dist/lib/queue.js +4 -0
- package/dist/lib/redis.js +50 -0
- package/dist/lib/roles.js +22 -0
- package/dist/lib/session.js +68 -0
- package/dist/lib/validate.js +14 -0
- package/dist/lib/ws.js +64 -0
- package/dist/middleware/bearerAuth.js +10 -0
- package/dist/middleware/botProtection.js +50 -0
- package/dist/middleware/cacheResponse.js +158 -0
- package/dist/middleware/cors.js +17 -0
- package/dist/middleware/errorHandler.js +13 -0
- package/dist/middleware/identify.js +33 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/logger.js +7 -0
- package/dist/middleware/rateLimit.js +20 -0
- package/dist/middleware/requireRole.js +36 -0
- package/dist/middleware/requireVerifiedEmail.js +25 -0
- package/dist/middleware/userAuth.js +6 -0
- package/dist/models/AuthUser.js +14 -0
- package/dist/routes/auth.js +206 -0
- package/dist/routes/health.js +22 -0
- package/dist/routes/home.js +16 -0
- package/dist/routes/oauth.js +150 -0
- package/dist/schemas/auth.js +9 -0
- package/dist/server.js +53 -0
- package/dist/services/auth.js +54 -0
- package/dist/ws/index.js +31 -0
- package/package.json +2 -2
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { HttpError } from "../lib/HttpError";
|
|
2
|
+
const _users = new Map();
|
|
3
|
+
const _byEmail = new Map();
|
|
4
|
+
const _sessions = new Map();
|
|
5
|
+
const _oauthStates = new Map();
|
|
6
|
+
const _cache = new Map();
|
|
7
|
+
const _verificationTokens = new Map();
|
|
8
|
+
/** Reset all in-memory state. Useful for test isolation. */
|
|
9
|
+
export const clearMemoryStore = () => {
|
|
10
|
+
_users.clear();
|
|
11
|
+
_byEmail.clear();
|
|
12
|
+
_sessions.clear();
|
|
13
|
+
_oauthStates.clear();
|
|
14
|
+
_cache.clear();
|
|
15
|
+
_verificationTokens.clear();
|
|
16
|
+
};
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Auth adapter
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
export const memoryAuthAdapter = {
|
|
21
|
+
async findByEmail(email) {
|
|
22
|
+
const id = _byEmail.get(email.toLowerCase());
|
|
23
|
+
if (!id)
|
|
24
|
+
return null;
|
|
25
|
+
const user = _users.get(id);
|
|
26
|
+
if (!user || !user.passwordHash)
|
|
27
|
+
return null;
|
|
28
|
+
return { id: user.id, passwordHash: user.passwordHash };
|
|
29
|
+
},
|
|
30
|
+
async create(email, passwordHash) {
|
|
31
|
+
const normalised = email.toLowerCase();
|
|
32
|
+
if (_byEmail.has(normalised))
|
|
33
|
+
throw new HttpError(409, "Email already registered");
|
|
34
|
+
const id = crypto.randomUUID();
|
|
35
|
+
const user = { id, email: normalised, passwordHash, providerIds: [], roles: [], emailVerified: false };
|
|
36
|
+
_users.set(id, user);
|
|
37
|
+
_byEmail.set(normalised, id);
|
|
38
|
+
return { id };
|
|
39
|
+
},
|
|
40
|
+
async setPassword(userId, passwordHash) {
|
|
41
|
+
const user = _users.get(userId);
|
|
42
|
+
if (!user)
|
|
43
|
+
return;
|
|
44
|
+
user.passwordHash = passwordHash;
|
|
45
|
+
},
|
|
46
|
+
async findOrCreateByProvider(provider, providerId, profile) {
|
|
47
|
+
const key = `${provider}:${providerId}`;
|
|
48
|
+
// Find by provider key
|
|
49
|
+
for (const user of _users.values()) {
|
|
50
|
+
if (user.providerIds.includes(key))
|
|
51
|
+
return { id: user.id, created: false };
|
|
52
|
+
}
|
|
53
|
+
// Reject if email belongs to a credential account
|
|
54
|
+
if (profile.email) {
|
|
55
|
+
const existingId = _byEmail.get(profile.email.toLowerCase());
|
|
56
|
+
if (existingId)
|
|
57
|
+
throw new HttpError(409, "An account with this email already exists. Sign in with your credentials, then link Google from your account settings.");
|
|
58
|
+
}
|
|
59
|
+
const id = crypto.randomUUID();
|
|
60
|
+
const email = profile.email ? profile.email.toLowerCase() : null;
|
|
61
|
+
const user = { id, email, passwordHash: null, providerIds: [key], roles: [], emailVerified: false };
|
|
62
|
+
_users.set(id, user);
|
|
63
|
+
if (email)
|
|
64
|
+
_byEmail.set(email, id);
|
|
65
|
+
return { id, created: true };
|
|
66
|
+
},
|
|
67
|
+
async linkProvider(userId, provider, providerId) {
|
|
68
|
+
const user = _users.get(userId);
|
|
69
|
+
if (!user)
|
|
70
|
+
throw new HttpError(404, "User not found");
|
|
71
|
+
const key = `${provider}:${providerId}`;
|
|
72
|
+
if (!user.providerIds.includes(key))
|
|
73
|
+
user.providerIds.push(key);
|
|
74
|
+
},
|
|
75
|
+
async getRoles(userId) {
|
|
76
|
+
return _users.get(userId)?.roles ?? [];
|
|
77
|
+
},
|
|
78
|
+
async setRoles(userId, roles) {
|
|
79
|
+
const user = _users.get(userId);
|
|
80
|
+
if (!user)
|
|
81
|
+
return;
|
|
82
|
+
user.roles = [...roles];
|
|
83
|
+
},
|
|
84
|
+
async addRole(userId, role) {
|
|
85
|
+
const user = _users.get(userId);
|
|
86
|
+
if (!user)
|
|
87
|
+
return;
|
|
88
|
+
if (!user.roles.includes(role))
|
|
89
|
+
user.roles.push(role);
|
|
90
|
+
},
|
|
91
|
+
async removeRole(userId, role) {
|
|
92
|
+
const user = _users.get(userId);
|
|
93
|
+
if (!user)
|
|
94
|
+
return;
|
|
95
|
+
user.roles = user.roles.filter((r) => r !== role);
|
|
96
|
+
},
|
|
97
|
+
async getUser(userId) {
|
|
98
|
+
const user = _users.get(userId);
|
|
99
|
+
if (!user)
|
|
100
|
+
return null;
|
|
101
|
+
return {
|
|
102
|
+
email: user.email ?? undefined,
|
|
103
|
+
providerIds: [...user.providerIds],
|
|
104
|
+
emailVerified: user.emailVerified,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
async unlinkProvider(userId, provider) {
|
|
108
|
+
const user = _users.get(userId);
|
|
109
|
+
if (!user)
|
|
110
|
+
throw new HttpError(404, "User not found");
|
|
111
|
+
user.providerIds = user.providerIds.filter((id) => !id.startsWith(`${provider}:`));
|
|
112
|
+
},
|
|
113
|
+
async findByIdentifier(value) {
|
|
114
|
+
const id = _byEmail.get(value.toLowerCase());
|
|
115
|
+
if (!id)
|
|
116
|
+
return null;
|
|
117
|
+
const user = _users.get(id);
|
|
118
|
+
if (!user || !user.passwordHash)
|
|
119
|
+
return null;
|
|
120
|
+
return { id: user.id, passwordHash: user.passwordHash };
|
|
121
|
+
},
|
|
122
|
+
async setEmailVerified(userId, verified) {
|
|
123
|
+
const user = _users.get(userId);
|
|
124
|
+
if (user)
|
|
125
|
+
user.emailVerified = verified;
|
|
126
|
+
},
|
|
127
|
+
async getEmailVerified(userId) {
|
|
128
|
+
return _users.get(userId)?.emailVerified ?? false;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Session helpers (used by src/lib/session.ts)
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
const SESSION_TTL_MS = 60 * 60 * 24 * 7 * 1000; // 7 days
|
|
135
|
+
export const memoryCreateSession = (userId, token) => {
|
|
136
|
+
_sessions.set(userId, { token, expiresAt: Date.now() + SESSION_TTL_MS });
|
|
137
|
+
};
|
|
138
|
+
export const memoryGetSession = (userId) => {
|
|
139
|
+
const entry = _sessions.get(userId);
|
|
140
|
+
if (!entry || entry.expiresAt <= Date.now())
|
|
141
|
+
return null;
|
|
142
|
+
return entry.token;
|
|
143
|
+
};
|
|
144
|
+
export const memoryDeleteSession = (userId) => {
|
|
145
|
+
_sessions.delete(userId);
|
|
146
|
+
};
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// OAuth state helpers (used by src/lib/oauth.ts)
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
const OAUTH_STATE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
151
|
+
export const memoryStoreOAuthState = (state, codeVerifier, linkUserId) => {
|
|
152
|
+
_oauthStates.set(state, { codeVerifier, linkUserId, expiresAt: Date.now() + OAUTH_STATE_TTL_MS });
|
|
153
|
+
};
|
|
154
|
+
export const memoryConsumeOAuthState = (state) => {
|
|
155
|
+
const entry = _oauthStates.get(state);
|
|
156
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
157
|
+
_oauthStates.delete(state);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
_oauthStates.delete(state);
|
|
161
|
+
return { codeVerifier: entry.codeVerifier, linkUserId: entry.linkUserId };
|
|
162
|
+
};
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Cache helpers (used by src/middleware/cacheResponse.ts)
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
export const memoryGetCache = (key) => {
|
|
167
|
+
const entry = _cache.get(key);
|
|
168
|
+
if (!entry)
|
|
169
|
+
return null;
|
|
170
|
+
if (entry.expiresAt !== undefined && entry.expiresAt <= Date.now()) {
|
|
171
|
+
_cache.delete(key);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return entry.value;
|
|
175
|
+
};
|
|
176
|
+
export const memorySetCache = (key, value, ttlSeconds) => {
|
|
177
|
+
const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined;
|
|
178
|
+
_cache.set(key, { value, expiresAt });
|
|
179
|
+
};
|
|
180
|
+
export const memoryDelCache = (key) => {
|
|
181
|
+
_cache.delete(key);
|
|
182
|
+
};
|
|
183
|
+
export const memoryDelCachePattern = (pattern) => {
|
|
184
|
+
// Convert glob * to a regex
|
|
185
|
+
const regex = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
|
|
186
|
+
for (const key of _cache.keys()) {
|
|
187
|
+
if (regex.test(key))
|
|
188
|
+
_cache.delete(key);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Email verification token helpers (used by src/lib/emailVerification.ts)
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
export const memoryCreateVerificationToken = (token, userId, email, ttlSeconds) => {
|
|
195
|
+
_verificationTokens.set(token, { userId, email, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
196
|
+
};
|
|
197
|
+
export const memoryGetVerificationToken = (token) => {
|
|
198
|
+
const entry = _verificationTokens.get(token);
|
|
199
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
200
|
+
_verificationTokens.delete(token);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return { userId: entry.userId, email: entry.email };
|
|
204
|
+
};
|
|
205
|
+
export const memoryDeleteVerificationToken = (token) => {
|
|
206
|
+
_verificationTokens.delete(token);
|
|
207
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { AuthUser } from "../models/AuthUser";
|
|
2
|
+
import { HttpError } from "../lib/HttpError";
|
|
3
|
+
export const mongoAuthAdapter = {
|
|
4
|
+
async findByEmail(email) {
|
|
5
|
+
const user = await AuthUser.findOne({ email });
|
|
6
|
+
if (!user)
|
|
7
|
+
return null;
|
|
8
|
+
return { id: String(user._id), passwordHash: user.password };
|
|
9
|
+
},
|
|
10
|
+
async create(email, passwordHash) {
|
|
11
|
+
try {
|
|
12
|
+
const user = await AuthUser.create({ email, password: passwordHash });
|
|
13
|
+
return { id: String(user._id) };
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
if (err?.code === 11000)
|
|
17
|
+
throw new HttpError(409, "Email already registered");
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
async setPassword(userId, passwordHash) {
|
|
22
|
+
await AuthUser.findByIdAndUpdate(userId, { password: passwordHash });
|
|
23
|
+
},
|
|
24
|
+
async findOrCreateByProvider(provider, providerId, profile) {
|
|
25
|
+
const key = `${provider}:${providerId}`;
|
|
26
|
+
// Find by provider key
|
|
27
|
+
let user = await AuthUser.findOne({ providerIds: key });
|
|
28
|
+
if (user)
|
|
29
|
+
return { id: String(user._id), created: false };
|
|
30
|
+
// Reject if the email belongs to a credential account — user must link manually
|
|
31
|
+
if (profile.email) {
|
|
32
|
+
const existing = await AuthUser.findOne({ email: profile.email });
|
|
33
|
+
if (existing)
|
|
34
|
+
throw new HttpError(409, "An account with this email already exists. Sign in with your credentials, then link Google from your account settings.");
|
|
35
|
+
}
|
|
36
|
+
// Create new user
|
|
37
|
+
user = await AuthUser.create({ email: profile.email, providerIds: [key] });
|
|
38
|
+
return { id: String(user._id), created: true };
|
|
39
|
+
},
|
|
40
|
+
async linkProvider(userId, provider, providerId) {
|
|
41
|
+
const key = `${provider}:${providerId}`;
|
|
42
|
+
const user = await AuthUser.findById(userId);
|
|
43
|
+
if (!user)
|
|
44
|
+
throw new HttpError(404, "User not found");
|
|
45
|
+
if (!user.providerIds.includes(key)) {
|
|
46
|
+
user.providerIds = [...user.providerIds, key];
|
|
47
|
+
await user.save();
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
async getRoles(userId) {
|
|
51
|
+
const user = await AuthUser.findById(userId, "roles").lean();
|
|
52
|
+
return user?.roles ?? [];
|
|
53
|
+
},
|
|
54
|
+
async setRoles(userId, roles) {
|
|
55
|
+
await AuthUser.findByIdAndUpdate(userId, { roles });
|
|
56
|
+
},
|
|
57
|
+
async addRole(userId, role) {
|
|
58
|
+
await AuthUser.findByIdAndUpdate(userId, { $addToSet: { roles: role } });
|
|
59
|
+
},
|
|
60
|
+
async removeRole(userId, role) {
|
|
61
|
+
await AuthUser.findByIdAndUpdate(userId, { $pull: { roles: role } });
|
|
62
|
+
},
|
|
63
|
+
async getUser(userId) {
|
|
64
|
+
const user = await AuthUser.findById(userId, "email providerIds emailVerified").lean();
|
|
65
|
+
if (!user)
|
|
66
|
+
return null;
|
|
67
|
+
return {
|
|
68
|
+
email: user.email,
|
|
69
|
+
providerIds: user.providerIds,
|
|
70
|
+
emailVerified: user.emailVerified ?? false,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
async unlinkProvider(userId, provider) {
|
|
74
|
+
const user = await AuthUser.findById(userId);
|
|
75
|
+
if (!user)
|
|
76
|
+
throw new HttpError(404, "User not found");
|
|
77
|
+
user.providerIds = user.providerIds.filter((id) => !id.startsWith(`${provider}:`));
|
|
78
|
+
await user.save();
|
|
79
|
+
},
|
|
80
|
+
async findByIdentifier(value) {
|
|
81
|
+
const user = await AuthUser.findOne({ email: value });
|
|
82
|
+
if (!user)
|
|
83
|
+
return null;
|
|
84
|
+
return { id: String(user._id), passwordHash: user.password };
|
|
85
|
+
},
|
|
86
|
+
async setEmailVerified(userId, verified) {
|
|
87
|
+
await AuthUser.findByIdAndUpdate(userId, { emailVerified: verified });
|
|
88
|
+
},
|
|
89
|
+
async getEmailVerified(userId) {
|
|
90
|
+
const user = await AuthUser.findById(userId, "emailVerified").lean();
|
|
91
|
+
return user?.emailVerified ?? false;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { HttpError } from "../lib/HttpError";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// DB singleton — call setSqliteDb(path) once at startup
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
let _db = null;
|
|
7
|
+
export const setSqliteDb = (path) => {
|
|
8
|
+
_db = new Database(path, { create: true });
|
|
9
|
+
_db.run("PRAGMA journal_mode = WAL");
|
|
10
|
+
_db.run("PRAGMA foreign_keys = ON");
|
|
11
|
+
initSchema(_db);
|
|
12
|
+
};
|
|
13
|
+
function getDb() {
|
|
14
|
+
if (!_db)
|
|
15
|
+
throw new Error("SQLite not initialized — call setSqliteDb(path) before using sqliteAuthAdapter or sessionStore: 'sqlite'");
|
|
16
|
+
return _db;
|
|
17
|
+
}
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Schema
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
function initSchema(db) {
|
|
22
|
+
db.run(`CREATE TABLE IF NOT EXISTS users (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
email TEXT UNIQUE,
|
|
25
|
+
passwordHash TEXT,
|
|
26
|
+
providerIds TEXT NOT NULL DEFAULT '[]',
|
|
27
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
28
|
+
emailVerified INTEGER NOT NULL DEFAULT 0
|
|
29
|
+
)`);
|
|
30
|
+
// Add emailVerified to pre-existing databases that lack the column
|
|
31
|
+
try {
|
|
32
|
+
db.run("ALTER TABLE users ADD COLUMN emailVerified INTEGER NOT NULL DEFAULT 0");
|
|
33
|
+
}
|
|
34
|
+
catch { /* already exists */ }
|
|
35
|
+
db.run(`CREATE TABLE IF NOT EXISTS sessions (
|
|
36
|
+
userId TEXT PRIMARY KEY,
|
|
37
|
+
token TEXT NOT NULL,
|
|
38
|
+
expiresAt INTEGER NOT NULL
|
|
39
|
+
)`);
|
|
40
|
+
db.run(`CREATE TABLE IF NOT EXISTS oauth_states (
|
|
41
|
+
state TEXT PRIMARY KEY,
|
|
42
|
+
codeVerifier TEXT,
|
|
43
|
+
linkUserId TEXT,
|
|
44
|
+
expiresAt INTEGER NOT NULL
|
|
45
|
+
)`);
|
|
46
|
+
db.run(`CREATE TABLE IF NOT EXISTS cache_entries (
|
|
47
|
+
key TEXT PRIMARY KEY,
|
|
48
|
+
value TEXT NOT NULL,
|
|
49
|
+
expiresAt INTEGER -- NULL = indefinite
|
|
50
|
+
)`);
|
|
51
|
+
db.run(`CREATE TABLE IF NOT EXISTS email_verifications (
|
|
52
|
+
token TEXT PRIMARY KEY,
|
|
53
|
+
userId TEXT NOT NULL,
|
|
54
|
+
email TEXT NOT NULL,
|
|
55
|
+
expiresAt INTEGER NOT NULL
|
|
56
|
+
)`);
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Auth adapter
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
export const sqliteAuthAdapter = {
|
|
62
|
+
async findByEmail(email) {
|
|
63
|
+
const row = getDb().query("SELECT id, passwordHash FROM users WHERE email = ?").get(email);
|
|
64
|
+
return row ?? null;
|
|
65
|
+
},
|
|
66
|
+
async create(email, passwordHash) {
|
|
67
|
+
const id = crypto.randomUUID();
|
|
68
|
+
try {
|
|
69
|
+
getDb().run("INSERT INTO users (id, email, passwordHash) VALUES (?, ?, ?)", [id, email, passwordHash]);
|
|
70
|
+
return { id };
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err?.code === "SQLITE_CONSTRAINT_UNIQUE")
|
|
74
|
+
throw new HttpError(409, "Email already registered");
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
async setPassword(userId, passwordHash) {
|
|
79
|
+
getDb().run("UPDATE users SET passwordHash = ? WHERE id = ?", [passwordHash, userId]);
|
|
80
|
+
},
|
|
81
|
+
async findOrCreateByProvider(provider, providerId, profile) {
|
|
82
|
+
const key = `${provider}:${providerId}`;
|
|
83
|
+
const db = getDb();
|
|
84
|
+
// Find by provider key using json_each
|
|
85
|
+
const existing = db.query("SELECT u.id FROM users u, json_each(u.providerIds) p WHERE p.value = ?").get(key);
|
|
86
|
+
if (existing)
|
|
87
|
+
return { id: existing.id, created: false };
|
|
88
|
+
// Reject if email belongs to a credential account
|
|
89
|
+
if (profile.email) {
|
|
90
|
+
const emailUser = db.query("SELECT id FROM users WHERE email = ?").get(profile.email);
|
|
91
|
+
if (emailUser)
|
|
92
|
+
throw new HttpError(409, "An account with this email already exists. Sign in with your credentials, then link Google from your account settings.");
|
|
93
|
+
}
|
|
94
|
+
const id = crypto.randomUUID();
|
|
95
|
+
db.run("INSERT INTO users (id, email, providerIds) VALUES (?, ?, ?)", [id, profile.email ?? null, JSON.stringify([key])]);
|
|
96
|
+
return { id, created: true };
|
|
97
|
+
},
|
|
98
|
+
async linkProvider(userId, provider, providerId) {
|
|
99
|
+
const key = `${provider}:${providerId}`;
|
|
100
|
+
const db = getDb();
|
|
101
|
+
const row = db.query("SELECT id, providerIds FROM users WHERE id = ?").get(userId);
|
|
102
|
+
if (!row)
|
|
103
|
+
throw new HttpError(404, "User not found");
|
|
104
|
+
const ids = JSON.parse(row.providerIds);
|
|
105
|
+
if (!ids.includes(key)) {
|
|
106
|
+
db.run("UPDATE users SET providerIds = ? WHERE id = ?", [JSON.stringify([...ids, key]), userId]);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
async getRoles(userId) {
|
|
110
|
+
const row = getDb().query("SELECT roles FROM users WHERE id = ?").get(userId);
|
|
111
|
+
return row ? JSON.parse(row.roles) : [];
|
|
112
|
+
},
|
|
113
|
+
async setRoles(userId, roles) {
|
|
114
|
+
getDb().run("UPDATE users SET roles = ? WHERE id = ?", [JSON.stringify(roles), userId]);
|
|
115
|
+
},
|
|
116
|
+
async addRole(userId, role) {
|
|
117
|
+
const db = getDb();
|
|
118
|
+
const row = db.query("SELECT roles FROM users WHERE id = ?").get(userId);
|
|
119
|
+
if (!row)
|
|
120
|
+
return;
|
|
121
|
+
const roles = JSON.parse(row.roles);
|
|
122
|
+
if (!roles.includes(role)) {
|
|
123
|
+
db.run("UPDATE users SET roles = ? WHERE id = ?", [JSON.stringify([...roles, role]), userId]);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
async removeRole(userId, role) {
|
|
127
|
+
const db = getDb();
|
|
128
|
+
const row = db.query("SELECT roles FROM users WHERE id = ?").get(userId);
|
|
129
|
+
if (!row)
|
|
130
|
+
return;
|
|
131
|
+
const roles = JSON.parse(row.roles);
|
|
132
|
+
db.run("UPDATE users SET roles = ? WHERE id = ?", [JSON.stringify(roles.filter((r) => r !== role)), userId]);
|
|
133
|
+
},
|
|
134
|
+
async getUser(userId) {
|
|
135
|
+
const row = getDb().query("SELECT email, providerIds, emailVerified FROM users WHERE id = ?").get(userId);
|
|
136
|
+
if (!row)
|
|
137
|
+
return null;
|
|
138
|
+
return {
|
|
139
|
+
email: row.email ?? undefined,
|
|
140
|
+
providerIds: JSON.parse(row.providerIds),
|
|
141
|
+
emailVerified: row.emailVerified === 1,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
async unlinkProvider(userId, provider) {
|
|
145
|
+
const db = getDb();
|
|
146
|
+
const row = db.query("SELECT providerIds FROM users WHERE id = ?").get(userId);
|
|
147
|
+
if (!row)
|
|
148
|
+
throw new HttpError(404, "User not found");
|
|
149
|
+
const ids = JSON.parse(row.providerIds);
|
|
150
|
+
db.run("UPDATE users SET providerIds = ? WHERE id = ?", [JSON.stringify(ids.filter((id) => !id.startsWith(`${provider}:`))), userId]);
|
|
151
|
+
},
|
|
152
|
+
async findByIdentifier(value) {
|
|
153
|
+
const row = getDb().query("SELECT id, passwordHash FROM users WHERE email = ?").get(value);
|
|
154
|
+
return row ?? null;
|
|
155
|
+
},
|
|
156
|
+
async setEmailVerified(userId, verified) {
|
|
157
|
+
getDb().run("UPDATE users SET emailVerified = ? WHERE id = ?", [verified ? 1 : 0, userId]);
|
|
158
|
+
},
|
|
159
|
+
async getEmailVerified(userId) {
|
|
160
|
+
const row = getDb().query("SELECT emailVerified FROM users WHERE id = ?").get(userId);
|
|
161
|
+
return row?.emailVerified === 1;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Session helpers (used by src/lib/session.ts)
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
const SESSION_TTL_MS = 60 * 60 * 24 * 7 * 1000; // 7 days
|
|
168
|
+
export const sqliteCreateSession = (userId, token) => {
|
|
169
|
+
const expiresAt = Date.now() + SESSION_TTL_MS;
|
|
170
|
+
getDb().run("INSERT INTO sessions (userId, token, expiresAt) VALUES (?, ?, ?) ON CONFLICT(userId) DO UPDATE SET token = excluded.token, expiresAt = excluded.expiresAt", [userId, token, expiresAt]);
|
|
171
|
+
};
|
|
172
|
+
export const sqliteGetSession = (userId) => {
|
|
173
|
+
const row = getDb().query("SELECT token FROM sessions WHERE userId = ? AND expiresAt > ?").get(userId, Date.now());
|
|
174
|
+
return row?.token ?? null;
|
|
175
|
+
};
|
|
176
|
+
export const sqliteDeleteSession = (userId) => {
|
|
177
|
+
getDb().run("DELETE FROM sessions WHERE userId = ?", [userId]);
|
|
178
|
+
};
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// OAuth state helpers (used by src/lib/oauth.ts)
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
const OAUTH_STATE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
183
|
+
export const sqliteStoreOAuthState = (state, codeVerifier, linkUserId) => {
|
|
184
|
+
const expiresAt = Date.now() + OAUTH_STATE_TTL_MS;
|
|
185
|
+
getDb().run("INSERT INTO oauth_states (state, codeVerifier, linkUserId, expiresAt) VALUES (?, ?, ?, ?)", [state, codeVerifier ?? null, linkUserId ?? null, expiresAt]);
|
|
186
|
+
};
|
|
187
|
+
export const sqliteConsumeOAuthState = (state) => {
|
|
188
|
+
const row = getDb().query("DELETE FROM oauth_states WHERE state = ? AND expiresAt > ? RETURNING codeVerifier, linkUserId").get(state, Date.now());
|
|
189
|
+
if (!row)
|
|
190
|
+
return null;
|
|
191
|
+
return {
|
|
192
|
+
codeVerifier: row.codeVerifier ?? undefined,
|
|
193
|
+
linkUserId: row.linkUserId ?? undefined,
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Cache helpers (used by src/middleware/cacheResponse.ts)
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
export const isSqliteReady = () => _db !== null;
|
|
200
|
+
export const sqliteGetCache = (key) => {
|
|
201
|
+
const row = getDb().query("SELECT value FROM cache_entries WHERE key = ? AND (expiresAt IS NULL OR expiresAt > ?)").get(key, Date.now());
|
|
202
|
+
return row?.value ?? null;
|
|
203
|
+
};
|
|
204
|
+
export const sqliteSetCache = (key, value, ttlSeconds) => {
|
|
205
|
+
const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : null;
|
|
206
|
+
getDb().run("INSERT INTO cache_entries (key, value, expiresAt) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expiresAt = excluded.expiresAt", [key, value, expiresAt]);
|
|
207
|
+
};
|
|
208
|
+
export const sqliteDelCache = (key) => {
|
|
209
|
+
getDb().run("DELETE FROM cache_entries WHERE key = ?", [key]);
|
|
210
|
+
};
|
|
211
|
+
export const sqliteDelCachePattern = (pattern) => {
|
|
212
|
+
// Convert glob pattern (* wildcard) to a SQL LIKE pattern (% wildcard)
|
|
213
|
+
const likePattern = pattern.replace(/%/g, "\\%").replace(/_/g, "\\_").replace(/\*/g, "%");
|
|
214
|
+
getDb().run("DELETE FROM cache_entries WHERE key LIKE ? ESCAPE '\\'", [likePattern]);
|
|
215
|
+
};
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Email verification token helpers (used by src/lib/emailVerification.ts)
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
export const sqliteCreateVerificationToken = (token, userId, email, ttlSeconds) => {
|
|
220
|
+
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
221
|
+
getDb().run("INSERT INTO email_verifications (token, userId, email, expiresAt) VALUES (?, ?, ?, ?)", [token, userId, email, expiresAt]);
|
|
222
|
+
};
|
|
223
|
+
export const sqliteGetVerificationToken = (token) => {
|
|
224
|
+
const row = getDb().query("SELECT userId, email FROM email_verifications WHERE token = ? AND expiresAt > ?").get(token, Date.now());
|
|
225
|
+
return row ?? null;
|
|
226
|
+
};
|
|
227
|
+
export const sqliteDeleteVerificationToken = (token) => {
|
|
228
|
+
getDb().run("DELETE FROM email_verifications WHERE token = ?", [token]);
|
|
229
|
+
};
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Optional periodic cleanup of expired rows
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
export const startSqliteCleanup = (intervalMs = 3_600_000) => {
|
|
234
|
+
return setInterval(() => {
|
|
235
|
+
const db = getDb();
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
db.run("DELETE FROM sessions WHERE expiresAt <= ?", [now]);
|
|
238
|
+
db.run("DELETE FROM oauth_states WHERE expiresAt <= ?", [now]);
|
|
239
|
+
db.run("DELETE FROM cache_entries WHERE expiresAt IS NOT NULL AND expiresAt <= ?", [now]);
|
|
240
|
+
db.run("DELETE FROM email_verifications WHERE expiresAt <= ?", [now]);
|
|
241
|
+
}, intervalMs);
|
|
242
|
+
};
|