@lastshotlabs/bunshot 0.0.8 → 0.0.10

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 (44) hide show
  1. package/README.md +97 -7
  2. package/dist/adapters/memoryAuth.d.ts +13 -3
  3. package/dist/adapters/memoryAuth.js +116 -8
  4. package/dist/adapters/sqliteAuth.d.ts +13 -3
  5. package/dist/adapters/sqliteAuth.js +93 -15
  6. package/dist/app.d.ts +39 -2
  7. package/dist/app.js +23 -5
  8. package/dist/cli.js +0 -0
  9. package/dist/entrypoints/mongo.d.ts +3 -0
  10. package/dist/entrypoints/mongo.js +3 -0
  11. package/dist/entrypoints/queue.d.ts +2 -0
  12. package/dist/entrypoints/queue.js +1 -0
  13. package/dist/entrypoints/redis.d.ts +1 -0
  14. package/dist/entrypoints/redis.js +1 -0
  15. package/dist/index.d.ts +6 -8
  16. package/dist/index.js +5 -6
  17. package/dist/lib/appConfig.d.ts +17 -0
  18. package/dist/lib/appConfig.js +20 -0
  19. package/dist/lib/context.d.ts +1 -0
  20. package/dist/lib/emailVerification.js +11 -10
  21. package/dist/lib/jwt.d.ts +1 -1
  22. package/dist/lib/jwt.js +1 -1
  23. package/dist/lib/mongo.d.ts +9 -4
  24. package/dist/lib/mongo.js +61 -10
  25. package/dist/lib/oauth.js +11 -10
  26. package/dist/lib/queue.d.ts +3 -4
  27. package/dist/lib/queue.js +18 -3
  28. package/dist/lib/redis.d.ts +3 -8
  29. package/dist/lib/redis.js +19 -8
  30. package/dist/lib/resetPassword.d.ts +12 -0
  31. package/dist/lib/resetPassword.js +95 -0
  32. package/dist/lib/session.d.ts +20 -3
  33. package/dist/lib/session.js +288 -35
  34. package/dist/middleware/cacheResponse.js +10 -9
  35. package/dist/middleware/identify.js +21 -7
  36. package/dist/models/AuthUser.d.ts +14 -106
  37. package/dist/models/AuthUser.js +31 -14
  38. package/dist/routes/auth.d.ts +3 -2
  39. package/dist/routes/auth.js +139 -4
  40. package/dist/routes/oauth.js +13 -4
  41. package/dist/services/auth.d.ts +3 -2
  42. package/dist/services/auth.js +20 -11
  43. package/dist/ws/index.js +6 -3
  44. package/package.json +39 -9
package/README.md CHANGED
@@ -60,6 +60,48 @@ bun add @lastshotlabs/bunshot
60
60
 
61
61
  ---
62
62
 
63
+ ## Peer Dependencies
64
+
65
+ Bunshot declares the following as peer dependencies so you control their versions and avoid duplicate installs in your app.
66
+
67
+ ### Required
68
+
69
+ These must be installed in every consuming app:
70
+
71
+ ```bash
72
+ bun add hono zod
73
+ ```
74
+
75
+ | Package | Required version |
76
+ |---|---|
77
+ | `hono` | `>=4.12 <5` |
78
+ | `zod` | `>=4.0 <5` |
79
+
80
+ ### Optional
81
+
82
+ Install only what your app actually uses:
83
+
84
+ ```bash
85
+ # MongoDB auth / sessions / cache
86
+ bun add mongoose
87
+
88
+ # Redis sessions, cache, rate limiting, or BullMQ
89
+ bun add ioredis
90
+
91
+ # Background job queues
92
+ bun add bullmq
93
+ ```
94
+
95
+ | Package | Required version | When you need it |
96
+ |---|---|---|
97
+ | `mongoose` | `>=9.0 <10` | `db.auth: "mongo"`, `db.sessions: "mongo"`, or `db.cache: "mongo"` |
98
+ | `ioredis` | `>=5.0 <6` | `db.redis: true` (the default), or any store set to `"redis"` |
99
+ | `bullmq` | `>=5.0 <6` | Workers / queues |
100
+
101
+ If you're running fully on SQLite or memory (no Redis, no MongoDB), none of the optional peers are needed.
102
+
103
+ ---
104
+
63
105
  ## Quick Start
64
106
 
65
107
  ```ts
@@ -78,11 +120,15 @@ That's it. Your app gets:
78
120
  |---|---|
79
121
  | `POST /auth/register` | Create account, returns JWT |
80
122
  | `POST /auth/login` | Login, returns JWT (includes `emailVerified` when verification is configured) |
81
- | `POST /auth/logout` | Invalidates session |
123
+ | `POST /auth/logout` | Invalidates the current session only |
82
124
  | `GET /auth/me` | Returns current user's `userId`, `email`, `emailVerified`, and `googleLinked` (requires login) |
83
125
  | `POST /auth/set-password` | Set or update password (requires login) |
126
+ | `GET /auth/sessions` | List active sessions with metadata — IP, user-agent, timestamps (requires login) |
127
+ | `DELETE /auth/sessions/:sessionId` | Revoke a specific session by ID (requires login) |
84
128
  | `POST /auth/verify-email` | Verify email with token (when `emailVerification` is configured) |
85
129
  | `POST /auth/resend-verification` | Resend verification email (requires login, when `emailVerification` is configured) |
130
+ | `POST /auth/forgot-password` | Request a password reset email (when `passwordReset` is configured) |
131
+ | `POST /auth/reset-password` | Reset password using a token from the reset email (when `passwordReset` is configured) |
86
132
  | `GET /health` | Health check |
87
133
  | `GET /docs` | Scalar API docs UI |
88
134
  | `GET /openapi.json` | OpenAPI spec |
@@ -195,18 +241,31 @@ await createServer({
195
241
 
196
242
  Import `appConnection` and register models on it. This ensures your models use the correct connection whether you're on a single DB or a separate tenant DB.
197
243
 
244
+ `appConnection` is a lazy proxy — calling `.model()` at the top level works fine even before `connectMongo()` has been called. Mongoose buffers any queries until the connection is established.
245
+
198
246
  ```ts
199
247
  // src/models/Product.ts
200
- import { appConnection, mongoose } from "@lastshotlabs/bunshot";
248
+ import { appConnection } from "@lastshotlabs/bunshot";
249
+ import { Schema } from "mongoose";
250
+ import type { HydratedDocument } from "mongoose";
251
+
252
+ interface IProduct {
253
+ name: string;
254
+ price: number;
255
+ }
201
256
 
202
- const ProductSchema = new mongoose.Schema({
257
+ export type ProductDocument = HydratedDocument<IProduct>;
258
+
259
+ const ProductSchema = new Schema<IProduct>({
203
260
  name: { type: String, required: true },
204
261
  price: { type: Number, required: true },
205
262
  }, { timestamps: true });
206
263
 
207
- export const Product = appConnection.model("Product", ProductSchema);
264
+ export const Product = appConnection.model<IProduct>("Product", ProductSchema);
208
265
  ```
209
266
 
267
+ > **Note:** Import types (`HydratedDocument`, `Schema`, etc.) directly from `"mongoose"` — the `appConnection` and `mongoose` exports from bunshot are runtime proxies and cannot be used as TypeScript namespaces.
268
+
210
269
  ---
211
270
 
212
271
  ## Jobs (BullMQ)
@@ -722,13 +781,27 @@ await createServer({
722
781
  await resend.emails.send({ to: email, subject: "Verify your email", text: `Token: ${token}` });
723
782
  },
724
783
  },
784
+ passwordReset: { // optional — only active when primaryField is "email"
785
+ tokenExpiry: 60 * 60, // default: 3600 (1 hour) — token TTL in seconds
786
+ onSend: async (email, token) => { // called by POST /auth/forgot-password — use any email provider
787
+ await resend.emails.send({ to: email, subject: "Reset your password", text: `Token: ${token}` });
788
+ },
789
+ },
725
790
  rateLimit: { // optional — built-in auth endpoint rate limiting
726
791
  login: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 failures / 15 min
727
792
  register: { windowMs: 60 * 60 * 1000, max: 5 }, // default: 5 attempts / hour (per IP)
728
793
  verifyEmail: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
729
794
  resendVerification: { windowMs: 60 * 60 * 1000, max: 3 }, // default: 3 attempts / hour (per user)
795
+ forgotPassword: { windowMs: 15 * 60 * 1000, max: 5 }, // default: 5 attempts / 15 min (per IP)
796
+ resetPassword: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
730
797
  store: "redis", // default: "redis" when Redis is enabled, else "memory"
731
798
  },
799
+ sessionPolicy: { // optional — session concurrency and metadata
800
+ maxSessions: 6, // default: 6 — max simultaneous sessions per user; oldest evicted when exceeded
801
+ persistSessionMetadata: true, // default: true — keep IP/UA/timestamp row after session expires (for device detection)
802
+ includeInactiveSessions: false, // default: false — include expired/deleted sessions in GET /auth/sessions
803
+ trackLastActive: false, // default: false — update lastActiveAt on every auth'd request (adds one DB write)
804
+ },
732
805
  oauth: {
733
806
  providers: { google: { ... }, apple: { ... } }, // omit a provider to disable it
734
807
  postRedirect: "/dashboard", // default: "/"
@@ -791,7 +864,7 @@ await createServer({
791
864
  });
792
865
  ```
793
866
 
794
- Redis key namespacing: when Redis is used, all keys are prefixed with `appName` (`session:{appName}:{userId}`, `oauth:{appName}:state:{state}`, `cache:{appName}:{key}`) so multiple apps sharing one Redis instance never collide.
867
+ Redis key namespacing: when Redis is used, all keys are prefixed with `appName` (`session:{appName}:{sessionId}`, `usersessions:{appName}:{userId}`, `oauth:{appName}:state:{state}`, `cache:{appName}:{key}`) so multiple apps sharing one Redis instance never collide.
795
868
 
796
869
  ---
797
870
 
@@ -860,7 +933,7 @@ clearMemoryStore();
860
933
 
861
934
  ## Auth Flow
862
935
 
863
- Sessions are backed by Redis by default (`session:{appName}:{userId}`). Set `db.sessions: "mongo"` to store them in MongoDB instead — useful when running without Redis. See [Running without Redis](#running-without-redis).
936
+ Sessions are backed by Redis by default. Each login creates an independent session keyed by a UUID (`session:{appName}:{sessionId}`), so multiple devices / tabs can be logged in simultaneously. Set `db.sessions: "mongo"` to store them in MongoDB instead — useful when running without Redis. See [Running without Redis](#running-without-redis).
864
937
 
865
938
  ### Browser clients
866
939
  1. `POST /auth/login` → JWT set as HttpOnly cookie automatically
@@ -870,6 +943,20 @@ Sessions are backed by Redis by default (`session:{appName}:{userId}`). Set `db.
870
943
  1. `POST /auth/login` → read `token` from response body
871
944
  2. Send `x-user-token: <token>` header on every request
872
945
 
946
+ ### Session management
947
+
948
+ Each login creates an independent session so multiple devices stay logged in simultaneously. The framework enforces a configurable cap (default: 6) — the oldest session is evicted when the limit is exceeded.
949
+
950
+ ```
951
+ GET /auth/sessions → [{ sessionId, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent, isActive }]
952
+ DELETE /auth/sessions/:sessionId → revoke a specific session (other sessions unaffected)
953
+ POST /auth/logout → revoke only the current session
954
+ ```
955
+
956
+ Session metadata (IP address, user-agent, timestamps) is persisted even after a session expires when `sessionPolicy.persistSessionMetadata: true` (default). This enables tenant apps to detect logins from novel devices or locations and prompt for MFA or send a security alert.
957
+
958
+ Set `sessionPolicy.includeInactiveSessions: true` to surface expired/deleted sessions in `GET /auth/sessions` with `isActive: false` — useful for a full device-history UI similar to Google or Meta's account security page.
959
+
873
960
  ### Protecting routes
874
961
 
875
962
  ```ts
@@ -972,6 +1059,8 @@ All built-in auth endpoints are rate-limited out of the box with sensible defaul
972
1059
  | `POST /auth/register` | IP address | Every attempt | 5 / hour |
973
1060
  | `POST /auth/verify-email` | IP address | Every attempt | 10 / 15 min |
974
1061
  | `POST /auth/resend-verification` | User ID (authenticated) | Every attempt | 3 / hour |
1062
+ | `POST /auth/forgot-password` | IP address | Every attempt | 5 / 15 min |
1063
+ | `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
975
1064
 
976
1065
  Login is keyed by the **identifier being targeted** — an attacker rotating IPs to brute-force `alice@example.com` is blocked regardless of source IP. A successful login resets the counter so legitimate users aren't locked out.
977
1066
 
@@ -1454,7 +1543,8 @@ import {
1454
1543
 
1455
1544
  // Auth utilities
1456
1545
  signToken, verifyToken,
1457
- createSession, getSession, deleteSession, setSessionStore,
1546
+ createSession, getSession, deleteSession, getUserSessions, getActiveSessionCount,
1547
+ evictOldestSession, updateSessionLastActive, setSessionStore,
1458
1548
  createVerificationToken, getVerificationToken, deleteVerificationToken, // email verification tokens
1459
1549
  bustAuthLimit, trackAttempt, isLimited, // auth rate limiting — use in custom routes or admin unlocks
1460
1550
  buildFingerprint, // HTTP fingerprint hash (IP-independent) — use in custom bot detection logic
@@ -1,10 +1,15 @@
1
1
  import type { AuthAdapter } from "../lib/authAdapter";
2
+ import type { SessionMetadata, SessionInfo } from "../lib/session";
2
3
  /** Reset all in-memory state. Useful for test isolation. */
3
4
  export declare const clearMemoryStore: () => void;
4
5
  export declare const memoryAuthAdapter: AuthAdapter;
5
- export declare const memoryCreateSession: (userId: string, token: string) => void;
6
- export declare const memoryGetSession: (userId: string) => string | null;
7
- export declare const memoryDeleteSession: (userId: string) => void;
6
+ export declare const memoryCreateSession: (userId: string, token: string, sessionId: string, metadata?: SessionMetadata) => void;
7
+ export declare const memoryGetSession: (sessionId: string) => string | null;
8
+ export declare const memoryDeleteSession: (sessionId: string) => void;
9
+ export declare const memoryGetUserSessions: (userId: string) => SessionInfo[];
10
+ export declare const memoryGetActiveSessionCount: (userId: string) => number;
11
+ export declare const memoryEvictOldestSession: (userId: string) => void;
12
+ export declare const memoryUpdateSessionLastActive: (sessionId: string) => void;
8
13
  export declare const memoryStoreOAuthState: (state: string, codeVerifier?: string, linkUserId?: string) => void;
9
14
  export declare const memoryConsumeOAuthState: (state: string) => {
10
15
  codeVerifier?: string;
@@ -20,3 +25,8 @@ export declare const memoryGetVerificationToken: (token: string) => {
20
25
  email: string;
21
26
  } | null;
22
27
  export declare const memoryDeleteVerificationToken: (token: string) => void;
28
+ export declare const memoryCreateResetToken: (token: string, userId: string, email: string, ttlSeconds: number) => void;
29
+ export declare const memoryConsumeResetToken: (hash: string) => {
30
+ userId: string;
31
+ email: string;
32
+ } | null;
@@ -1,18 +1,23 @@
1
1
  import { HttpError } from "../lib/HttpError";
2
+ import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
2
3
  const _users = new Map();
3
4
  const _byEmail = new Map();
4
- const _sessions = new Map();
5
+ const _sessions = new Map(); // sessionId → session
6
+ const _userSessionIds = new Map(); // userId → Set<sessionId>
5
7
  const _oauthStates = new Map();
6
8
  const _cache = new Map();
7
9
  const _verificationTokens = new Map();
10
+ const _resetTokens = new Map();
8
11
  /** Reset all in-memory state. Useful for test isolation. */
9
12
  export const clearMemoryStore = () => {
10
13
  _users.clear();
11
14
  _byEmail.clear();
12
15
  _sessions.clear();
16
+ _userSessionIds.clear();
13
17
  _oauthStates.clear();
14
18
  _cache.clear();
15
19
  _verificationTokens.clear();
20
+ _resetTokens.clear();
16
21
  };
17
22
  // ---------------------------------------------------------------------------
18
23
  // Auth adapter
@@ -132,17 +137,99 @@ export const memoryAuthAdapter = {
132
137
  // Session helpers (used by src/lib/session.ts)
133
138
  // ---------------------------------------------------------------------------
134
139
  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 });
140
+ export const memoryCreateSession = (userId, token, sessionId, metadata) => {
141
+ const now = Date.now();
142
+ const session = {
143
+ sessionId, userId, token,
144
+ createdAt: now, lastActiveAt: now, expiresAt: now + SESSION_TTL_MS,
145
+ ipAddress: metadata?.ipAddress,
146
+ userAgent: metadata?.userAgent,
147
+ };
148
+ _sessions.set(sessionId, session);
149
+ if (!_userSessionIds.has(userId))
150
+ _userSessionIds.set(userId, new Set());
151
+ _userSessionIds.get(userId).add(sessionId);
137
152
  };
138
- export const memoryGetSession = (userId) => {
139
- const entry = _sessions.get(userId);
140
- if (!entry || entry.expiresAt <= Date.now())
153
+ export const memoryGetSession = (sessionId) => {
154
+ const entry = _sessions.get(sessionId);
155
+ if (!entry || !entry.token || entry.expiresAt <= Date.now())
141
156
  return null;
142
157
  return entry.token;
143
158
  };
144
- export const memoryDeleteSession = (userId) => {
145
- _sessions.delete(userId);
159
+ export const memoryDeleteSession = (sessionId) => {
160
+ const entry = _sessions.get(sessionId);
161
+ if (!entry)
162
+ return;
163
+ if (getPersistSessionMetadata()) {
164
+ entry.token = null;
165
+ }
166
+ else {
167
+ _sessions.delete(sessionId);
168
+ _userSessionIds.get(entry.userId)?.delete(sessionId);
169
+ }
170
+ };
171
+ export const memoryGetUserSessions = (userId) => {
172
+ const ids = _userSessionIds.get(userId);
173
+ if (!ids)
174
+ return [];
175
+ const now = Date.now();
176
+ const includeInactive = getIncludeInactiveSessions();
177
+ const persist = getPersistSessionMetadata();
178
+ const results = [];
179
+ for (const sessionId of ids) {
180
+ const s = _sessions.get(sessionId);
181
+ if (!s)
182
+ continue;
183
+ const isActive = !!s.token && s.expiresAt > now;
184
+ if (!isActive && !persist)
185
+ continue;
186
+ if (!isActive && !includeInactive)
187
+ continue;
188
+ results.push({
189
+ sessionId: s.sessionId,
190
+ createdAt: s.createdAt,
191
+ lastActiveAt: s.lastActiveAt,
192
+ expiresAt: s.expiresAt,
193
+ ipAddress: s.ipAddress,
194
+ userAgent: s.userAgent,
195
+ isActive,
196
+ });
197
+ }
198
+ return results;
199
+ };
200
+ export const memoryGetActiveSessionCount = (userId) => {
201
+ const ids = _userSessionIds.get(userId);
202
+ if (!ids)
203
+ return 0;
204
+ const now = Date.now();
205
+ let count = 0;
206
+ for (const sessionId of ids) {
207
+ const s = _sessions.get(sessionId);
208
+ if (s && s.token && s.expiresAt > now)
209
+ count++;
210
+ }
211
+ return count;
212
+ };
213
+ export const memoryEvictOldestSession = (userId) => {
214
+ const ids = _userSessionIds.get(userId);
215
+ if (!ids)
216
+ return;
217
+ const now = Date.now();
218
+ let oldest = null;
219
+ for (const sessionId of ids) {
220
+ const s = _sessions.get(sessionId);
221
+ if (!s || !s.token || s.expiresAt <= now)
222
+ continue;
223
+ if (!oldest || s.createdAt < oldest.createdAt)
224
+ oldest = s;
225
+ }
226
+ if (oldest)
227
+ memoryDeleteSession(oldest.sessionId);
228
+ };
229
+ export const memoryUpdateSessionLastActive = (sessionId) => {
230
+ const entry = _sessions.get(sessionId);
231
+ if (entry)
232
+ entry.lastActiveAt = Date.now();
146
233
  };
147
234
  // ---------------------------------------------------------------------------
148
235
  // OAuth state helpers (used by src/lib/oauth.ts)
@@ -205,3 +292,24 @@ export const memoryGetVerificationToken = (token) => {
205
292
  export const memoryDeleteVerificationToken = (token) => {
206
293
  _verificationTokens.delete(token);
207
294
  };
295
+ // ---------------------------------------------------------------------------
296
+ // Password reset token helpers (used by src/lib/resetPassword.ts)
297
+ // ---------------------------------------------------------------------------
298
+ export const memoryCreateResetToken = (token, userId, email, ttlSeconds) => {
299
+ const now = Date.now();
300
+ // Opportunistically purge expired entries to prevent unbounded memory growth
301
+ for (const [k, v] of _resetTokens) {
302
+ if (v.expiresAt <= now)
303
+ _resetTokens.delete(k);
304
+ }
305
+ _resetTokens.set(token, { userId, email, expiresAt: now + ttlSeconds * 1000 });
306
+ };
307
+ export const memoryConsumeResetToken = (hash) => {
308
+ const entry = _resetTokens.get(hash);
309
+ if (!entry || entry.expiresAt <= Date.now()) {
310
+ _resetTokens.delete(hash);
311
+ return null;
312
+ }
313
+ _resetTokens.delete(hash);
314
+ return { userId: entry.userId, email: entry.email };
315
+ };
@@ -1,9 +1,14 @@
1
1
  import type { AuthAdapter } from "../lib/authAdapter";
2
2
  export declare const setSqliteDb: (path: string) => void;
3
3
  export declare const sqliteAuthAdapter: AuthAdapter;
4
- export declare const sqliteCreateSession: (userId: string, token: string) => void;
5
- export declare const sqliteGetSession: (userId: string) => string | null;
6
- export declare const sqliteDeleteSession: (userId: string) => void;
4
+ import type { SessionMetadata, SessionInfo } from "../lib/session";
5
+ export declare const sqliteCreateSession: (userId: string, token: string, sessionId: string, metadata?: SessionMetadata) => void;
6
+ export declare const sqliteGetSession: (sessionId: string) => string | null;
7
+ export declare const sqliteDeleteSession: (sessionId: string) => void;
8
+ export declare const sqliteGetUserSessions: (userId: string) => SessionInfo[];
9
+ export declare const sqliteGetActiveSessionCount: (userId: string) => number;
10
+ export declare const sqliteEvictOldestSession: (userId: string) => void;
11
+ export declare const sqliteUpdateSessionLastActive: (sessionId: string) => void;
7
12
  export declare const sqliteStoreOAuthState: (state: string, codeVerifier?: string, linkUserId?: string) => void;
8
13
  export declare const sqliteConsumeOAuthState: (state: string) => {
9
14
  codeVerifier?: string;
@@ -20,4 +25,9 @@ export declare const sqliteGetVerificationToken: (token: string) => {
20
25
  email: string;
21
26
  } | null;
22
27
  export declare const sqliteDeleteVerificationToken: (token: string) => void;
28
+ export declare const sqliteCreateResetToken: (token: string, userId: string, email: string, ttlSeconds: number) => void;
29
+ export declare const sqliteConsumeResetToken: (hash: string) => {
30
+ userId: string;
31
+ email: string;
32
+ } | null;
23
33
  export declare const startSqliteCleanup: (intervalMs?: number) => ReturnType<typeof setInterval>;
@@ -32,11 +32,22 @@ function initSchema(db) {
32
32
  db.run("ALTER TABLE users ADD COLUMN emailVerified INTEGER NOT NULL DEFAULT 0");
33
33
  }
34
34
  catch { /* already exists */ }
35
+ // Migrate legacy sessions table (userId PK) to new multi-session schema (sessionId PK)
36
+ try {
37
+ db.run("ALTER TABLE sessions RENAME TO sessions_legacy");
38
+ }
39
+ catch { /* already migrated */ }
35
40
  db.run(`CREATE TABLE IF NOT EXISTS sessions (
36
- userId TEXT PRIMARY KEY,
37
- token TEXT NOT NULL,
38
- expiresAt INTEGER NOT NULL
41
+ sessionId TEXT PRIMARY KEY,
42
+ userId TEXT NOT NULL,
43
+ token TEXT,
44
+ createdAt INTEGER NOT NULL,
45
+ lastActiveAt INTEGER NOT NULL,
46
+ expiresAt INTEGER NOT NULL,
47
+ ipAddress TEXT,
48
+ userAgent TEXT
39
49
  )`);
50
+ db.run("CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId)");
40
51
  db.run(`CREATE TABLE IF NOT EXISTS oauth_states (
41
52
  state TEXT PRIMARY KEY,
42
53
  codeVerifier TEXT,
@@ -54,6 +65,12 @@ function initSchema(db) {
54
65
  email TEXT NOT NULL,
55
66
  expiresAt INTEGER NOT NULL
56
67
  )`);
68
+ db.run(`CREATE TABLE IF NOT EXISTS password_resets (
69
+ token TEXT PRIMARY KEY,
70
+ userId TEXT NOT NULL,
71
+ email TEXT NOT NULL,
72
+ expiresAt INTEGER NOT NULL
73
+ )`);
57
74
  }
58
75
  // ---------------------------------------------------------------------------
59
76
  // Auth adapter
@@ -161,20 +178,63 @@ export const sqliteAuthAdapter = {
161
178
  return row?.emailVerified === 1;
162
179
  },
163
180
  };
164
- // ---------------------------------------------------------------------------
165
- // Session helpers (used by src/lib/session.ts)
166
- // ---------------------------------------------------------------------------
181
+ import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
167
182
  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]);
183
+ export const sqliteCreateSession = (userId, token, sessionId, metadata) => {
184
+ const now = Date.now();
185
+ const expiresAt = now + SESSION_TTL_MS;
186
+ getDb().run("INSERT INTO sessions (sessionId, userId, token, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [sessionId, userId, token, now, now, expiresAt, metadata?.ipAddress ?? null, metadata?.userAgent ?? null]);
187
+ };
188
+ export const sqliteGetSession = (sessionId) => {
189
+ const row = getDb().query("SELECT token FROM sessions WHERE sessionId = ? AND expiresAt > ?").get(sessionId, Date.now());
190
+ if (!row || !row.token)
191
+ return null;
192
+ return row.token;
193
+ };
194
+ export const sqliteDeleteSession = (sessionId) => {
195
+ if (getPersistSessionMetadata()) {
196
+ getDb().run("UPDATE sessions SET token = NULL WHERE sessionId = ?", [sessionId]);
197
+ }
198
+ else {
199
+ getDb().run("DELETE FROM sessions WHERE sessionId = ?", [sessionId]);
200
+ }
201
+ };
202
+ export const sqliteGetUserSessions = (userId) => {
203
+ const now = Date.now();
204
+ const rows = getDb().query("SELECT sessionId, token, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent FROM sessions WHERE userId = ? ORDER BY createdAt ASC").all(userId);
205
+ const includeInactive = getIncludeInactiveSessions();
206
+ const persist = getPersistSessionMetadata();
207
+ const results = [];
208
+ for (const row of rows) {
209
+ const isActive = !!row.token && row.expiresAt > now;
210
+ if (!isActive && !persist)
211
+ continue;
212
+ if (!isActive && !includeInactive)
213
+ continue;
214
+ results.push({
215
+ sessionId: row.sessionId,
216
+ createdAt: row.createdAt,
217
+ lastActiveAt: row.lastActiveAt,
218
+ expiresAt: row.expiresAt,
219
+ ipAddress: row.ipAddress ?? undefined,
220
+ userAgent: row.userAgent ?? undefined,
221
+ isActive,
222
+ });
223
+ }
224
+ return results;
171
225
  };
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;
226
+ export const sqliteGetActiveSessionCount = (userId) => {
227
+ const row = getDb().query("SELECT COUNT(*) AS count FROM sessions WHERE userId = ? AND token IS NOT NULL AND expiresAt > ?").get(userId, Date.now());
228
+ return row?.count ?? 0;
175
229
  };
176
- export const sqliteDeleteSession = (userId) => {
177
- getDb().run("DELETE FROM sessions WHERE userId = ?", [userId]);
230
+ export const sqliteEvictOldestSession = (userId) => {
231
+ const now = Date.now();
232
+ const oldest = getDb().query("SELECT sessionId FROM sessions WHERE userId = ? AND token IS NOT NULL AND expiresAt > ? ORDER BY createdAt ASC LIMIT 1").get(userId, now);
233
+ if (oldest)
234
+ sqliteDeleteSession(oldest.sessionId);
235
+ };
236
+ export const sqliteUpdateSessionLastActive = (sessionId) => {
237
+ getDb().run("UPDATE sessions SET lastActiveAt = ? WHERE sessionId = ?", [Date.now(), sessionId]);
178
238
  };
179
239
  // ---------------------------------------------------------------------------
180
240
  // OAuth state helpers (used by src/lib/oauth.ts)
@@ -228,15 +288,33 @@ export const sqliteDeleteVerificationToken = (token) => {
228
288
  getDb().run("DELETE FROM email_verifications WHERE token = ?", [token]);
229
289
  };
230
290
  // ---------------------------------------------------------------------------
291
+ // Password reset token helpers (used by src/lib/resetPassword.ts)
292
+ // ---------------------------------------------------------------------------
293
+ export const sqliteCreateResetToken = (token, userId, email, ttlSeconds) => {
294
+ const expiresAt = Date.now() + ttlSeconds * 1000;
295
+ getDb().run("INSERT INTO password_resets (token, userId, email, expiresAt) VALUES (?, ?, ?, ?)", [token, userId, email, expiresAt]);
296
+ };
297
+ export const sqliteConsumeResetToken = (hash) => {
298
+ const row = getDb().query("DELETE FROM password_resets WHERE token = ? AND expiresAt > ? RETURNING userId, email").get(hash, Date.now());
299
+ return row ?? null;
300
+ };
301
+ // ---------------------------------------------------------------------------
231
302
  // Optional periodic cleanup of expired rows
232
303
  // ---------------------------------------------------------------------------
233
304
  export const startSqliteCleanup = (intervalMs = 3_600_000) => {
234
305
  return setInterval(() => {
235
306
  const db = getDb();
236
307
  const now = Date.now();
237
- db.run("DELETE FROM sessions WHERE expiresAt <= ?", [now]);
308
+ if (getPersistSessionMetadata()) {
309
+ // Null out tokens for expired sessions but keep the metadata row
310
+ db.run("UPDATE sessions SET token = NULL WHERE expiresAt <= ? AND token IS NOT NULL", [now]);
311
+ }
312
+ else {
313
+ db.run("DELETE FROM sessions WHERE expiresAt <= ?", [now]);
314
+ }
238
315
  db.run("DELETE FROM oauth_states WHERE expiresAt <= ?", [now]);
239
316
  db.run("DELETE FROM cache_entries WHERE expiresAt IS NOT NULL AND expiresAt <= ?", [now]);
240
317
  db.run("DELETE FROM email_verifications WHERE expiresAt <= ?", [now]);
318
+ db.run("DELETE FROM password_resets WHERE expiresAt <= ?", [now]);
241
319
  }, intervalMs);
242
320
  };
package/dist/app.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { MiddlewareHandler } from "hono";
3
3
  import type { AppEnv } from "./lib/context";
4
- import type { PrimaryField, EmailVerificationConfig } from "./lib/appConfig";
4
+ import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig } from "./lib/appConfig";
5
5
  import type { AuthAdapter } from "./lib/authAdapter";
6
6
  import type { OAuthProviderConfig } from "./lib/oauth";
7
7
  type StoreType = "redis" | "mongo" | "sqlite" | "memory";
@@ -82,6 +82,16 @@ export interface AuthRateLimitConfig {
82
82
  windowMs?: number;
83
83
  max?: number;
84
84
  };
85
+ /** Max forgot-password requests per IP per window. Default: 5 per 15 min. */
86
+ forgotPassword?: {
87
+ windowMs?: number;
88
+ max?: number;
89
+ };
90
+ /** Max reset-password attempts per IP per window. Default: 10 per 15 min. */
91
+ resetPassword?: {
92
+ windowMs?: number;
93
+ max?: number;
94
+ };
85
95
  /**
86
96
  * Store backend for auth rate limit counters.
87
97
  * Defaults to "redis" when Redis is enabled, otherwise "memory".
@@ -116,10 +126,37 @@ export interface AuthConfig {
116
126
  * Provide an onSend callback to send the verification email via any provider (Resend, SendGrid, etc.).
117
127
  */
118
128
  emailVerification?: EmailVerificationConfig;
129
+ /**
130
+ * Password reset configuration. Only active when primaryField is "email".
131
+ * Provide an onSend callback to send the reset email via any provider (Resend, SendGrid, etc.).
132
+ * Mounts POST /auth/forgot-password and POST /auth/reset-password.
133
+ */
134
+ passwordReset?: PasswordResetConfig;
119
135
  /** Rate limit configuration for built-in auth endpoints. */
120
136
  rateLimit?: AuthRateLimitConfig;
137
+ /** Session concurrency and metadata persistence policy. */
138
+ sessionPolicy?: AuthSessionPolicyConfig;
139
+ }
140
+ export interface AuthSessionPolicyConfig {
141
+ /** Max simultaneous active sessions per user. Oldest is evicted when exceeded. Default: 6. */
142
+ maxSessions?: number;
143
+ /**
144
+ * Retain session metadata (IP, user-agent, timestamps) after a session expires or is deleted.
145
+ * Enables future novel-device/location detection. Default: true.
146
+ */
147
+ persistSessionMetadata?: boolean;
148
+ /**
149
+ * Include inactive (expired/deleted) sessions in GET /auth/sessions.
150
+ * Only meaningful when persistSessionMetadata is true. Default: false.
151
+ */
152
+ includeInactiveSessions?: boolean;
153
+ /**
154
+ * Update lastActiveAt on every authenticated request.
155
+ * Adds one DB write per auth'd request. Default: false.
156
+ */
157
+ trackLastActive?: boolean;
121
158
  }
122
- export type { PrimaryField, EmailVerificationConfig };
159
+ export type { PrimaryField, EmailVerificationConfig, PasswordResetConfig };
123
160
  export interface BotProtectionConfig {
124
161
  /**
125
162
  * List of IPv4 CIDRs (e.g. "198.51.100.0/24"), IPv4 addresses, or IPv6 addresses to block outright.