@lastshotlabs/bunshot 0.0.13 → 0.0.16

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 (98) hide show
  1. package/README.md +2510 -1747
  2. package/dist/adapters/memoryAuth.d.ts +4 -0
  3. package/dist/adapters/memoryAuth.js +131 -2
  4. package/dist/adapters/mongoAuth.js +56 -0
  5. package/dist/adapters/sqliteAuth.d.ts +6 -0
  6. package/dist/adapters/sqliteAuth.js +137 -2
  7. package/dist/app.d.ts +77 -2
  8. package/dist/app.js +29 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +14 -5
  12. package/dist/index.js +9 -3
  13. package/dist/lib/appConfig.d.ts +46 -0
  14. package/dist/lib/appConfig.js +20 -0
  15. package/dist/lib/authAdapter.d.ts +30 -0
  16. package/dist/lib/constants.d.ts +2 -0
  17. package/dist/lib/constants.js +2 -0
  18. package/dist/lib/context.d.ts +2 -0
  19. package/dist/lib/createDtoMapper.d.ts +33 -0
  20. package/dist/lib/createDtoMapper.js +69 -0
  21. package/dist/lib/jwt.d.ts +1 -1
  22. package/dist/lib/jwt.js +2 -2
  23. package/dist/lib/mfaChallenge.d.ts +20 -0
  24. package/dist/lib/mfaChallenge.js +184 -0
  25. package/dist/lib/queue.d.ts +33 -0
  26. package/dist/lib/queue.js +98 -0
  27. package/dist/lib/roles.d.ts +4 -0
  28. package/dist/lib/roles.js +27 -0
  29. package/dist/lib/session.d.ts +12 -0
  30. package/dist/lib/session.js +163 -5
  31. package/dist/lib/tenant.d.ts +15 -0
  32. package/dist/lib/tenant.js +65 -0
  33. package/dist/lib/zodToMongoose.d.ts +38 -0
  34. package/dist/lib/zodToMongoose.js +84 -0
  35. package/dist/middleware/cacheResponse.js +4 -1
  36. package/dist/middleware/rateLimit.d.ts +2 -1
  37. package/dist/middleware/rateLimit.js +5 -2
  38. package/dist/middleware/requireRole.d.ts +14 -3
  39. package/dist/middleware/requireRole.js +46 -6
  40. package/dist/middleware/tenant.d.ts +5 -0
  41. package/dist/middleware/tenant.js +116 -0
  42. package/dist/models/AuthUser.d.ts +8 -0
  43. package/dist/models/AuthUser.js +8 -0
  44. package/dist/models/TenantRole.d.ts +15 -0
  45. package/dist/models/TenantRole.js +23 -0
  46. package/dist/routes/auth.d.ts +5 -3
  47. package/dist/routes/auth.js +153 -22
  48. package/dist/routes/jobs.d.ts +2 -0
  49. package/dist/routes/jobs.js +270 -0
  50. package/dist/routes/mfa.d.ts +1 -0
  51. package/dist/routes/mfa.js +409 -0
  52. package/dist/routes/oauth.js +107 -16
  53. package/dist/server.js +9 -0
  54. package/dist/services/auth.d.ts +17 -5
  55. package/dist/services/auth.js +95 -17
  56. package/dist/services/mfa.d.ts +37 -0
  57. package/dist/services/mfa.js +276 -0
  58. package/docs/sections/adding-middleware/full.md +35 -0
  59. package/docs/sections/adding-models/full.md +125 -0
  60. package/docs/sections/adding-models/overview.md +13 -0
  61. package/docs/sections/adding-routes/full.md +182 -0
  62. package/docs/sections/adding-routes/overview.md +23 -0
  63. package/docs/sections/auth-flow/full.md +456 -0
  64. package/docs/sections/auth-flow/overview.md +10 -0
  65. package/docs/sections/cli/full.md +30 -0
  66. package/docs/sections/configuration/full.md +135 -0
  67. package/docs/sections/configuration/overview.md +17 -0
  68. package/docs/sections/configuration-example/full.md +99 -0
  69. package/docs/sections/configuration-example/overview.md +30 -0
  70. package/docs/sections/documentation/full.md +171 -0
  71. package/docs/sections/environment-variables/full.md +55 -0
  72. package/docs/sections/exports/full.md +83 -0
  73. package/docs/sections/extending-context/full.md +59 -0
  74. package/docs/sections/header.md +3 -0
  75. package/docs/sections/installation/full.md +6 -0
  76. package/docs/sections/jobs/full.md +140 -0
  77. package/docs/sections/jobs/overview.md +15 -0
  78. package/docs/sections/mongodb-connections/full.md +45 -0
  79. package/docs/sections/mongodb-connections/overview.md +7 -0
  80. package/docs/sections/multi-tenancy/full.md +62 -0
  81. package/docs/sections/multi-tenancy/overview.md +15 -0
  82. package/docs/sections/oauth/full.md +119 -0
  83. package/docs/sections/oauth/overview.md +16 -0
  84. package/docs/sections/package-development/full.md +7 -0
  85. package/docs/sections/peer-dependencies/full.md +43 -0
  86. package/docs/sections/quick-start/full.md +43 -0
  87. package/docs/sections/response-caching/full.md +115 -0
  88. package/docs/sections/response-caching/overview.md +13 -0
  89. package/docs/sections/roles/full.md +136 -0
  90. package/docs/sections/roles/overview.md +12 -0
  91. package/docs/sections/running-without-redis/full.md +16 -0
  92. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  93. package/docs/sections/stack/full.md +10 -0
  94. package/docs/sections/websocket/full.md +100 -0
  95. package/docs/sections/websocket/overview.md +5 -0
  96. package/docs/sections/websocket-rooms/full.md +97 -0
  97. package/docs/sections/websocket-rooms/overview.md +5 -0
  98. package/package.json +19 -10
@@ -0,0 +1,182 @@
1
+ ## Adding Routes
2
+
3
+ Drop a file in your `routes/` directory that exports a `router` — see the [Quick Start](#quick-start) example above. Routes are auto-discovered via glob — no registration needed. Subdirectories are supported, so you can organise by feature:
4
+
5
+ ```
6
+ routes/
7
+ products.ts
8
+ ingredients/
9
+ list.ts
10
+ detail.ts
11
+ ```
12
+
13
+ ### OpenAPI Schema Registration
14
+
15
+ Import `createRoute` from `@lastshotlabs/bunshot` (not from `@hono/zod-openapi`). The wrapper automatically registers every unnamed request body and response schema as a named entry in `components/schemas`. Schemas you already named via `registerSchema` are never overwritten.
16
+
17
+ Every Zod schema that appears in your OpenAPI spec ends up as a named entry in `components/schemas` — either auto-named by the framework or explicitly named by you. There are four registration methods, each suited to a different scenario.
18
+
19
+ ---
20
+
21
+ ### Method 1 — Route-level auto-registration (via `createRoute`)
22
+
23
+ The most common case. When you define a route with `createRoute`, every unnamed request body and response schema is automatically registered under a name derived from the HTTP method and path.
24
+
25
+ **Naming convention**
26
+
27
+ | Route | Part | Generated name |
28
+ |-------|------|----------------|
29
+ | `POST /products` | request body | `CreateProductsRequest` |
30
+ | `POST /products` | 201 response | `CreateProductsResponse` |
31
+ | `GET /products/{id}` | 200 response | `GetProductsByIdResponse` |
32
+ | `DELETE /products/{id}` | 404 response | `DeleteProductsByIdNotFoundError` |
33
+ | `PATCH /products/{id}` | request body | `UpdateProductsByIdRequest` |
34
+
35
+ HTTP methods → verbs: `GET → Get`, `POST → Create`, `PUT → Replace`, `PATCH → Update`, `DELETE → Delete`.
36
+
37
+ Status codes → suffixes: `200/201/204 → Response`, `400 → BadRequestError`, `401 → UnauthorizedError`, `403 → ForbiddenError`, `404 → NotFoundError`, `409 → ConflictError`, `422 → ValidationError`, `429 → RateLimitError`, `500 → InternalError`, `501 → NotImplementedError`, `503 → UnavailableError`. Unknown codes fall back to the number.
38
+
39
+ **Limitation:** if the same Zod object is used in two different routes, each route names it after itself — you get two identical inline shapes instead of one shared `$ref`. Use Method 2 or 3 to fix this.
40
+
41
+ ---
42
+
43
+ ### Method 2 — Directory / glob auto-discovery (via `modelSchemas`)
44
+
45
+ Use this when you have schemas shared across multiple routes. Point `modelSchemas` at one or more directories and Bunshot imports every `.ts` file **before** routes are loaded. Any exported Zod schema is registered automatically — same object referenced in multiple routes → same `$ref` in the spec.
46
+
47
+ **Naming:** export name with the trailing `Schema` suffix stripped (`LedgerItemSchema` → `"LedgerItem"`). Already-registered schemas are never overwritten.
48
+
49
+ ```ts
50
+ // src/schemas/ledgerItem.ts
51
+ import { z } from "zod";
52
+ export const LedgerItemSchema = z.object({ id: z.string(), name: z.string(), amount: z.number() });
53
+ // → auto-registered as "LedgerItem"
54
+ ```
55
+
56
+ ```ts
57
+ // src/config/index.ts
58
+ await createServer({
59
+ routesDir: import.meta.dir + "/routes",
60
+ modelSchemas: import.meta.dir + "/schemas", // string shorthand — registration: "auto"
61
+ });
62
+ ```
63
+
64
+ ```ts
65
+ // src/routes/ledger.ts AND src/routes/ledgerDetail.ts
66
+ import { LedgerItemSchema } from "@schemas/ledgerItem"; // same Zod object instance
67
+ createRoute({ responses: { 200: { content: { "application/json": { schema: LedgerItemSchema } } } } });
68
+ // → $ref: "#/components/schemas/LedgerItem" in both routes
69
+ ```
70
+
71
+ **Multiple directories and glob patterns**
72
+
73
+ ```ts
74
+ modelSchemas: [
75
+ import.meta.dir + "/schemas", // dedicated schemas dir
76
+ import.meta.dir + "/models", // co-located with DB models
77
+ import.meta.dir + "/services/**/*.schema.ts", // selective glob
78
+ ]
79
+ ```
80
+
81
+ **Full config object** — use when you need to set `registration` or mix paths and globs:
82
+
83
+ ```ts
84
+ modelSchemas: {
85
+ paths: [import.meta.dir + "/schemas", import.meta.dir + "/models"],
86
+ registration: "auto", // default — auto-registers exports with suffix stripping
87
+ }
88
+ ```
89
+
90
+ **`registration: "explicit"`** — files are imported but nothing is auto-registered. Registration is left entirely to `registerSchema` / `registerSchemas` calls inside each file. Use this when you want zero magic and full name control:
91
+
92
+ ```ts
93
+ modelSchemas: { paths: import.meta.dir + "/schemas", registration: "explicit" }
94
+ ```
95
+
96
+ ---
97
+
98
+ ### Method 3 — Batch explicit registration (via `registerSchemas`)
99
+
100
+ `registerSchemas` lets you name a group of schemas all at once. Object keys become the `components/schemas` names; the same object is returned so you can destructure and export normally. No suffix stripping — names are taken as-is.
101
+
102
+ ```ts
103
+ // src/schemas/index.ts
104
+ import { registerSchemas } from "@lastshotlabs/bunshot";
105
+ import { z } from "zod";
106
+
107
+ export const { LedgerItem, Product, ErrorResponse } = registerSchemas({
108
+ LedgerItem: z.object({ id: z.string(), name: z.string(), amount: z.number() }),
109
+ Product: z.object({ id: z.string(), price: z.number() }),
110
+ ErrorResponse: z.object({ error: z.string() }),
111
+ });
112
+ ```
113
+
114
+ Pair with `registration: "explicit"` in `modelSchemas` so the file is imported before routes, or call it inline at the top of any route file — route files are auto-discovered so the top-level call runs before the spec is served.
115
+
116
+ ---
117
+
118
+ ### Method 4 — Single explicit registration (via `registerSchema`)
119
+
120
+ `registerSchema("Name", schema)` registers one schema and returns it unchanged. Useful for a single shared type (e.g. a common error envelope) or to override the name auto-discovery would generate.
121
+
122
+ ```ts
123
+ // src/schemas/errors.ts
124
+ import { registerSchema } from "@lastshotlabs/bunshot";
125
+ import { z } from "zod";
126
+
127
+ export const ErrorResponse = registerSchema("ErrorResponse",
128
+ z.object({ error: z.string() })
129
+ );
130
+ ```
131
+
132
+ Registration is idempotent — calling `registerSchema` on an already-registered schema is a no-op. This means you can safely call it in files that are also covered by `modelSchemas` auto-discovery: whichever runs first wins, and the other is silently skipped.
133
+
134
+ ---
135
+
136
+ ### Priority and interaction
137
+
138
+ All four methods write to the same process-global registry. The rules are simple:
139
+
140
+ 1. **First write wins** — once a schema has a name, it cannot be renamed.
141
+ 2. **`modelSchemas` files are imported before routes**, so explicit calls inside them always take precedence over what `createRoute` would generate for the same object.
142
+ 3. **`registerSchema` / `registerSchemas` take precedence over auto-discovery** when they appear at module top level (they run at import time, before `maybeAutoRegister` inspects the export list).
143
+ 4. **`createRoute` never overwrites** a schema already in the registry — it only fills gaps.
144
+
145
+ **Decision guide:**
146
+
147
+ | Situation | Use |
148
+ |-----------|-----|
149
+ | Route-specific, one-off schema | `createRoute` auto-registration (Method 1) |
150
+ | Shared across routes, happy with suffix-stripped export name | `modelSchemas` auto-discovery (Method 2) |
151
+ | Shared across routes, want explicit names or batch control | `registerSchemas` (Method 3) |
152
+ | Single shared schema or custom name override | `registerSchema` (Method 4) |
153
+
154
+ **Protected routes**
155
+
156
+ Use `withSecurity` to declare security schemes on a route without breaking `c.req.valid()` type inference. (Inlining `security` directly in `createRoute({...})` causes TypeScript to collapse the handler's input types to `never`.)
157
+
158
+ ```ts
159
+ import { createRoute, withSecurity } from "@lastshotlabs/bunshot";
160
+
161
+ router.openapi(
162
+ withSecurity(
163
+ createRoute({ method: "get", path: "/me", ... }),
164
+ { cookieAuth: [] },
165
+ { userToken: [] }
166
+ ),
167
+ async (c) => {
168
+ const userId = c.get("authUserId"); // fully typed
169
+ }
170
+ );
171
+ ```
172
+
173
+ Pass each security scheme as a separate object argument. The security scheme names (`cookieAuth`, `userToken`, `bearerAuth`) are registered globally by `createApp`.
174
+
175
+ **Load order:** By default, routes load in filesystem order. If a route needs to be registered before another (e.g. for Hono's first-match-wins routing), export a `priority` number — lower values load first. Routes without a `priority` load last.
176
+
177
+ ```ts
178
+ // routes/tenants.ts — must match before generic routes
179
+ export const priority = 1;
180
+ export const router = createRouter();
181
+ // ...
182
+ ```
@@ -0,0 +1,23 @@
1
+ ## Adding Routes
2
+
3
+ Drop a file in your `routes/` directory that exports a `router` — routes are auto-discovered via glob. Subdirectories are supported.
4
+
5
+ ```ts
6
+ import { z } from "zod";
7
+ import { createRoute, createRouter } from "@lastshotlabs/bunshot";
8
+
9
+ export const router = createRouter();
10
+
11
+ router.openapi(
12
+ createRoute({
13
+ method: "get",
14
+ path: "/hello",
15
+ responses: {
16
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Hello" },
17
+ },
18
+ }),
19
+ (c) => c.json({ message: "Hello world!" }, 200)
20
+ );
21
+ ```
22
+
23
+ Import `createRoute` from `@lastshotlabs/bunshot` (not `@hono/zod-openapi`) to get automatic OpenAPI schema registration. Four registration methods are available — route-level auto-registration, directory/glob auto-discovery via `modelSchemas`, batch explicit via `registerSchemas`, and single explicit via `registerSchema`. Use `withSecurity` to add auth requirements without breaking type inference.
@@ -0,0 +1,456 @@
1
+ ## Auth Flow
2
+
3
+ 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).
4
+
5
+ ### Browser clients
6
+ 1. `POST /auth/login` → JWT set as HttpOnly cookie automatically
7
+ 2. All subsequent requests send the cookie — no extra code needed
8
+
9
+ ### API / non-browser clients
10
+ 1. `POST /auth/login` → read `token` from response body
11
+ 2. Send `x-user-token: <token>` header on every request
12
+
13
+ ### Session management
14
+
15
+ 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.
16
+
17
+ ```
18
+ GET /auth/sessions → [{ sessionId, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent, isActive }]
19
+ DELETE /auth/sessions/:sessionId → revoke a specific session (other sessions unaffected)
20
+ POST /auth/logout → revoke only the current session
21
+ ```
22
+
23
+ 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.
24
+
25
+ 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.
26
+
27
+ #### Sliding sessions
28
+
29
+ Set `sessionPolicy.trackLastActive: true` to update `lastActiveAt` on every authenticated request. This adds one DB write per request but enables a sliding-session experience — sessions that are actively used stay fresh. Pair with refresh tokens (below) for true sliding behavior: short-lived access tokens (15 min) keep authorization tight, while a long-lived refresh token (30 days) lets the client silently renew without re-entering credentials.
30
+
31
+ ### Refresh Tokens
32
+
33
+ When configured, login and register return short-lived access tokens (default 15 min) alongside long-lived refresh tokens (default 30 days). The client uses `POST /auth/refresh` to obtain a new access token when the current one expires.
34
+
35
+ ```ts
36
+ await createServer({
37
+ auth: {
38
+ refreshTokens: {
39
+ accessTokenExpiry: 900, // seconds, default: 900 (15 min)
40
+ refreshTokenExpiry: 2_592_000, // seconds, default: 2_592_000 (30 days)
41
+ rotationGraceSeconds: 30, // default: 30 — old token still works briefly after rotation
42
+ },
43
+ },
44
+ });
45
+ ```
46
+
47
+ **When not configured**, the existing 7-day JWT behavior is unchanged — fully backward compatible.
48
+
49
+ #### Endpoints
50
+
51
+ | Endpoint | Purpose |
52
+ |---|---|
53
+ | `POST /auth/login` | Returns `token` + `refreshToken` |
54
+ | `POST /auth/register` | Returns `token` + `refreshToken` |
55
+ | `POST /auth/refresh` | Rotates refresh token, returns new `token` + `refreshToken` |
56
+
57
+ #### Rotation with grace window
58
+
59
+ On each refresh, the server generates a new refresh token but keeps the old one valid for `rotationGraceSeconds` (default 30s). If the client's network drops mid-refresh, it can safely retry with the old token. If the old token is reused *after* the grace window, the entire session is invalidated — this is token-family theft detection.
60
+
61
+ #### Cookie behavior
62
+
63
+ The refresh token is set as an `HttpOnly` cookie (`refresh_token`) alongside the existing session cookie. For non-browser clients, it's also returned in the JSON body and accepted via the `x-refresh-token` header.
64
+
65
+ ### MFA / TOTP
66
+
67
+ Enable multi-factor authentication with TOTP (Google Authenticator, Authy, etc.):
68
+
69
+ ```ts
70
+ await createServer({
71
+ auth: {
72
+ mfa: {
73
+ issuer: "My App", // shown in authenticator apps (default: app name)
74
+ algorithm: "SHA1", // default, most compatible
75
+ digits: 6, // default
76
+ period: 30, // seconds, default
77
+ recoveryCodes: 10, // number of recovery codes, default: 10
78
+ challengeTtlSeconds: 300, // MFA challenge window, default: 5 min
79
+ },
80
+ },
81
+ });
82
+ ```
83
+
84
+ Requires `otpauth` peer dependency:
85
+
86
+ ```bash
87
+ bun add otpauth
88
+ ```
89
+
90
+ #### Endpoints
91
+
92
+ | Endpoint | Auth | Purpose |
93
+ |---|---|---|
94
+ | `POST /auth/mfa/setup` | userAuth | Generate TOTP secret + otpauth URI (for QR code) |
95
+ | `POST /auth/mfa/verify-setup` | userAuth | Confirm with TOTP code, returns recovery codes |
96
+ | `POST /auth/mfa/verify` | none (uses mfaToken) | Complete login after password verified |
97
+ | `DELETE /auth/mfa` | userAuth | Disable all MFA (requires TOTP code) |
98
+ | `POST /auth/mfa/recovery-codes` | userAuth | Regenerate codes (requires TOTP code) |
99
+ | `GET /auth/mfa/methods` | userAuth | Get enabled MFA methods |
100
+
101
+ #### Login flow with MFA enabled
102
+
103
+ 1. `POST /auth/login` with credentials → password OK + MFA enabled → `{ mfaRequired: true, mfaToken: "...", mfaMethods: ["totp"] }` (no session created)
104
+ 2. `POST /auth/mfa/verify` with `{ mfaToken, code }` → verifies TOTP or recovery code → creates session → returns normal token response
105
+
106
+ The verify endpoint accepts an optional `method` field (`"totp"` or `"emailOtp"`) to target a specific verification method. When omitted, methods are tried automatically.
107
+
108
+ **OAuth logins skip MFA** — the OAuth provider is treated as the second factor.
109
+
110
+ **Recovery codes**: 10 random 8-character alphanumeric codes, stored as SHA-256 hashes. Each code can only be used once. Enabling a second MFA method regenerates recovery codes — save the new set.
111
+
112
+ ### Email OTP
113
+
114
+ An alternative to TOTP that sends a one-time code to the user's email. Users can enable TOTP, email OTP, or both.
115
+
116
+ ```ts
117
+ await createServer({
118
+ auth: {
119
+ mfa: {
120
+ challengeTtlSeconds: 300,
121
+ emailOtp: {
122
+ onSend: async (email, code) => {
123
+ await sendEmail(email, `Your login code: ${code}`);
124
+ },
125
+ codeLength: 6, // default
126
+ },
127
+ },
128
+ },
129
+ });
130
+ ```
131
+
132
+ #### Endpoints
133
+
134
+ | Endpoint | Auth | Purpose |
135
+ |---|---|---|
136
+ | `POST /auth/mfa/email-otp/enable` | userAuth | Send verification code to email |
137
+ | `POST /auth/mfa/email-otp/verify-setup` | userAuth | Confirm code, enable email OTP |
138
+ | `DELETE /auth/mfa/email-otp` | userAuth | Disable email OTP |
139
+ | `POST /auth/mfa/resend` | none (uses mfaToken) | Resend email OTP code (max 3 per challenge) |
140
+
141
+ #### Setup flow
142
+
143
+ 1. `POST /auth/mfa/email-otp/enable` → sends code to email → returns `{ setupToken }`
144
+ 2. `POST /auth/mfa/email-otp/verify-setup` with `{ setupToken, code }` → enables email OTP → returns recovery codes
145
+
146
+ This two-step flow ensures the `onSend` callback actually delivers emails before MFA is activated, preventing lockout from misconfigured email providers.
147
+
148
+ #### Login flow with email OTP
149
+
150
+ 1. `POST /auth/login` → `{ mfaRequired: true, mfaToken, mfaMethods: ["emailOtp"] }` — code is auto-sent to user's email
151
+ 2. `POST /auth/mfa/verify` with `{ mfaToken, code }` → creates session
152
+ 3. If the code didn't arrive: `POST /auth/mfa/resend` with `{ mfaToken }` (max 3 resends, capped at 3x challenge TTL)
153
+
154
+ #### Disabling email OTP
155
+
156
+ - If TOTP is also enabled: requires a TOTP code in the `code` field
157
+ - If email OTP is the only method: requires the account password in the `password` field
158
+ - Disabling the last MFA method turns off MFA entirely
159
+
160
+ ### Account Deletion
161
+
162
+ Enable `DELETE /auth/me` for user-initiated account deletion:
163
+
164
+ ```ts
165
+ await createServer({
166
+ auth: {
167
+ accountDeletion: {
168
+ onBeforeDelete: async (userId) => {
169
+ // Throw to abort (e.g., check for active subscription)
170
+ },
171
+ onAfterDelete: async (userId) => {
172
+ // Cleanup: delete S3 files, cancel Stripe, etc.
173
+ // Runs at execution time — query current state, not a snapshot
174
+ },
175
+ queued: false, // set true for async deletion via BullMQ
176
+ gracePeriod: 0, // seconds before queued deletion executes
177
+ onDeletionScheduled: async (userId, email, cancelToken) => {
178
+ // Send cancellation email with cancelToken link
179
+ },
180
+ },
181
+ },
182
+ });
183
+ ```
184
+
185
+ #### Behavior
186
+
187
+ - Requires `userAuth` middleware (user must be logged in)
188
+ - Body: `{ password?: string }` — required for credential accounts, skipped for OAuth-only
189
+ - Revokes all sessions, deletes tokens, calls `adapter.deleteUser(userId)`
190
+ - Rate limited (3/hour by userId)
191
+
192
+ #### Queued deletion
193
+
194
+ When `queued: true`, deletion is enqueued as a BullMQ job instead of running synchronously. The endpoint returns `202 Accepted` immediately. With `gracePeriod > 0`, the user can cancel via `POST /auth/cancel-deletion`.
195
+
196
+ ### Protecting routes
197
+
198
+ ```ts
199
+ import { userAuth, requireRole, requireVerifiedEmail } from "@lastshotlabs/bunshot";
200
+
201
+ router.use("/my-route", userAuth); // returns 401 if not logged in
202
+ router.use("/admin", userAuth, requireRole("admin")); // returns 403 if user lacks role
203
+ router.use("/content", userAuth, requireRole("admin", "editor")); // allow either role
204
+ router.use("/dashboard", userAuth, requireVerifiedEmail); // returns 403 if email not verified
205
+ ```
206
+
207
+ ### Custom auth adapter
208
+
209
+ By default, `/auth/*` routes store users in MongoDB via `mongoAuthAdapter`. Pass `auth: { adapter: myAdapter }` to `createServer` to use any other store — Postgres, SQLite, an external service, etc. Alternatively, use `db.auth` to select a built-in adapter (`"mongo"` | `"sqlite"` | `"memory"`).
210
+
211
+ The schema should include a `roles` column if you plan to use role-based access:
212
+
213
+ ```sql
214
+ -- roles stored as a text array in Postgres
215
+ ALTER TABLE users ADD COLUMN roles text[] NOT NULL DEFAULT '{}';
216
+ ```
217
+
218
+ ```ts
219
+ import type { AuthAdapter } from "@lastshotlabs/bunshot";
220
+ import { HttpError } from "@lastshotlabs/bunshot";
221
+ import { db } from "./db";
222
+ import { users } from "./schema";
223
+ import { eq, sql } from "drizzle-orm";
224
+
225
+ const pgAuthAdapter: AuthAdapter = {
226
+ async findByEmail(email) {
227
+ const user = await db.query.users.findFirst({ where: eq(users.email, email) });
228
+ return user ? { id: user.id, passwordHash: user.passwordHash } : null;
229
+ },
230
+ async create(email, passwordHash) {
231
+ try {
232
+ const [user] = await db.insert(users).values({ email, passwordHash }).returning({ id: users.id });
233
+ return { id: user.id };
234
+ } catch (err: any) {
235
+ if (/* unique constraint */ err.code === "23505") throw new HttpError(409, "Email already registered");
236
+ throw err;
237
+ }
238
+ },
239
+ // --- Role methods (optional — only needed if using roles / requireRole) ---
240
+ async getRoles(userId) {
241
+ const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
242
+ return user?.roles ?? [];
243
+ },
244
+ async setRoles(userId, roles) { // required if using defaultRole
245
+ await db.update(users).set({ roles }).where(eq(users.id, userId));
246
+ },
247
+ async addRole(userId, role) {
248
+ await db.update(users)
249
+ .set({ roles: sql`array_append(roles, ${role})` })
250
+ .where(eq(users.id, userId));
251
+ },
252
+ async removeRole(userId, role) {
253
+ await db.update(users)
254
+ .set({ roles: sql`array_remove(roles, ${role})` })
255
+ .where(eq(users.id, userId));
256
+ },
257
+ };
258
+
259
+ await createServer({
260
+ routesDir: import.meta.dir + "/routes",
261
+ app: { name: "My App", version: "1.0.0" },
262
+ auth: {
263
+ roles: ["admin", "editor", "user"],
264
+ defaultRole: "user",
265
+ adapter: pgAuthAdapter,
266
+ },
267
+ });
268
+ ```
269
+
270
+ The adapter is responsible for:
271
+ - `findByEmail` — return `{ id, passwordHash }` or `null` if not found
272
+ - `create` — insert the user and return `{ id }`, throw `HttpError(409, ...)` on duplicate email
273
+ - `setPassword` _(optional)_ — update the stored password hash for `userId`; implement to enable `POST /auth/set-password`
274
+ - `findOrCreateByProvider` _(optional)_ — required for OAuth social login
275
+ - `linkProvider` _(optional)_ — add a provider identity to an existing user; implement to enable `GET /auth/{provider}/link`
276
+ - `unlinkProvider` _(optional)_ — remove all identities for a provider from a user; implement to enable `DELETE /auth/{provider}/link`
277
+ - `getRoles` _(optional)_ — return the roles assigned to `userId`; implement to enable `requireRole` middleware
278
+ - `setRoles` _(optional)_ — replace all roles; required if using `defaultRole`
279
+ - `addRole` _(optional)_ — add a single role; implement to use `addUserRole`
280
+ - `removeRole` _(optional)_ — remove a single role; implement to use `removeUserRole`
281
+ - `getUser` _(optional)_ — return `{ email?, providerIds?, emailVerified? }` for `userId`; implement to populate `GET /auth/me` (including `googleLinked` and `emailVerified`)
282
+ - `findByIdentifier` _(optional)_ — look up a user by the configured `primaryField` value; implement for non-email primary fields. Falls back to `findByEmail` if absent.
283
+ - `setEmailVerified` _(optional)_ — mark a user as email-verified; implement to support `POST /auth/verify-email`
284
+ - `getEmailVerified` _(optional)_ — return whether a user is email-verified; implement to support the `emailVerification.required` gate and `POST /auth/resend-verification`
285
+
286
+ Everything else (password hashing, JWT signing, Redis sessions) is handled by the package.
287
+
288
+ ### Auth Rate Limiting
289
+
290
+ All built-in auth endpoints are rate-limited out of the box with sensible defaults. No configuration needed — just be aware of the behavior:
291
+
292
+ | Endpoint | Key | Counts | Default limit |
293
+ |---|---|---|---|
294
+ | `POST /auth/login` | identifier (email/username/phone) | **Failures only** — reset on success | 10 failures / 15 min |
295
+ | `POST /auth/register` | IP address | Every attempt | 5 / hour |
296
+ | `POST /auth/verify-email` | IP address | Every attempt | 10 / 15 min |
297
+ | `POST /auth/resend-verification` | Identifier (email/username/phone) | Every attempt | 3 / hour |
298
+ | `POST /auth/forgot-password` | IP address | Every attempt | 5 / 15 min |
299
+ | `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
300
+
301
+ 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.
302
+
303
+ #### Tuning limits
304
+
305
+ ```ts
306
+ await createServer({
307
+ auth: {
308
+ rateLimit: {
309
+ login: { windowMs: 10 * 60 * 1000, max: 5 }, // stricter: 5 failures / 10 min
310
+ register: { windowMs: 60 * 60 * 1000, max: 3 },
311
+ verifyEmail: { windowMs: 15 * 60 * 1000, max: 10 }, // leave at default
312
+ resendVerification: { windowMs: 60 * 60 * 1000, max: 2 },
313
+ store: "redis", // default when Redis is enabled — shared across all server instances
314
+ },
315
+ },
316
+ });
317
+ ```
318
+
319
+ #### Manually clearing a limit (admin unlock)
320
+
321
+ If a legitimate user gets locked out, call `bustAuthLimit` with the same key format the limiter uses:
322
+
323
+ ```ts
324
+ import { bustAuthLimit } from "@lastshotlabs/bunshot";
325
+
326
+ // Admin route: POST /admin/unblock-login
327
+ router.post("/admin/unblock-login", userAuth, requireRole("admin"), async (c) => {
328
+ const { identifier } = await c.req.json();
329
+ await bustAuthLimit(`login:${identifier}`);
330
+ return c.json({ message: "Login limit cleared" });
331
+ });
332
+ ```
333
+
334
+ Key formats: `login:{identifier}`, `register:{ip}`, `verify:{ip}`, `resend:{userId}`.
335
+
336
+ #### Using the rate limiter in your own routes
337
+
338
+ `trackAttempt` and `isLimited` are exported so you can apply the same Redis-backed rate limiting to any route in your app. They use the same store configured via `auth.rateLimit.store`.
339
+
340
+ ```ts
341
+ import { trackAttempt, isLimited, bustAuthLimit } from "@lastshotlabs/bunshot";
342
+
343
+ // trackAttempt — increments the counter and returns true if now over the limit
344
+ // isLimited — checks without incrementing (read-only)
345
+ // bustAuthLimit — resets a key (e.g. on success or admin unlock)
346
+
347
+ router.post("/api/submit", async (c) => {
348
+ const ip = c.req.header("x-forwarded-for") ?? "unknown";
349
+ const key = `submit:${ip}`;
350
+
351
+ if (await trackAttempt(key, { windowMs: 60 * 1000, max: 5 })) {
352
+ return c.json({ error: "Too many requests" }, 429);
353
+ }
354
+
355
+ // ... handle request
356
+ return c.json({ ok: true });
357
+ });
358
+ ```
359
+
360
+ Use `isLimited` when you want to check the current state without counting the request itself — for example, to gate an expensive pre-check before the attempt is registered:
361
+
362
+ ```ts
363
+ if (await isLimited(key, opts)) {
364
+ return c.json({ error: "Too many requests" }, 429);
365
+ }
366
+ ```
367
+
368
+ Keys are automatically namespaced to the app (e.g. `rl:MyApp:submit:1.2.3.4`) when the Redis store is active, so they won't collide on a shared Redis instance.
369
+
370
+ #### Store
371
+
372
+ The rate limit store defaults to `"redis"` when Redis is enabled (recommended for multi-instance deployments — limits are shared across all servers). Falls back to `"memory"` automatically when Redis is disabled. In-memory limits don't persist across restarts.
373
+
374
+ ---
375
+
376
+ ### Bot Protection
377
+
378
+ The built-in IP rate limiter is ineffective against bots that rotate IPs. The `botProtection` config adds two IP-rotation-resistant layers that run before the IP rate limit check.
379
+
380
+ #### Fingerprint rate limiting
381
+
382
+ When `fingerprintRateLimit: true`, every request is also rate-limited by an HTTP fingerprint — a 12-char hash derived from `User-Agent`, `Accept-*`, `Connection`, and the presence/absence of browser-only headers (`sec-fetch-*`, `sec-ch-ua-*`, `origin`, `referer`, etc.).
383
+
384
+ Bots that rotate IPs but use the same HTTP client (e.g. Python `requests`, `curl`, a headless browser) produce the same fingerprint and share a rate-limit bucket regardless of their source IP. Real browser sessions produce a different fingerprint from CLI tools, so they don't interfere with each other.
385
+
386
+ ```ts
387
+ await createServer({
388
+ security: {
389
+ rateLimit: { windowMs: 60_000, max: 100 }, // applies to both IP and fingerprint buckets
390
+ botProtection: {
391
+ fingerprintRateLimit: true,
392
+ },
393
+ },
394
+ });
395
+ ```
396
+
397
+ The fingerprint bucket uses the same window and max as `security.rateLimit`, and is stored in the same backend as `auth.rateLimit.store` (Redis by default, shared across all instances).
398
+
399
+ #### IP / CIDR blocklist
400
+
401
+ Block known datacenter ranges, proxy providers, or individual IPs outright. Matched requests receive a 403 before any other processing — no session lookup, no rate-limit increment.
402
+
403
+ ```ts
404
+ await createServer({
405
+ security: {
406
+ botProtection: {
407
+ blockList: [
408
+ "198.51.100.0/24", // IPv4 CIDR
409
+ "203.0.113.42", // exact IPv4
410
+ "2001:db8::1", // exact IPv6
411
+ ],
412
+ },
413
+ },
414
+ });
415
+ ```
416
+
417
+ Both options can be combined. The middleware order is: blocklist → IP rate limit → fingerprint rate limit.
418
+
419
+ #### Apply `botProtection` to individual routes
420
+
421
+ `botProtection` is also exported for per-route use:
422
+
423
+ ```ts
424
+ import { botProtection } from "@lastshotlabs/bunshot";
425
+
426
+ router.use("/api/submit", botProtection({ blockList: ["198.51.100.0/24"] }));
427
+ ```
428
+
429
+ ---
430
+
431
+ ### Setting a password after social login
432
+
433
+ If a user signed up via Google or Apple and later wants to add a password, send an authenticated request to `POST /auth/set-password`:
434
+
435
+ ```ts
436
+ // Client (logged-in user)
437
+ await fetch("/auth/set-password", {
438
+ method: "POST",
439
+ headers: { "Content-Type": "application/json", "x-user-token": token },
440
+ body: JSON.stringify({ password: "mynewpassword" }),
441
+ });
442
+ ```
443
+
444
+ The built-in route hashes the password and calls `adapter.setPassword(userId, hash)`. If your adapter does not implement `setPassword`, the route returns `501 Not Implemented`.
445
+
446
+ To support it with a custom adapter:
447
+
448
+ ```ts
449
+ const myAdapter: AuthAdapter = {
450
+ findByEmail: ...,
451
+ create: ...,
452
+ async setPassword(userId, passwordHash) {
453
+ await db.update(users).set({ passwordHash }).where(eq(users.id, userId));
454
+ },
455
+ };
456
+ ```
@@ -0,0 +1,10 @@
1
+ ## Auth Flow
2
+
3
+ Sessions are backed by Redis (default), MongoDB, SQLite, or memory. Each login creates an independent session keyed by UUID, so multiple devices stay logged in simultaneously.
4
+
5
+ - **Browser clients**: `POST /auth/login` sets an HttpOnly cookie automatically
6
+ - **API clients**: Read `token` from the response body, send `x-user-token: <token>` header
7
+
8
+ Features include session management (list/revoke), refresh tokens (short-lived access + long-lived refresh with rotation), MFA (TOTP via Google Authenticator, email OTP, recovery codes), account deletion (immediate or queued with grace period), custom auth adapters, rate limiting on all auth endpoints, bot protection (fingerprint rate limiting + CIDR blocklist), and password set/reset flows.
9
+
10
+ Protect routes with `userAuth`, `requireRole("admin")`, and `requireVerifiedEmail` middleware.