@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,93 @@
|
|
|
1
|
+
## Pagination Helpers
|
|
2
|
+
|
|
3
|
+
Shared Zod schema factories and parse utilities for offset and cursor pagination. Both patterns produce named OpenAPI components and eliminate repeated `parseInt`/clamping boilerplate in route files.
|
|
4
|
+
|
|
5
|
+
### Offset pagination
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import {
|
|
9
|
+
offsetParams, parseOffsetParams, paginatedResponse,
|
|
10
|
+
} from "@lastshotlabs/bunshot";
|
|
11
|
+
|
|
12
|
+
const ItemSchema = z.object({ id: z.string(), name: z.string() });
|
|
13
|
+
|
|
14
|
+
// Schema factories — call once at module scope
|
|
15
|
+
const querySchema = offsetParams({ limit: 20, maxLimit: 100 });
|
|
16
|
+
const responseSchema = paginatedResponse(ItemSchema, "PaginatedItems");
|
|
17
|
+
|
|
18
|
+
router.openapi(
|
|
19
|
+
createRoute({
|
|
20
|
+
method: "get",
|
|
21
|
+
path: "/items",
|
|
22
|
+
request: { query: querySchema },
|
|
23
|
+
responses: { 200: { content: { "application/json": { schema: responseSchema } }, description: "ok" } },
|
|
24
|
+
}),
|
|
25
|
+
async (c) => {
|
|
26
|
+
const { limit, offset } = parseOffsetParams(c.req.query(), { maxLimit: 100 });
|
|
27
|
+
const [items, total] = await Promise.all([
|
|
28
|
+
Item.find().skip(offset).limit(limit),
|
|
29
|
+
Item.countDocuments(),
|
|
30
|
+
]);
|
|
31
|
+
return c.json({ items, total, limit, offset });
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`paginatedResponse(itemSchema, name)` wraps the item schema in `{ items: T[], total: number, limit: number, offset: number }` and registers the result as a named OpenAPI component. Calling it with the same name and schema instance is idempotent; calling it with the same name but a different schema throws at startup.
|
|
37
|
+
|
|
38
|
+
`parseOffsetParams` clamps `limit` to `[1, maxLimit]` and `offset` to `[0, ∞)`. Non-numeric values fall back to defaults. Floats are truncated via `parseInt`.
|
|
39
|
+
|
|
40
|
+
### Cursor pagination
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import {
|
|
44
|
+
cursorParams, parseCursorParams, cursorResponse,
|
|
45
|
+
type CursorResult,
|
|
46
|
+
} from "@lastshotlabs/bunshot";
|
|
47
|
+
|
|
48
|
+
const postQuerySchema = cursorParams({ limit: 25 });
|
|
49
|
+
const postResponseSchema = cursorResponse(PostSchema, "PostsPage");
|
|
50
|
+
|
|
51
|
+
router.openapi(
|
|
52
|
+
createRoute({
|
|
53
|
+
method: "get",
|
|
54
|
+
path: "/posts",
|
|
55
|
+
request: { query: postQuerySchema },
|
|
56
|
+
responses: { 200: { content: { "application/json": { schema: postResponseSchema } }, description: "ok" } },
|
|
57
|
+
}),
|
|
58
|
+
async (c) => {
|
|
59
|
+
const { limit, cursor } = parseCursorParams(c.req.query(), { limit: 25 });
|
|
60
|
+
const filter = cursor ? { _id: { $lt: cursor } } : {};
|
|
61
|
+
const items = await Post.find(filter).sort({ _id: -1 }).limit(limit + 1);
|
|
62
|
+
const hasMore = items.length > limit;
|
|
63
|
+
const page = hasMore ? items.slice(0, limit) : items;
|
|
64
|
+
return c.json({
|
|
65
|
+
items: page,
|
|
66
|
+
nextCursor: hasMore ? page[page.length - 1].id : null,
|
|
67
|
+
hasMore,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`cursorResponse(itemSchema, name)` wraps the item schema in `{ items: T[], nextCursor: string | null, hasMore: boolean }`. The `cursor` field is opaque — the service layer decides encoding (ID, timestamp, base64 composite key). An empty cursor string is normalized to `undefined` by `parseCursorParams`.
|
|
74
|
+
|
|
75
|
+
### TypeScript result type
|
|
76
|
+
|
|
77
|
+
Use `CursorResult<T>` to type the return value of cursor-paginated service functions:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import type { CursorResult } from "@lastshotlabs/bunshot";
|
|
81
|
+
|
|
82
|
+
async function listPosts(limit: number, cursor?: string): Promise<CursorResult<Post>> {
|
|
83
|
+
// ...
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Defaults reference
|
|
88
|
+
|
|
89
|
+
| Parameter | Default | Notes |
|
|
90
|
+
|-----------|---------|-------|
|
|
91
|
+
| `limit` | `50` | Override via `defaults.limit` |
|
|
92
|
+
| `maxLimit` | `200` | Limit clamped to this ceiling |
|
|
93
|
+
| `offset` | `0` | Offset pagination only |
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#### Passkey Login (Passwordless)
|
|
2
|
+
|
|
3
|
+
Passkeys (Windows Hello, Face ID, Touch ID) can be used as a **first-factor** passwordless login — no password required. The user authenticates directly with their biometric or device PIN.
|
|
4
|
+
|
|
5
|
+
This is separate from [WebAuthn as an MFA method](#webauthn--security-keys) (which requires password first). When both are configured, passkey login and WebAuthn MFA coexist independently.
|
|
6
|
+
|
|
7
|
+
> **Prerequisites:** Credentials must be registered with `residentKey: "required"` and `userVerification: "required"` to work as passkeys. bunshot sets both automatically on all WebAuthn registrations — credentials registered with an older version of the library will continue to work as MFA-only second factors but won't be usable for passwordless login.
|
|
8
|
+
|
|
9
|
+
##### Enable passkey login
|
|
10
|
+
|
|
11
|
+
Add `allowPasswordlessLogin: true` to the `mfa.webauthn` config:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
await createServer({
|
|
15
|
+
auth: {
|
|
16
|
+
mfa: {
|
|
17
|
+
webauthn: {
|
|
18
|
+
rpId: "example.com",
|
|
19
|
+
origin: "https://example.com",
|
|
20
|
+
allowPasswordlessLogin: true, // mounts /auth/passkey/* routes
|
|
21
|
+
passkeyMfaBypass: true, // default — passkey satisfies both factors
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
When `allowPasswordlessLogin` is `false` (the default), the `/auth/passkey/*` routes are not mounted at all — callers receive a `404`.
|
|
29
|
+
|
|
30
|
+
##### Endpoints
|
|
31
|
+
|
|
32
|
+
| Endpoint | Auth | Rate limit | Purpose |
|
|
33
|
+
|---|---|---|---|
|
|
34
|
+
| `POST /auth/passkey/login-options` | None | 5 / min per IP | Get WebAuthn challenge options |
|
|
35
|
+
| `POST /auth/passkey/login` | None | 10 / 15 min per IP | Verify assertion, issue session |
|
|
36
|
+
|
|
37
|
+
##### Login flow
|
|
38
|
+
|
|
39
|
+
1. `POST /auth/passkey/login-options` with optional `{ email? }` → `{ options, passkeyToken }`
|
|
40
|
+
2. Client passes `options` to `startAuthentication(options)` from `@simplewebauthn/browser` — OS shows biometric / PIN prompt
|
|
41
|
+
3. `POST /auth/passkey/login` with `{ passkeyToken, assertionResponse }` → `{ token, userId, ... }`
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { startAuthentication } from '@simplewebauthn/browser'
|
|
45
|
+
|
|
46
|
+
// Step 1 — get challenge
|
|
47
|
+
const { options, passkeyToken } = await fetch('/auth/passkey/login-options', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ email }), // optional hint
|
|
51
|
+
}).then(r => r.json())
|
|
52
|
+
|
|
53
|
+
// Step 2 — browser OS prompt
|
|
54
|
+
const assertionResponse = await startAuthentication(options)
|
|
55
|
+
|
|
56
|
+
// Step 3 — verify & get session
|
|
57
|
+
const result = await fetch('/auth/passkey/login', {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({ passkeyToken, assertionResponse }),
|
|
61
|
+
}).then(r => r.json())
|
|
62
|
+
// result: { token, userId, email? } or { mfaRequired, mfaToken, mfaMethods } when passkeyMfaBypass: false
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
##### MFA bypass
|
|
66
|
+
|
|
67
|
+
By default (`passkeyMfaBypass: true`), a passkey login that passes `userVerification: "required"` satisfies **both factors** — no subsequent TOTP or email OTP prompt, even if the user has MFA enabled. Biometric + device possession is equivalent to password + TOTP.
|
|
68
|
+
|
|
69
|
+
Set `passkeyMfaBypass: false` to require MFA after a passkey login — for apps with strict compliance requirements:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
mfa: {
|
|
73
|
+
webauthn: {
|
|
74
|
+
// ...
|
|
75
|
+
allowPasswordlessLogin: true,
|
|
76
|
+
passkeyMfaBypass: false, // require TOTP/OTP after passkey
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
##### Enumeration prevention
|
|
82
|
+
|
|
83
|
+
`POST /auth/passkey/login-options` always returns a valid-looking challenge regardless of whether the email exists or has registered credentials. It never returns a `404` or a distinguishable error — the shape and timing are identical for known and unknown emails.
|
|
84
|
+
|
|
85
|
+
##### Security
|
|
86
|
+
|
|
87
|
+
- `userVerification` defaults to `"required"` for passkey login — a bare hardware key tap without biometric or PIN is rejected. Set `mfa.webauthn.userVerification: "preferred"` to allow touch-only hardware keys (same tradeoff as "remember this device": proves possession, not identity)
|
|
88
|
+
- The `passkeyToken` is a 120-second single-use challenge token — it is consumed on the first verification attempt, so replay is not possible even within the TTL window
|
|
89
|
+
- Sign count is validated and updated on every successful assertion. A backward sign count logs a warning; set `strictSignCount: true` to reject it (possible cloned authenticator signal)
|
|
90
|
+
- Sessions created via passkey login are independent of the credential — revoking or deleting a credential does not invalidate existing sessions. Use explicit session revocation for that
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Passkeys (Windows Hello, Face ID, Touch ID) as a **passwordless first-factor** — no password required. Enable with `mfa.webauthn.allowPasswordlessLogin: true`. Mounts `POST /auth/passkey/login-options` and `POST /auth/passkey/login`. By default a verified passkey satisfies both factors (`passkeyMfaBypass: true`). Enumeration-safe: login-options always returns valid-looking challenge data.
|
|
@@ -1,136 +1,225 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Declare the valid roles for your app in `createServer` / `createApp`:
|
|
6
|
-
|
|
7
|
-
```ts
|
|
8
|
-
await createServer({
|
|
9
|
-
auth: {
|
|
10
|
-
roles: ["admin", "editor", "user"],
|
|
11
|
-
defaultRole: "user", // automatically assigned on /auth/register
|
|
12
|
-
},
|
|
13
|
-
// ...
|
|
14
|
-
});
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
`roles` makes the list available anywhere via `getAppRoles()`. `defaultRole` is assigned to every new user that registers via `POST /auth/register` — no extra code needed.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Three helpers are available depending on what you need:
|
|
22
|
-
|
|
23
|
-
| Helper | Behaviour |
|
|
24
|
-
|---|---|
|
|
25
|
-
| `setUserRoles(userId, roles)` | Replace all roles — pass the full desired set |
|
|
26
|
-
| `addUserRole(userId, role)` | Add a single role, leaving others unchanged |
|
|
27
|
-
| `removeUserRole(userId, role)` | Remove a single role, leaving others unchanged |
|
|
28
|
-
|
|
29
|
-
```ts
|
|
30
|
-
import { setUserRoles, addUserRole, removeUserRole, userAuth, requireRole } from "@lastshotlabs/bunshot";
|
|
31
|
-
|
|
32
|
-
// promote a user to admin
|
|
33
|
-
router.post("/admin/users/:id/promote", userAuth, requireRole("admin"), async (c) => {
|
|
34
|
-
await addUserRole(c.req.param("id"), "admin");
|
|
35
|
-
return c.json({ ok: true });
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
// revoke a role
|
|
39
|
-
router.post("/admin/users/:id/demote", userAuth, requireRole("admin"), async (c) => {
|
|
40
|
-
await removeUserRole(c.req.param("id"), "admin");
|
|
41
|
-
return c.json({ ok: true });
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
// replace all roles at once
|
|
45
|
-
router.put("/admin/users/:id/roles", userAuth, requireRole("admin"), async (c) => {
|
|
46
|
-
const { roles } = await c.req.json();
|
|
47
|
-
await setUserRoles(c.req.param("id"), roles);
|
|
48
|
-
return c.json({ ok: true });
|
|
49
|
-
});
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
`requireRole` is a middleware factory. It lazy-fetches roles on the first role-checked request and caches them on the Hono context, so multiple `requireRole` calls in a middleware chain only hit the DB once.
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
import { userAuth, requireRole } from "@lastshotlabs/bunshot";
|
|
58
|
-
|
|
59
|
-
router.use("/admin", userAuth, requireRole("admin"));
|
|
60
|
-
router.use("/content", userAuth, requireRole("admin", "editor")); // allow either role
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
| Scenario | Response |
|
|
64
|
-
|---|---|
|
|
65
|
-
| No session | `401 Unauthorized` |
|
|
66
|
-
| Authenticated, wrong role | `403 Forbidden` |
|
|
67
|
-
| Authenticated, correct role | passes through |
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
If you're using a custom `authAdapter`, implement the role methods to back role operations with your own store:
|
|
72
|
-
|
|
73
|
-
| Method | Required for |
|
|
74
|
-
|---|---|
|
|
75
|
-
| `getRoles(userId)` | `requireRole` middleware |
|
|
76
|
-
| `setRoles(userId, roles)` | `defaultRole` assignment on registration, full replace |
|
|
77
|
-
| `addRole(userId, role)` | Granular role addition |
|
|
78
|
-
| `removeRole(userId, role)` | Granular role removal |
|
|
79
|
-
|
|
80
|
-
All are optional — only implement what your app uses. `setRoles` is **required** if you configure `defaultRole` (the app will throw at startup if this combination is misconfigured). The exported helpers `setUserRoles`, `addUserRole`, and `removeUserRole` route through your adapter, so they work regardless of which store you use.
|
|
81
|
-
|
|
82
|
-
```ts
|
|
83
|
-
const myAdapter: AuthAdapter = {
|
|
84
|
-
findByEmail: ...,
|
|
85
|
-
create: ...,
|
|
86
|
-
async getRoles(userId) {
|
|
87
|
-
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
88
|
-
return user?.roles ?? [];
|
|
89
|
-
},
|
|
90
|
-
async setRoles(userId, roles) {
|
|
91
|
-
await db.update(users).set({ roles }).where(eq(users.id, userId));
|
|
92
|
-
},
|
|
93
|
-
async addRole(userId, role) {
|
|
94
|
-
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
95
|
-
if (user && !user.roles.includes(role)) {
|
|
96
|
-
await db.update(users).set({ roles: [...user.roles, role] }).where(eq(users.id, userId));
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
async removeRole(userId, role) {
|
|
100
|
-
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
101
|
-
if (user) {
|
|
102
|
-
await db.update(users).set({ roles: user.roles.filter((r: string) => r !== role) }).where(eq(users.id, userId));
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
};
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
When multi-tenancy is enabled (see below), `requireRole` automatically checks **tenant-scoped roles** instead of app-wide roles when a `tenantId` is present in the request context.
|
|
111
|
-
|
|
112
|
-
```ts
|
|
113
|
-
// Assign a tenant-scoped role
|
|
114
|
-
import { addTenantRole, setTenantRoles, removeTenantRole, getTenantRoles } from "@lastshotlabs/bunshot";
|
|
115
|
-
|
|
116
|
-
await addTenantRole(userId, "acme", "admin");
|
|
117
|
-
await setTenantRoles(userId, "acme", ["admin", "editor"]);
|
|
118
|
-
await removeTenantRole(userId, "acme", "editor");
|
|
119
|
-
const roles = await getTenantRoles(userId, "acme"); // ["admin"]
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
`requireRole("admin")` checks tenant-scoped roles when `tenantId` is in context, and falls back to app-wide roles when there is no tenant context. Use `requireRole.global("superadmin")` to always check app-wide roles regardless of tenant.
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
router.use("/tenant-admin", userAuth, requireRole("admin")); // checks tenant roles when in tenant context
|
|
126
|
-
router.use("/super-admin", userAuth, requireRole.global("superadmin")); // always checks app-wide roles
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
If you're using a custom `authAdapter`, implement the tenant role methods:
|
|
130
|
-
|
|
131
|
-
| Method | Purpose |
|
|
132
|
-
|---|---|
|
|
133
|
-
| `getTenantRoles(userId, tenantId)` | Required for tenant-scoped `requireRole` |
|
|
134
|
-
| `setTenantRoles(userId, tenantId, roles)` | Full replace |
|
|
135
|
-
| `addTenantRole(userId, tenantId, role)` | Granular addition |
|
|
1
|
+
### Roles
|
|
2
|
+
|
|
3
|
+
#### Setup
|
|
4
|
+
|
|
5
|
+
Declare the valid roles for your app in `createServer` / `createApp`:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
await createServer({
|
|
9
|
+
auth: {
|
|
10
|
+
roles: ["admin", "editor", "user"],
|
|
11
|
+
defaultRole: "user", // automatically assigned on /auth/register
|
|
12
|
+
},
|
|
13
|
+
// ...
|
|
14
|
+
});
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`roles` makes the list available anywhere via `getAppRoles()`. `defaultRole` is assigned to every new user that registers via `POST /auth/register` — no extra code needed.
|
|
18
|
+
|
|
19
|
+
#### Assigning roles to a user
|
|
20
|
+
|
|
21
|
+
Three helpers are available depending on what you need:
|
|
22
|
+
|
|
23
|
+
| Helper | Behaviour |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `setUserRoles(userId, roles)` | Replace all roles — pass the full desired set |
|
|
26
|
+
| `addUserRole(userId, role)` | Add a single role, leaving others unchanged |
|
|
27
|
+
| `removeUserRole(userId, role)` | Remove a single role, leaving others unchanged |
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { setUserRoles, addUserRole, removeUserRole, userAuth, requireRole } from "@lastshotlabs/bunshot";
|
|
31
|
+
|
|
32
|
+
// promote a user to admin
|
|
33
|
+
router.post("/admin/users/:id/promote", userAuth, requireRole("admin"), async (c) => {
|
|
34
|
+
await addUserRole(c.req.param("id"), "admin");
|
|
35
|
+
return c.json({ ok: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// revoke a role
|
|
39
|
+
router.post("/admin/users/:id/demote", userAuth, requireRole("admin"), async (c) => {
|
|
40
|
+
await removeUserRole(c.req.param("id"), "admin");
|
|
41
|
+
return c.json({ ok: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// replace all roles at once
|
|
45
|
+
router.put("/admin/users/:id/roles", userAuth, requireRole("admin"), async (c) => {
|
|
46
|
+
const { roles } = await c.req.json();
|
|
47
|
+
await setUserRoles(c.req.param("id"), roles);
|
|
48
|
+
return c.json({ ok: true });
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
#### Protecting routes by role
|
|
53
|
+
|
|
54
|
+
`requireRole` is a middleware factory. It lazy-fetches roles on the first role-checked request and caches them on the Hono context, so multiple `requireRole` calls in a middleware chain only hit the DB once.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { userAuth, requireRole } from "@lastshotlabs/bunshot";
|
|
58
|
+
|
|
59
|
+
router.use("/admin", userAuth, requireRole("admin"));
|
|
60
|
+
router.use("/content", userAuth, requireRole("admin", "editor")); // allow either role
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Scenario | Response |
|
|
64
|
+
|---|---|
|
|
65
|
+
| No session | `401 Unauthorized` |
|
|
66
|
+
| Authenticated, wrong role | `403 Forbidden` |
|
|
67
|
+
| Authenticated, correct role | passes through |
|
|
68
|
+
|
|
69
|
+
#### Custom adapter with roles
|
|
70
|
+
|
|
71
|
+
If you're using a custom `authAdapter`, implement the role methods to back role operations with your own store:
|
|
72
|
+
|
|
73
|
+
| Method | Required for |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `getRoles(userId)` | `requireRole` middleware |
|
|
76
|
+
| `setRoles(userId, roles)` | `defaultRole` assignment on registration, full replace |
|
|
77
|
+
| `addRole(userId, role)` | Granular role addition |
|
|
78
|
+
| `removeRole(userId, role)` | Granular role removal |
|
|
79
|
+
|
|
80
|
+
All are optional — only implement what your app uses. `setRoles` is **required** if you configure `defaultRole` (the app will throw at startup if this combination is misconfigured). The exported helpers `setUserRoles`, `addUserRole`, and `removeUserRole` route through your adapter, so they work regardless of which store you use.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const myAdapter: AuthAdapter = {
|
|
84
|
+
findByEmail: ...,
|
|
85
|
+
create: ...,
|
|
86
|
+
async getRoles(userId) {
|
|
87
|
+
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
88
|
+
return user?.roles ?? [];
|
|
89
|
+
},
|
|
90
|
+
async setRoles(userId, roles) {
|
|
91
|
+
await db.update(users).set({ roles }).where(eq(users.id, userId));
|
|
92
|
+
},
|
|
93
|
+
async addRole(userId, role) {
|
|
94
|
+
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
95
|
+
if (user && !user.roles.includes(role)) {
|
|
96
|
+
await db.update(users).set({ roles: [...user.roles, role] }).where(eq(users.id, userId));
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async removeRole(userId, role) {
|
|
100
|
+
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
101
|
+
if (user) {
|
|
102
|
+
await db.update(users).set({ roles: user.roles.filter((r: string) => r !== role) }).where(eq(users.id, userId));
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
#### Tenant-scoped roles
|
|
109
|
+
|
|
110
|
+
When multi-tenancy is enabled (see below), `requireRole` automatically checks **tenant-scoped roles** instead of app-wide roles when a `tenantId` is present in the request context.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// Assign a tenant-scoped role
|
|
114
|
+
import { addTenantRole, setTenantRoles, removeTenantRole, getTenantRoles } from "@lastshotlabs/bunshot";
|
|
115
|
+
|
|
116
|
+
await addTenantRole(userId, "acme", "admin");
|
|
117
|
+
await setTenantRoles(userId, "acme", ["admin", "editor"]);
|
|
118
|
+
await removeTenantRole(userId, "acme", "editor");
|
|
119
|
+
const roles = await getTenantRoles(userId, "acme"); // ["admin"]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`requireRole("admin")` checks tenant-scoped roles when `tenantId` is in context, and falls back to app-wide roles when there is no tenant context. Use `requireRole.global("superadmin")` to always check app-wide roles regardless of tenant.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
router.use("/tenant-admin", userAuth, requireRole("admin")); // checks tenant roles when in tenant context
|
|
126
|
+
router.use("/super-admin", userAuth, requireRole.global("superadmin")); // always checks app-wide roles
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
If you're using a custom `authAdapter`, implement the tenant role methods:
|
|
130
|
+
|
|
131
|
+
| Method | Purpose |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `getTenantRoles(userId, tenantId)` | Required for tenant-scoped `requireRole` |
|
|
134
|
+
| `setTenantRoles(userId, tenantId, roles)` | Full replace |
|
|
135
|
+
| `addTenantRole(userId, tenantId, role)` | Granular addition |
|
|
136
136
|
| `removeTenantRole(userId, tenantId, role)` | Granular removal |
|
|
137
|
+
|
|
138
|
+
#### Groups
|
|
139
|
+
|
|
140
|
+
Groups are named collections of users that grant roles additively. They sit on top of direct role assignments — effective roles are always `directRoles ∪ groupBaselineRoles ∪ membershipRoles` (deduplicated).
|
|
141
|
+
|
|
142
|
+
**Role model:** Each group carries a `roles[]` array that all members inherit. Each `GroupMembership` also carries its own `roles[]` for per-member extras on top of the group baseline.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import {
|
|
146
|
+
createGroup, deleteGroup, getGroup, listGroups, updateGroup,
|
|
147
|
+
addGroupMember, updateGroupMembership, removeGroupMember,
|
|
148
|
+
getGroupMembers, getUserGroups, getEffectiveRoles,
|
|
149
|
+
} from "@lastshotlabs/bunshot";
|
|
150
|
+
|
|
151
|
+
// Create a group (app-wide; tenantId: null)
|
|
152
|
+
const { id } = await createGroup({ name: "editors", roles: ["editor"], tenantId: null });
|
|
153
|
+
|
|
154
|
+
// Add a member with optional per-membership extras
|
|
155
|
+
await addGroupMember(id, userId, ["editor-lead"]); // throws 409 if already a member
|
|
156
|
+
|
|
157
|
+
// Effective roles = direct + group baseline + per-membership (deduplicated)
|
|
158
|
+
const roles = await getEffectiveRoles(userId, null); // ["editor", "editor-lead"]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Scope:** Groups are either app-wide (`tenantId: null`) or tenant-scoped (`tenantId: string`). Tenant-scoped group roles only count when `requireRole` runs in that tenant's context — they never satisfy `requireRole.global`.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// tenant-scoped group: roles only visible within that tenant's context
|
|
165
|
+
await createGroup({ name: "tenant-admins", roles: ["admin"], tenantId: "acme" });
|
|
166
|
+
await addGroupMember(groupId, userId);
|
|
167
|
+
|
|
168
|
+
// Within acme's request context → requireRole("admin") passes
|
|
169
|
+
// requireRole.global("admin") → NEVER satisfied by a tenant-scoped group
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**`tenantId` is immutable** after creation. To move a group to a different scope, delete it and recreate it.
|
|
173
|
+
|
|
174
|
+
##### Management routes
|
|
175
|
+
|
|
176
|
+
Enable built-in REST endpoints for managing groups:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
await createServer({
|
|
180
|
+
groups: {
|
|
181
|
+
managementRoutes: true, // default guard: requireRole.global("admin")
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
| Option | Type | Description |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `managementRoutes` | `true \| { adminRole?, middleware? }` | Enable management routes |
|
|
189
|
+
| `adminRole` | `string` | Role required (default: `"admin"`); uses `requireRole.global` |
|
|
190
|
+
| `middleware` | `MiddlewareHandler[]` | Fully replaces the default `[userAuth, requireRole.global(adminRole)]` stack |
|
|
191
|
+
|
|
192
|
+
Routes mounted at the root:
|
|
193
|
+
|
|
194
|
+
| Method | Path | Description |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `GET` | `/groups` | List groups (tenant-scoped if `tenantId` in context, else app-wide) |
|
|
197
|
+
| `POST` | `/groups` | Create group (`name` must match `/^[a-z0-9_-]+$/`) |
|
|
198
|
+
| `GET` | `/groups/:groupId` | Get group |
|
|
199
|
+
| `PATCH` | `/groups/:groupId` | Update name / displayName / description / roles |
|
|
200
|
+
| `DELETE` | `/groups/:groupId` | Delete group (cascades memberships) |
|
|
201
|
+
| `GET` | `/groups/:groupId/members` | List members with per-membership roles |
|
|
202
|
+
| `POST` | `/groups/:groupId/members` | Add member `{ userId, roles? }` |
|
|
203
|
+
| `PATCH` | `/groups/:groupId/members/:userId` | Update member's per-membership roles |
|
|
204
|
+
| `DELETE` | `/groups/:groupId/members/:userId` | Remove member |
|
|
205
|
+
| `GET` | `/users/:userId/groups` | List user's groups with `membershipRoles` |
|
|
206
|
+
|
|
207
|
+
All list endpoints are paginated (`?limit=&offset=`).
|
|
208
|
+
|
|
209
|
+
##### Custom adapter
|
|
210
|
+
|
|
211
|
+
Implement these methods on your `AuthAdapter` to back groups with your own store:
|
|
212
|
+
|
|
213
|
+
| Method | Purpose |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `createGroup(group)` | Create group; throw `HttpError(409, ...)` on duplicate name in scope |
|
|
216
|
+
| `deleteGroup(groupId)` | Delete group + cascade memberships |
|
|
217
|
+
| `getGroup(groupId)` | Fetch by ID |
|
|
218
|
+
| `listGroups(tenantId, opts?)` | Paginated list scoped to `tenantId` |
|
|
219
|
+
| `updateGroup(groupId, updates)` | Update name/displayName/description/roles |
|
|
220
|
+
| `addGroupMember(groupId, userId, roles?)` | Add member; **must throw 409** if already a member |
|
|
221
|
+
| `updateGroupMembership(groupId, userId, roles)` | Update per-membership roles in-place |
|
|
222
|
+
| `removeGroupMember(groupId, userId)` | Remove member |
|
|
223
|
+
| `getGroupMembers(groupId, opts?)` | Paginated member list |
|
|
224
|
+
| `getUserGroups(userId, tenantId)` | All groups for a user in a scope |
|
|
225
|
+
| `getEffectiveRoles(userId, tenantId)` | Compute effective roles (required — no fallback) |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
### Roles
|
|
2
2
|
|
|
3
3
|
Declare roles in `createServer({ auth: { roles: ["admin", "editor", "user"], defaultRole: "user" } })`. The default role is auto-assigned on registration.
|
|
4
4
|
|
|
@@ -10,3 +10,5 @@ await addUserRole(userId, "admin"); // also: setUserRoles, removeUserRole
|
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
Tenant-scoped roles are supported when multi-tenancy is enabled — `requireRole` checks tenant roles when `tenantId` is in context, falls back to app-wide roles otherwise. Use `requireRole.global("superadmin")` to always check app-wide roles.
|
|
13
|
+
|
|
14
|
+
**Groups** are named user collections that grant roles additively. Effective roles = `directRoles ∪ groupBaselineRoles ∪ membershipRoles`. Groups are either app-wide (`tenantId: null`) or tenant-scoped — tenant group roles never satisfy `requireRole.global`. Enable managed REST endpoints via `groups: { managementRoutes: true }` in config.
|