@lastshotlabs/bunshot 0.0.21 → 0.0.27
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/README.md +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +59 -0
- package/dist/adapters/memoryAuth.d.ts +13 -0
- package/dist/adapters/memoryAuth.js +261 -2
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +217 -1
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +30 -0
- package/dist/adapters/sqliteAuth.js +352 -2
- package/dist/app.d.ts +203 -3
- package/dist/app.js +352 -48
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +69 -8
- package/dist/index.js +46 -5
- package/dist/lib/HttpError.d.ts +7 -1
- package/dist/lib/HttpError.js +10 -1
- package/dist/lib/appConfig.d.ts +157 -0
- package/dist/lib/appConfig.js +54 -0
- package/dist/lib/auditLog.d.ts +58 -0
- package/dist/lib/auditLog.js +218 -0
- package/dist/lib/authAdapter.d.ts +140 -1
- package/dist/lib/authRateLimit.js +36 -0
- package/dist/lib/breachedPassword.d.ts +13 -0
- package/dist/lib/breachedPassword.js +48 -0
- package/dist/lib/captcha.d.ts +25 -0
- package/dist/lib/captcha.js +37 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +24 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- package/dist/lib/jwks.d.ts +25 -0
- package/dist/lib/jwks.js +51 -0
- package/dist/lib/jwt.d.ts +15 -2
- package/dist/lib/jwt.js +92 -5
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +6 -0
- package/dist/lib/m2m.d.ts +29 -0
- package/dist/lib/m2m.js +48 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/mfaChallenge.d.ts +14 -1
- package/dist/lib/mfaChallenge.js +111 -6
- package/dist/lib/mongo.js +1 -1
- package/dist/lib/oauthCode.js +23 -18
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/resetPassword.js +3 -1
- package/dist/lib/saml.d.ts +25 -0
- package/dist/lib/saml.js +64 -0
- package/dist/lib/scim.d.ts +44 -0
- package/dist/lib/scim.js +54 -0
- package/dist/lib/securityEvents.d.ts +28 -0
- package/dist/lib/securityEvents.js +26 -0
- package/dist/lib/session.d.ts +14 -0
- package/dist/lib/session.js +121 -5
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +183 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +39 -0
- package/dist/lib/upload.js +112 -0
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +28 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +18 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +89 -14
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +100 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +37 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/requireScope.d.ts +10 -0
- package/dist/middleware/requireScope.js +25 -0
- package/dist/middleware/requireStepUp.d.ts +18 -0
- package/dist/middleware/requireStepUp.js +29 -0
- package/dist/middleware/scimAuth.d.ts +8 -0
- package/dist/middleware/scimAuth.js +29 -0
- package/dist/middleware/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +58 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -0
- package/dist/models/M2MClient.d.ts +18 -0
- package/dist/models/M2MClient.js +18 -0
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +238 -21
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +66 -46
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +8 -0
- package/dist/routes/metrics.js +55 -0
- package/dist/routes/mfa.js +13 -1
- package/dist/routes/oauth.js +6 -0
- package/dist/routes/oidc.d.ts +2 -0
- package/dist/routes/oidc.js +29 -0
- package/dist/routes/passkey.d.ts +1 -0
- package/dist/routes/passkey.js +157 -0
- package/dist/routes/saml.d.ts +2 -0
- package/dist/routes/saml.js +86 -0
- package/dist/routes/scim.d.ts +2 -0
- package/dist/routes/scim.js +255 -0
- package/dist/routes/uploads.d.ts +14 -0
- package/dist/routes/uploads.js +227 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +101 -22
- package/dist/services/mfa.js +2 -2
- package/dist/ws/index.js +5 -1
- package/docs/sections/auth-flow/full.md +203 -47
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +388 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +131 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +208 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +95 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +18 -5
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
## Unified HMAC Signing (`security.signing`)
|
|
2
|
+
|
|
3
|
+
A single `security.signing` config block enables six HMAC-based security features. All features are opt-in — disable the whole block or any individual feature to keep existing behavior.
|
|
4
|
+
|
|
5
|
+
### Configuration
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
createApp({
|
|
9
|
+
security: {
|
|
10
|
+
signing: {
|
|
11
|
+
// HMAC secret. Defaults to JWT_SECRET_DEV/PROD env var if omitted.
|
|
12
|
+
// Pass string[] for key rotation — first element signs, all elements verify.
|
|
13
|
+
secret: process.env.HMAC_SECRET,
|
|
14
|
+
|
|
15
|
+
cookies: true, // Sign/verify cookies set via signCookieValue()
|
|
16
|
+
cursors: true, // HMAC-sign pagination cursors
|
|
17
|
+
presignedUrls: { // Stateless HMAC presigned download URLs
|
|
18
|
+
defaultExpiry: 3600, // seconds, default 3600
|
|
19
|
+
},
|
|
20
|
+
requestSigning: { // Require clients to HMAC-sign requests
|
|
21
|
+
tolerance: 300_000, // ms, default 5 min
|
|
22
|
+
header: "x-signature",
|
|
23
|
+
timestampHeader: "x-timestamp",
|
|
24
|
+
},
|
|
25
|
+
idempotencyKeys: true, // HMAC-hash idempotency keys before storage
|
|
26
|
+
sessionBinding: { // Bind sessions to client fingerprint
|
|
27
|
+
fields: ["ip", "ua"], // default: ["ip", "ua"]
|
|
28
|
+
onMismatch: "reject", // "unauthenticate" | "reject" | "log-only"
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Secret & Key Rotation
|
|
36
|
+
|
|
37
|
+
Secret resolution order: `signing.secret` → `JWT_SECRET_DEV/PROD` env var (same as CSRF and JWT).
|
|
38
|
+
|
|
39
|
+
To rotate keys without breaking in-flight tokens, pass an array — **newest key first**:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
secret: [process.env.HMAC_SECRET_NEW!, process.env.HMAC_SECRET_OLD!]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
All verification attempts try each key in order; signing always uses the first.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
### Feature 1: Signed Cookie Values
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { signCookieValue, verifyCookieValue } from "@lastshotlabs/bunshot";
|
|
53
|
+
|
|
54
|
+
// Sign before setting a cookie
|
|
55
|
+
const signed = signCookieValue(userId, secret); // "b64value.hmac"
|
|
56
|
+
setCookie(c, "session_hint", signed);
|
|
57
|
+
|
|
58
|
+
// Verify when reading
|
|
59
|
+
const raw = verifyCookieValue(getCookie(c, "session_hint") ?? "", secret);
|
|
60
|
+
// null if tampered or missing
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
When `signing.cookies: false`, the helpers are still exported — they pass through values without signing (with a console warning).
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
### Feature 2: Request Signing (`requireSignedRequest`)
|
|
68
|
+
|
|
69
|
+
Requires clients to HMAC-sign a canonical string of the request:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
METHOD\nPATH\nCANONICAL_QUERY\nTIMESTAMP\nBODY
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Query params are sorted and percent-encoding normalized (`%20` and `+` both become `%20`) before signing.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { requireSignedRequest } from "@lastshotlabs/bunshot";
|
|
79
|
+
|
|
80
|
+
// Mount on specific routes that need signing
|
|
81
|
+
router.use("/webhooks/internal", requireSignedRequest());
|
|
82
|
+
|
|
83
|
+
// Or override defaults per-route
|
|
84
|
+
router.use("/admin/*", requireSignedRequest({ tolerance: 60_000 }));
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Returns `401 { code: "INVALID_SIGNATURE" | "EXPIRED_TIMESTAMP" }` on failure.
|
|
88
|
+
|
|
89
|
+
When `signing.requestSigning: false`, the middleware is a no-op.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
### Feature 3: Idempotency (`idempotent`)
|
|
94
|
+
|
|
95
|
+
Deduplicates requests using the `Idempotency-Key` header. The second identical request returns the cached first response without re-executing the handler.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { idempotent } from "@lastshotlabs/bunshot";
|
|
99
|
+
|
|
100
|
+
router.use("/payments", idempotent({ ttl: 86400 }));
|
|
101
|
+
router.post("/payments", async (c) => {
|
|
102
|
+
// Safe to retry — second call returns cached 201
|
|
103
|
+
const result = await processPayment(c.req.valid("json"));
|
|
104
|
+
return c.json(result, 201);
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Store key: `userId:key` (authenticated) or `anon:key` (unauthenticated). When `signing.idempotencyKeys: true`, keys are HMAC'd before storage to prevent enumeration.
|
|
109
|
+
|
|
110
|
+
**Race condition handling**: Two concurrent identical requests both miss the cache. The second writer detects the collision (Redis `SET NX`, Mongo duplicate key, SQLite `INSERT OR IGNORE`) and falls back to the first-stored result — never a 500.
|
|
111
|
+
|
|
112
|
+
Configure the store via `setIdempotencyStore("redis" | "mongo" | "sqlite" | "memory")`. Default: `"redis"`.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
### Feature 4: Signed Cursors
|
|
117
|
+
|
|
118
|
+
When `signing.cursors: true`, `parseCursorParams()` verifies cursor signatures and `maybeSignCursor()` signs outgoing cursors. Tampered cursors are rejected with an invalid cursor flag.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { parseCursorParams, maybeSignCursor } from "@lastshotlabs/bunshot";
|
|
122
|
+
|
|
123
|
+
const { limit, cursor, invalidCursor } = parseCursorParams(c.req.query());
|
|
124
|
+
if (invalidCursor) return c.json({ error: "Invalid cursor" }, 400);
|
|
125
|
+
|
|
126
|
+
const items = await fetchPage({ limit, cursor });
|
|
127
|
+
const nextCursor = maybeSignCursor(items.length === limit ? items.at(-1)!.id : null);
|
|
128
|
+
return c.json({ items, nextCursor, hasMore: items.length === limit });
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
When off, cursors pass through unsigned (current behavior).
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
### Feature 5: Presigned URLs
|
|
136
|
+
|
|
137
|
+
Stateless HMAC-signed download URLs — no database lookup required.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { createPresignedUrl, verifyPresignedUrl } from "@lastshotlabs/bunshot";
|
|
141
|
+
|
|
142
|
+
// Generate (e.g. in a GET /uploads/presign/:key route)
|
|
143
|
+
const url = createPresignedUrl(
|
|
144
|
+
"https://api.example.com/uploads/download/",
|
|
145
|
+
"avatars/user123.jpg",
|
|
146
|
+
{ method: "GET", expiry: 3600 },
|
|
147
|
+
secret
|
|
148
|
+
);
|
|
149
|
+
// → "https://api.example.com/uploads/download/?key=avatars%2F...&exp=...&method=GET&sig=..."
|
|
150
|
+
|
|
151
|
+
// Verify (e.g. in the download handler)
|
|
152
|
+
const result = verifyPresignedUrl(url, "GET", secret);
|
|
153
|
+
// null if expired, tampered, or wrong method
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The built-in upload router (`presignedUrls: true`) automatically serves HMAC presigned URLs at `GET /uploads/presign/:key` when `signing.presignedUrls` is enabled. Falls back to `adapter.presignGet()` (S3) otherwise.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### Feature 6: Session Binding
|
|
161
|
+
|
|
162
|
+
Binds sessions to the client's HTTP fingerprint (IP + User-Agent by default). Mismatches indicate session hijacking or IP change.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
sessionBinding: {
|
|
166
|
+
fields: ["ip", "ua"], // fingerprint components
|
|
167
|
+
onMismatch: "reject", // strict — 401 on mismatch
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
| `onMismatch` | Behavior |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `"unauthenticate"` (default) | Treat as logged-out; continue request unauthenticated |
|
|
174
|
+
| `"reject"` | Return `401 { code: "FINGERPRINT_MISMATCH" }` |
|
|
175
|
+
| `"log-only"` | Allow through but log the mismatch (useful during rollout) |
|
|
176
|
+
|
|
177
|
+
The fingerprint is stored lazily on the first authenticated request after login. Subsequent requests compare the current fingerprint to the stored one.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
### "HMAC off" behavior per feature
|
|
182
|
+
|
|
183
|
+
| Feature | HMAC on | HMAC off |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| Signed cookies | `signCookieValue` / `verifyCookieValue` sign/verify | Pass-through (identity functions with warning) |
|
|
186
|
+
| Request signing | `requireSignedRequest` validates HMAC | Middleware is a no-op |
|
|
187
|
+
| Idempotency keys | Key is HMAC'd before storage | Raw key stored (slight enumeration risk) |
|
|
188
|
+
| Signed cursors | `parseCursorParams` rejects invalid sigs | Cursors pass through unsigned |
|
|
189
|
+
| Presigned URLs | Stateless HMAC-signed URL | Falls back to `adapter.presignGet()` or 501 |
|
|
190
|
+
| Session binding | Fingerprint verified on each request | No fingerprint check |
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
### Low-level primitives
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { hmacSign, hmacVerify } from "@lastshotlabs/bunshot";
|
|
198
|
+
|
|
199
|
+
const sig = hmacSign("data", secret);
|
|
200
|
+
const ok = hmacVerify("data", sig, secret); // uses timingSafeEqual internally
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`hmacVerify` always uses `timingSafeEqual` — never `===` — to prevent timing side-channel attacks.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
## File Uploads
|
|
2
|
+
|
|
3
|
+
Bunshot provides opt-in file upload handling with pluggable storage adapters (memory, local filesystem, S3/R2), server-side multipart parsing, and optional presigned URL generation for direct client-to-storage uploads.
|
|
4
|
+
|
|
5
|
+
### Configuration
|
|
6
|
+
|
|
7
|
+
Enable uploads by passing `upload` to `createApp` or `createServer`:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { createServer, memoryStorage } from "@lastshotlabs/bunshot";
|
|
11
|
+
|
|
12
|
+
await createServer({
|
|
13
|
+
routesDir: import.meta.dir + "/routes",
|
|
14
|
+
upload: {
|
|
15
|
+
storage: memoryStorage(), // swap for localStorage() or s3Storage()
|
|
16
|
+
maxFileSize: 5 * 1024 * 1024, // 5 MB per file
|
|
17
|
+
maxFiles: 3,
|
|
18
|
+
allowedMimeTypes: ["image/*", "application/pdf"],
|
|
19
|
+
keyPrefix: "uploads/",
|
|
20
|
+
tenantScopedKeys: false,
|
|
21
|
+
presignedUrls: true, // mounts POST /uploads/presign and DELETE /uploads/:key
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**`UploadConfig` fields:**
|
|
27
|
+
|
|
28
|
+
| Field | Type | Default | Description |
|
|
29
|
+
|-------|------|---------|-------------|
|
|
30
|
+
| `storage` | `StorageAdapter` | required | Storage backend instance |
|
|
31
|
+
| `maxFileSize` | `number` | `10485760` (10 MB) | Max bytes per file |
|
|
32
|
+
| `maxFiles` | `number` | `10` | Max files per request |
|
|
33
|
+
| `allowedMimeTypes` | `string[]` | all allowed | Permitted MIME types (supports wildcards like `image/*`) |
|
|
34
|
+
| `keyPrefix` | `string` | `"uploads/"` | Path prefix prepended to generated keys |
|
|
35
|
+
| `generateKey` | `(file, ctx) => string` | UUID-based | Custom key generation function |
|
|
36
|
+
| `tenantScopedKeys` | `boolean` | `false` | Prepend `tenantId/` to generated keys |
|
|
37
|
+
| `presignedUrls` | `boolean \| PresignedUrlConfig` | `false` | Mount presigned URL routes |
|
|
38
|
+
|
|
39
|
+
### Storage Adapters
|
|
40
|
+
|
|
41
|
+
#### Memory (testing)
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { memoryStorage, clearMemoryUploadStore } from "@lastshotlabs/bunshot";
|
|
45
|
+
|
|
46
|
+
const storage = memoryStorage();
|
|
47
|
+
// clearMemoryUploadStore() is called automatically by clearMemoryStore() in tests
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Does not implement `presignPut` / `presignGet`. The presigned URL route returns `501` when this adapter is used.
|
|
51
|
+
|
|
52
|
+
#### Local Filesystem
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { localStorage } from "@lastshotlabs/bunshot";
|
|
56
|
+
|
|
57
|
+
const storage = localStorage({
|
|
58
|
+
directory: import.meta.dir + "/uploads",
|
|
59
|
+
baseUrl: "https://example.com/files", // optional — appended to key for public URL
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
#### S3 / R2 / MinIO
|
|
64
|
+
|
|
65
|
+
Requires `@aws-sdk/client-s3`. Presigned URLs additionally require `@aws-sdk/s3-request-presigner`.
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import { s3Storage } from "@lastshotlabs/bunshot";
|
|
69
|
+
|
|
70
|
+
const storage = s3Storage({
|
|
71
|
+
bucket: "my-bucket",
|
|
72
|
+
region: "us-east-1",
|
|
73
|
+
credentials: {
|
|
74
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
75
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
76
|
+
},
|
|
77
|
+
publicUrl: "https://cdn.example.com", // optional — used to build the returned URL
|
|
78
|
+
// For Cloudflare R2 or MinIO:
|
|
79
|
+
// endpoint: "https://<account>.r2.cloudflarestorage.com",
|
|
80
|
+
// forcePathStyle: true,
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Server-Side Upload (Middleware)
|
|
85
|
+
|
|
86
|
+
Use `handleUpload` middleware in your route handler. It parses the multipart body, validates files, stores them, and sets `uploadResults` on the context.
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { createRoute, handleUpload } from "@lastshotlabs/bunshot";
|
|
90
|
+
import { createRouter } from "@lastshotlabs/bunshot";
|
|
91
|
+
|
|
92
|
+
export const router = createRouter();
|
|
93
|
+
|
|
94
|
+
const uploadRoute = createRoute({
|
|
95
|
+
method: "post",
|
|
96
|
+
path: "/photos",
|
|
97
|
+
summary: "Upload photos",
|
|
98
|
+
responses: {
|
|
99
|
+
200: { description: "Uploaded", content: { "application/json": { schema: z.object({ keys: z.array(z.string()) }) } } },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
router.openapi(uploadRoute, handleUpload({ field: "photo", allowedMimeTypes: ["image/*"] }), async (c) => {
|
|
104
|
+
const results = c.get("uploadResults") ?? [];
|
|
105
|
+
return c.json({ keys: results.map((r) => r.key) });
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**`UploadResult` fields:**
|
|
110
|
+
|
|
111
|
+
| Field | Type | Description |
|
|
112
|
+
|-------|------|-------------|
|
|
113
|
+
| `key` | `string` | Storage key |
|
|
114
|
+
| `originalName` | `string` | Original filename from the browser |
|
|
115
|
+
| `mimeType` | `string` | Detected MIME type |
|
|
116
|
+
| `size` | `number` | File size in bytes |
|
|
117
|
+
| `url` | `string \| undefined` | Public URL (when adapter returns one) |
|
|
118
|
+
|
|
119
|
+
### Presigned URL Flow (Client-Side Upload)
|
|
120
|
+
|
|
121
|
+
When `presignedUrls` is set, two routes are mounted (default base path `/uploads`, requires authentication):
|
|
122
|
+
|
|
123
|
+
- `POST /uploads/presign` — returns a time-limited PUT URL
|
|
124
|
+
- `DELETE /uploads/:key` — deletes a stored file
|
|
125
|
+
|
|
126
|
+
**`POST /uploads/presign` request fields:**
|
|
127
|
+
|
|
128
|
+
| Field | Type | Description |
|
|
129
|
+
|-------|------|-------------|
|
|
130
|
+
| `key` | `string` | Storage key for the upload |
|
|
131
|
+
| `mimeType` | `string` (optional) | MIME type of the file. Certain dangerous types are rejected with `400 { error: "File type not allowed." }`: `application/x-executable`, `application/x-sh`, `application/x-msdownload`, `text/html`, `application/x-httpd-php`, `application/javascript`, `text/javascript` |
|
|
132
|
+
| `expirySeconds` | `number` (optional) | URL expiry in seconds |
|
|
133
|
+
| `maxBytes` | `number` (optional) | Maximum allowed file size in bytes (client-enforced via Content-Length). Defaults to 10MB. Maximum: 100MB. Returned in the response so clients can enforce the limit before uploading. |
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// 1. Client requests a presigned URL from your API
|
|
137
|
+
const { url, key, maxBytes } = await fetch("/uploads/presign", {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/json" },
|
|
140
|
+
body: JSON.stringify({ key: "photos/my-image.jpg", mimeType: "image/jpeg", expirySeconds: 300, maxBytes: 5 * 1024 * 1024 }),
|
|
141
|
+
}).then(r => r.json());
|
|
142
|
+
|
|
143
|
+
// 2. Client uploads directly to storage (no server bandwidth used)
|
|
144
|
+
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": "image/jpeg" } });
|
|
145
|
+
|
|
146
|
+
// 3. Store the key in your database
|
|
147
|
+
await savePhotoRecord({ key, userId: currentUser.id });
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Configure the base path and default expiry:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
presignedUrls: {
|
|
154
|
+
path: "/media", // mounts POST /media/presign and DELETE /media/:key
|
|
155
|
+
expirySeconds: 600, // default 10-minute expiry
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
If the configured storage adapter does not support `presignPut` (e.g., memory or local filesystem), the endpoint returns `501 { error: "Presigned URLs not supported by the configured storage adapter" }`.
|
|
160
|
+
|
|
161
|
+
### Dynamic Bucket Selection
|
|
162
|
+
|
|
163
|
+
Set `c.set("uploadBucket", "tenant-bucket-name")` before calling `handleUpload` (or `parseUpload`) to override the default bucket on a per-request basis. Supported by `s3Storage` — `meta.bucket` takes priority over `config.bucket`.
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
router.use("/uploads/*", async (c, next) => {
|
|
167
|
+
const tenant = c.get("tenantId");
|
|
168
|
+
if (tenant) c.set("uploadBucket", `tenant-${tenant}`);
|
|
169
|
+
await next();
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### MIME Type Wildcards
|
|
174
|
+
|
|
175
|
+
`allowedMimeTypes` supports exact matches and `type/*` wildcards:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
allowedMimeTypes: ["image/*", "video/mp4", "application/pdf"]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Tenant-Scoped Keys
|
|
182
|
+
|
|
183
|
+
When `tenantScopedKeys: true`, generated keys are prefixed with the request's `tenantId`:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
uploads/tenant-abc/550e8400-e29b-41d4-a716-446655440000.jpg
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Context Variables
|
|
190
|
+
|
|
191
|
+
| Variable | Type | Set by |
|
|
192
|
+
|----------|------|--------|
|
|
193
|
+
| `uploadResults` | `UploadResult[] \| null` | `handleUpload` middleware |
|
|
194
|
+
| `uploadBucket` | `string \| undefined` | Application code (per-request bucket override) |
|
|
195
|
+
|
|
196
|
+
### `parseUpload` (lower-level)
|
|
197
|
+
|
|
198
|
+
`parseUpload(c, opts?)` is the underlying function used by `handleUpload`. It returns `UploadResult[]` directly without touching `c.set("uploadResults", ...)`. Use it when you need more control over the response or want to handle results inline:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import { parseUpload } from "@lastshotlabs/bunshot";
|
|
202
|
+
|
|
203
|
+
router.post("/avatar", async (c) => {
|
|
204
|
+
const [result] = await parseUpload(c, { field: "avatar", maxFiles: 1, allowedMimeTypes: ["image/*"] });
|
|
205
|
+
await db.users.update({ id: c.get("authUserId")! }, { avatar: result.key });
|
|
206
|
+
return c.json({ key: result.key });
|
|
207
|
+
});
|
|
208
|
+
```
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
## API Versioning
|
|
2
|
+
|
|
3
|
+
Mount multiple API versions with isolated OpenAPI specs and Scalar docs pages.
|
|
4
|
+
|
|
5
|
+
### Directory Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
routes/
|
|
9
|
+
├── v1/
|
|
10
|
+
│ └── users.ts # v1-specific routes
|
|
11
|
+
├── v2/
|
|
12
|
+
│ └── users.ts # v2-specific routes (breaking changes)
|
|
13
|
+
└── shared/
|
|
14
|
+
└── health.ts # appears in all versions, unprefixed schemas
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Configuration
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
await createServer({
|
|
21
|
+
routesDir: import.meta.dir + "/routes",
|
|
22
|
+
versioning: {
|
|
23
|
+
versions: ["v1", "v2"], // subdirectories under routesDir
|
|
24
|
+
defaultVersion: "v2", // which version /docs and /openapi.json redirect to (default: last)
|
|
25
|
+
sharedDir: "shared", // shared routes dir (default: "shared", set false to disable)
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**What gets mounted:**
|
|
31
|
+
|
|
32
|
+
| Path | Description |
|
|
33
|
+
|------|-------------|
|
|
34
|
+
| `/v1/users`, `/v2/users` | Version-specific route handlers |
|
|
35
|
+
| `/v1/openapi.json`, `/v2/openapi.json` | Isolated OpenAPI specs |
|
|
36
|
+
| `/v1/docs`, `/v2/docs` | Scalar docs per version |
|
|
37
|
+
| `/docs` | Version selector page (links to each version's docs) |
|
|
38
|
+
| `/openapi.json` | 302 redirect to `/{defaultVersion}/openapi.json` (no merged spec) |
|
|
39
|
+
|
|
40
|
+
### Route Files
|
|
41
|
+
|
|
42
|
+
Route files are unchanged — use `createRouter()` and `createRoute()` as normal:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
// routes/v2/users.ts
|
|
46
|
+
import { createRouter, createRoute } from "@lastshotlabs/bunshot";
|
|
47
|
+
import { z } from "zod";
|
|
48
|
+
|
|
49
|
+
export const router = createRouter();
|
|
50
|
+
|
|
51
|
+
const ListUsersRoute = createRoute({
|
|
52
|
+
method: "get",
|
|
53
|
+
path: "/users",
|
|
54
|
+
responses: {
|
|
55
|
+
200: {
|
|
56
|
+
description: "Users",
|
|
57
|
+
content: { "application/json": { schema: z.object({ users: z.array(UserSchema) }) } },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
router.openapi(ListUsersRoute, (c) => c.json({ users: [] }));
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Schema names are automatically prefixed with the version: `GET /users` in v1 → `V1GetUsersResponse`, in v2 → `V2GetUsersResponse`. This prevents phantom types in generated TypeScript clients when both specs share a registry.
|
|
66
|
+
|
|
67
|
+
### Shared Routes
|
|
68
|
+
|
|
69
|
+
Routes in the `shared/` directory are mounted on every versioned app. Their schemas receive **no version prefix** since they are version-agnostic. Shared route files must be stateless — Hono mounts a reference (not a clone), so the same router instance is shared across all versioned apps. Standard `createRouter()` routers are stateless by design.
|
|
70
|
+
|
|
71
|
+
### Unreferenced Schema Stripping
|
|
72
|
+
|
|
73
|
+
Each version's spec is post-processed to remove `components/schemas` entries not referenced by that version's routes. This prevents schema bleed from the global OpenAPI registry into specs that don't use them.
|
|
74
|
+
|
|
75
|
+
`stripUnreferencedSchemas` is also exported from the package for apps that serve OpenAPI specs via custom handlers.
|
|
76
|
+
|
|
77
|
+
### No Merged Spec
|
|
78
|
+
|
|
79
|
+
There is no combined spec at `/openapi.json`. Root `/openapi.json` returns a `302` redirect to the default version's spec. This is intentional — merging specs from multiple versions produces ambiguous schema names and defeats the isolation benefit.
|
|
80
|
+
|
|
81
|
+
### Notes
|
|
82
|
+
|
|
83
|
+
- **Avoid unbounded top-level `await` in route files.** The framework sets the version prefix module-level before importing each version's route files. A route file whose `createRoute()` calls run synchronously (the normal pattern) works correctly. If `createRoute()` is called after an unbounded top-level `await` (e.g., an unresolved DB call), the prefix may have changed. The framework throws a clear startup error if this is detected.
|
|
84
|
+
- **Non-versioned behavior is unchanged.** If `versioning` is not set, routes are discovered at `routesDir/**/*.ts` and mounted at root exactly as before.
|
|
85
|
+
- **Priority ordering works per-version.** Export `export const priority = <number>` from a versioned route file to control load order within that version.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
## Webhook Authentication
|
|
2
|
+
|
|
3
|
+
`webhookAuth` is a middleware that verifies HMAC signatures on incoming webhook requests. It supports GitHub-style signing, multiple algorithms, replay protection, and per-request dynamic secrets.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { webhookAuth } from "@lastshotlabs/bunshot";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
### Basic usage
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// routes/webhooks.ts
|
|
13
|
+
import { webhookAuth } from "@lastshotlabs/bunshot";
|
|
14
|
+
import { createRouter } from "@lastshotlabs/bunshot";
|
|
15
|
+
|
|
16
|
+
export const router = createRouter();
|
|
17
|
+
|
|
18
|
+
router.post(
|
|
19
|
+
"/webhooks/stripe",
|
|
20
|
+
webhookAuth({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
|
|
21
|
+
async (c) => {
|
|
22
|
+
const event = await c.req.json();
|
|
23
|
+
// body is still readable — Hono caches it after webhookAuth reads it
|
|
24
|
+
return c.json({ received: true });
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### GitHub-style (prefix + sha256)
|
|
30
|
+
|
|
31
|
+
GitHub sends `X-Hub-Signature-256: sha256=<hex>`. Use `prefix` to strip the `sha256=` before comparing:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
router.post(
|
|
35
|
+
"/webhooks/github",
|
|
36
|
+
webhookAuth({
|
|
37
|
+
secret: process.env.GITHUB_WEBHOOK_SECRET!,
|
|
38
|
+
header: "x-hub-signature-256",
|
|
39
|
+
prefix: "sha256=",
|
|
40
|
+
algorithm: "sha256",
|
|
41
|
+
}),
|
|
42
|
+
async (c) => c.json({ ok: true })
|
|
43
|
+
);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If the header value does **not** start with the configured prefix, the full value is compared as-is — the prefix is stripped when present, not required.
|
|
47
|
+
|
|
48
|
+
### Options
|
|
49
|
+
|
|
50
|
+
| Option | Type | Default | Description |
|
|
51
|
+
|--------|------|---------|-------------|
|
|
52
|
+
| `secret` | `string \| (c) => string \| Promise<string>` | — | Shared HMAC secret. Pass a function for dynamic resolution (e.g. per-tenant lookup). |
|
|
53
|
+
| `header` | `string` | `"x-webhook-signature"` | Header that carries the signature. |
|
|
54
|
+
| `algorithm` | `"sha256" \| "sha512"` | `"sha256"` | HMAC algorithm. |
|
|
55
|
+
| `prefix` | `string` | — | Strip this prefix before comparing (e.g. `"sha256="` for GitHub). |
|
|
56
|
+
| `timestamp` | `{ header, tolerance }` | — | Opt-in replay protection (see below). |
|
|
57
|
+
|
|
58
|
+
### Replay protection
|
|
59
|
+
|
|
60
|
+
Pass `timestamp` to reject requests with a stale timestamp header:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
webhookAuth({
|
|
64
|
+
secret: process.env.WEBHOOK_SECRET!,
|
|
65
|
+
timestamp: {
|
|
66
|
+
header: "x-webhook-timestamp", // header name carrying the Unix timestamp
|
|
67
|
+
tolerance: 300_000, // 5 minutes in ms
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- Values `< 1e10` in the header are treated as Unix **seconds** and auto-converted to ms
|
|
73
|
+
- Missing or non-numeric timestamps → `401 EXPIRED_TIMESTAMP`
|
|
74
|
+
- Timestamp outside `tolerance` → `401 EXPIRED_TIMESTAMP`
|
|
75
|
+
- Signature check runs after the timestamp check passes
|
|
76
|
+
|
|
77
|
+
### Dynamic secrets (multi-tenant)
|
|
78
|
+
|
|
79
|
+
Pass a function to resolve the secret per request — useful when each tenant has its own webhook secret:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
webhookAuth({
|
|
83
|
+
secret: async (c) => {
|
|
84
|
+
const tenantId = c.req.header("x-tenant-id");
|
|
85
|
+
const tenant = await getTenantWebhookSecret(tenantId);
|
|
86
|
+
if (!tenant) throw new Error("unknown tenant");
|
|
87
|
+
return tenant.webhookSecret;
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If the function throws, the request receives `500 { code: "WEBHOOK_SECRET_ERROR" }`.
|
|
93
|
+
|
|
94
|
+
### Error responses
|
|
95
|
+
|
|
96
|
+
| Condition | Status | `code` |
|
|
97
|
+
|-----------|--------|--------|
|
|
98
|
+
| Missing or invalid signature | `401` | `INVALID_SIGNATURE` |
|
|
99
|
+
| Missing, non-numeric, or expired timestamp | `401` | `EXPIRED_TIMESTAMP` |
|
|
100
|
+
| Secret function threw | `500` | `WEBHOOK_SECRET_ERROR` |
|