@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.
Files changed (185) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +59 -0
  4. package/dist/adapters/memoryAuth.d.ts +13 -0
  5. package/dist/adapters/memoryAuth.js +261 -2
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +217 -1
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +30 -0
  12. package/dist/adapters/sqliteAuth.js +352 -2
  13. package/dist/app.d.ts +203 -3
  14. package/dist/app.js +352 -48
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +69 -8
  17. package/dist/index.js +46 -5
  18. package/dist/lib/HttpError.d.ts +7 -1
  19. package/dist/lib/HttpError.js +10 -1
  20. package/dist/lib/appConfig.d.ts +157 -0
  21. package/dist/lib/appConfig.js +54 -0
  22. package/dist/lib/auditLog.d.ts +58 -0
  23. package/dist/lib/auditLog.js +218 -0
  24. package/dist/lib/authAdapter.d.ts +140 -1
  25. package/dist/lib/authRateLimit.js +36 -0
  26. package/dist/lib/breachedPassword.d.ts +13 -0
  27. package/dist/lib/breachedPassword.js +48 -0
  28. package/dist/lib/captcha.d.ts +25 -0
  29. package/dist/lib/captcha.js +37 -0
  30. package/dist/lib/constants.d.ts +4 -0
  31. package/dist/lib/constants.js +4 -0
  32. package/dist/lib/context.d.ts +24 -1
  33. package/dist/lib/context.js +17 -3
  34. package/dist/lib/createRoute.d.ts +28 -2
  35. package/dist/lib/createRoute.js +54 -3
  36. package/dist/lib/credentialStuffing.d.ts +31 -0
  37. package/dist/lib/credentialStuffing.js +77 -0
  38. package/dist/lib/deletionCancelToken.d.ts +12 -0
  39. package/dist/lib/deletionCancelToken.js +88 -0
  40. package/dist/lib/emailVerification.d.ts +6 -0
  41. package/dist/lib/emailVerification.js +46 -3
  42. package/dist/lib/groups.d.ts +113 -0
  43. package/dist/lib/groups.js +133 -0
  44. package/dist/lib/idempotency.d.ts +22 -0
  45. package/dist/lib/idempotency.js +182 -0
  46. package/dist/lib/jwks.d.ts +25 -0
  47. package/dist/lib/jwks.js +51 -0
  48. package/dist/lib/jwt.d.ts +15 -2
  49. package/dist/lib/jwt.js +92 -5
  50. package/dist/lib/logger.d.ts +2 -0
  51. package/dist/lib/logger.js +6 -0
  52. package/dist/lib/m2m.d.ts +29 -0
  53. package/dist/lib/m2m.js +48 -0
  54. package/dist/lib/metrics.d.ts +14 -0
  55. package/dist/lib/metrics.js +158 -0
  56. package/dist/lib/mfaChallenge.d.ts +14 -1
  57. package/dist/lib/mfaChallenge.js +111 -6
  58. package/dist/lib/mongo.js +1 -1
  59. package/dist/lib/oauthCode.js +23 -18
  60. package/dist/lib/pagination.d.ts +119 -0
  61. package/dist/lib/pagination.js +166 -0
  62. package/dist/lib/resetPassword.js +3 -1
  63. package/dist/lib/saml.d.ts +25 -0
  64. package/dist/lib/saml.js +64 -0
  65. package/dist/lib/scim.d.ts +44 -0
  66. package/dist/lib/scim.js +54 -0
  67. package/dist/lib/securityEvents.d.ts +28 -0
  68. package/dist/lib/securityEvents.js +26 -0
  69. package/dist/lib/session.d.ts +14 -0
  70. package/dist/lib/session.js +121 -5
  71. package/dist/lib/signing.d.ts +52 -0
  72. package/dist/lib/signing.js +183 -0
  73. package/dist/lib/storageAdapter.d.ts +30 -0
  74. package/dist/lib/storageAdapter.js +1 -0
  75. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  76. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  77. package/dist/lib/suspension.d.ts +13 -0
  78. package/dist/lib/suspension.js +23 -0
  79. package/dist/lib/tenant.js +2 -2
  80. package/dist/lib/upload.d.ts +39 -0
  81. package/dist/lib/upload.js +112 -0
  82. package/dist/lib/uploadRegistry.d.ts +18 -0
  83. package/dist/lib/uploadRegistry.js +83 -0
  84. package/dist/lib/validate.js +2 -2
  85. package/dist/lib/ws.d.ts +1 -0
  86. package/dist/lib/ws.js +28 -0
  87. package/dist/lib/wsHeartbeat.d.ts +12 -0
  88. package/dist/lib/wsHeartbeat.js +57 -0
  89. package/dist/lib/wsMessages.d.ts +40 -0
  90. package/dist/lib/wsMessages.js +330 -0
  91. package/dist/lib/wsPresence.d.ts +25 -0
  92. package/dist/lib/wsPresence.js +99 -0
  93. package/dist/middleware/auditLog.d.ts +22 -0
  94. package/dist/middleware/auditLog.js +39 -0
  95. package/dist/middleware/bearerAuth.js +1 -1
  96. package/dist/middleware/cacheResponse.js +5 -1
  97. package/dist/middleware/captcha.d.ts +10 -0
  98. package/dist/middleware/captcha.js +36 -0
  99. package/dist/middleware/csrf.js +18 -4
  100. package/dist/middleware/errorHandler.js +4 -1
  101. package/dist/middleware/identify.js +89 -14
  102. package/dist/middleware/metrics.d.ts +9 -0
  103. package/dist/middleware/metrics.js +26 -0
  104. package/dist/middleware/requestId.d.ts +3 -0
  105. package/dist/middleware/requestId.js +7 -0
  106. package/dist/middleware/requestLogger.d.ts +38 -0
  107. package/dist/middleware/requestLogger.js +68 -0
  108. package/dist/middleware/requestSigning.d.ts +20 -0
  109. package/dist/middleware/requestSigning.js +100 -0
  110. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  111. package/dist/middleware/requireMfaSetup.js +37 -0
  112. package/dist/middleware/requireRole.d.ts +9 -3
  113. package/dist/middleware/requireRole.js +23 -36
  114. package/dist/middleware/requireScope.d.ts +10 -0
  115. package/dist/middleware/requireScope.js +25 -0
  116. package/dist/middleware/requireStepUp.d.ts +18 -0
  117. package/dist/middleware/requireStepUp.js +29 -0
  118. package/dist/middleware/scimAuth.d.ts +8 -0
  119. package/dist/middleware/scimAuth.js +29 -0
  120. package/dist/middleware/upload.d.ts +5 -0
  121. package/dist/middleware/upload.js +27 -0
  122. package/dist/middleware/webhookAuth.d.ts +30 -0
  123. package/dist/middleware/webhookAuth.js +58 -0
  124. package/dist/models/AuditLog.d.ts +30 -0
  125. package/dist/models/AuditLog.js +39 -0
  126. package/dist/models/AuthUser.d.ts +7 -0
  127. package/dist/models/AuthUser.js +7 -0
  128. package/dist/models/Group.d.ts +21 -0
  129. package/dist/models/Group.js +28 -0
  130. package/dist/models/GroupMembership.d.ts +21 -0
  131. package/dist/models/GroupMembership.js +25 -0
  132. package/dist/models/M2MClient.d.ts +18 -0
  133. package/dist/models/M2MClient.js +18 -0
  134. package/dist/routes/auth.d.ts +3 -2
  135. package/dist/routes/auth.js +238 -21
  136. package/dist/routes/groups.d.ts +21 -0
  137. package/dist/routes/groups.js +346 -0
  138. package/dist/routes/jobs.js +66 -46
  139. package/dist/routes/m2m.d.ts +2 -0
  140. package/dist/routes/m2m.js +72 -0
  141. package/dist/routes/metrics.d.ts +8 -0
  142. package/dist/routes/metrics.js +55 -0
  143. package/dist/routes/mfa.js +13 -1
  144. package/dist/routes/oauth.js +6 -0
  145. package/dist/routes/oidc.d.ts +2 -0
  146. package/dist/routes/oidc.js +29 -0
  147. package/dist/routes/passkey.d.ts +1 -0
  148. package/dist/routes/passkey.js +157 -0
  149. package/dist/routes/saml.d.ts +2 -0
  150. package/dist/routes/saml.js +86 -0
  151. package/dist/routes/scim.d.ts +2 -0
  152. package/dist/routes/scim.js +255 -0
  153. package/dist/routes/uploads.d.ts +14 -0
  154. package/dist/routes/uploads.js +227 -0
  155. package/dist/server.d.ts +26 -0
  156. package/dist/server.js +46 -3
  157. package/dist/services/auth.d.ts +2 -0
  158. package/dist/services/auth.js +101 -22
  159. package/dist/services/mfa.js +2 -2
  160. package/dist/ws/index.js +5 -1
  161. package/docs/sections/auth-flow/full.md +203 -47
  162. package/docs/sections/auth-flow/overview.md +2 -2
  163. package/docs/sections/auth-security-examples/full.md +388 -0
  164. package/docs/sections/authentication/full.md +130 -0
  165. package/docs/sections/authentication/overview.md +5 -0
  166. package/docs/sections/cli/full.md +13 -1
  167. package/docs/sections/configuration/full.md +17 -0
  168. package/docs/sections/configuration/overview.md +1 -0
  169. package/docs/sections/exports/full.md +34 -3
  170. package/docs/sections/logging/full.md +83 -0
  171. package/docs/sections/metrics/full.md +131 -0
  172. package/docs/sections/oauth/full.md +189 -189
  173. package/docs/sections/oauth/overview.md +1 -1
  174. package/docs/sections/pagination/full.md +93 -0
  175. package/docs/sections/passkey-login/full.md +90 -0
  176. package/docs/sections/passkey-login/overview.md +1 -0
  177. package/docs/sections/roles/full.md +224 -135
  178. package/docs/sections/roles/overview.md +3 -1
  179. package/docs/sections/signing/full.md +203 -0
  180. package/docs/sections/uploads/full.md +208 -0
  181. package/docs/sections/versioning/full.md +85 -0
  182. package/docs/sections/webhook-auth/full.md +100 -0
  183. package/docs/sections/websocket/full.md +95 -0
  184. package/docs/sections/websocket-rooms/full.md +6 -1
  185. 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
- ## 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 |
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
- ## Roles
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.