@lastshotlabs/bunshot 0.0.21 → 0.0.25
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 +44 -0
- package/dist/adapters/memoryAuth.d.ts +7 -0
- package/dist/adapters/memoryAuth.js +144 -0
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +120 -0
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +7 -0
- package/dist/adapters/sqliteAuth.js +199 -0
- package/dist/app.d.ts +100 -3
- package/dist/app.js +247 -46
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +49 -7
- package/dist/index.js +35 -5
- package/dist/lib/HttpError.d.ts +5 -0
- package/dist/lib/HttpError.js +7 -0
- package/dist/lib/appConfig.d.ts +44 -0
- package/dist/lib/appConfig.js +16 -0
- package/dist/lib/auditLog.d.ts +52 -0
- package/dist/lib/auditLog.js +201 -0
- package/dist/lib/authAdapter.d.ts +69 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +19 -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/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- 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/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/session.d.ts +4 -0
- package/dist/lib/session.js +56 -2
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +180 -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/tenant.js +2 -2
- package/dist/lib/upload.d.ts +35 -0
- package/dist/lib/upload.js +87 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +21 -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/cacheResponse.js +5 -1
- package/dist/middleware/csrf.js +10 -0
- package/dist/middleware/identify.js +57 -9
- 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 +99 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +36 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- 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 +57 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -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/routes/auth.js +84 -6
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +47 -45
- package/dist/routes/metrics.d.ts +7 -0
- package/dist/routes/metrics.js +52 -0
- package/dist/routes/mfa.js +4 -0
- package/dist/routes/uploads.d.ts +2 -0
- package/dist/routes/uploads.js +135 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/ws/index.js +3 -0
- package/docs/sections/auth-flow/full.md +779 -634
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +365 -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 +127 -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/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 +199 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +83 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +16 -4
|
@@ -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 |
|
|
@@ -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.
|
|
@@ -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.
|