@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,346 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createRoute, withSecurity } from "../lib/createRoute";
|
|
3
|
+
import { createRouter } from "../lib/context";
|
|
4
|
+
import { userAuth } from "../middleware/userAuth";
|
|
5
|
+
import { requireRole } from "../middleware/requireRole";
|
|
6
|
+
import { createGroup, deleteGroup, getGroup, listGroups, updateGroup, addGroupMember, updateGroupMembership, removeGroupMember, getGroupMembers, getUserGroups, } from "../lib/groups";
|
|
7
|
+
import { HttpError } from "../lib/HttpError";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Zod schemas
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const SLUG_REGEX = /^[a-z0-9_-]+$/;
|
|
12
|
+
const GroupBody = z.object({
|
|
13
|
+
name: z.string().min(1).max(100).regex(SLUG_REGEX, "name must be a slug: lowercase letters, digits, underscores, or hyphens"),
|
|
14
|
+
displayName: z.string().max(200).optional(),
|
|
15
|
+
description: z.string().max(1000).optional(),
|
|
16
|
+
roles: z.array(z.string()).default([]),
|
|
17
|
+
tenantId: z.string().nullable().optional().default(null),
|
|
18
|
+
});
|
|
19
|
+
const GroupUpdateBody = z.object({
|
|
20
|
+
name: z.string().min(1).max(100).regex(SLUG_REGEX, "name must be a slug").optional(),
|
|
21
|
+
displayName: z.string().max(200).nullable().optional(),
|
|
22
|
+
description: z.string().max(1000).nullable().optional(),
|
|
23
|
+
roles: z.array(z.string()).optional(),
|
|
24
|
+
});
|
|
25
|
+
const MemberBody = z.object({
|
|
26
|
+
userId: z.string().min(1),
|
|
27
|
+
roles: z.array(z.string()).default([]),
|
|
28
|
+
});
|
|
29
|
+
const MemberRolesBody = z.object({
|
|
30
|
+
roles: z.array(z.string()),
|
|
31
|
+
});
|
|
32
|
+
const GroupResponse = z.object({
|
|
33
|
+
id: z.string(),
|
|
34
|
+
name: z.string(),
|
|
35
|
+
displayName: z.string().optional(),
|
|
36
|
+
description: z.string().optional(),
|
|
37
|
+
roles: z.array(z.string()),
|
|
38
|
+
tenantId: z.string().nullable(),
|
|
39
|
+
createdAt: z.number(),
|
|
40
|
+
updatedAt: z.number(),
|
|
41
|
+
}).openapi("Group");
|
|
42
|
+
const MemberResponse = z.object({
|
|
43
|
+
userId: z.string(),
|
|
44
|
+
roles: z.array(z.string()),
|
|
45
|
+
}).openapi("GroupMember");
|
|
46
|
+
const PaginatedGroupsResponse = z.object({
|
|
47
|
+
items: z.array(GroupResponse),
|
|
48
|
+
total: z.number(),
|
|
49
|
+
limit: z.number(),
|
|
50
|
+
offset: z.number(),
|
|
51
|
+
}).openapi("PaginatedGroups");
|
|
52
|
+
const PaginatedMembersResponse = z.object({
|
|
53
|
+
items: z.array(MemberResponse),
|
|
54
|
+
total: z.number(),
|
|
55
|
+
limit: z.number(),
|
|
56
|
+
offset: z.number(),
|
|
57
|
+
}).openapi("PaginatedGroupMembers");
|
|
58
|
+
const UserGroupEntry = z.object({
|
|
59
|
+
group: GroupResponse,
|
|
60
|
+
membershipRoles: z.array(z.string()),
|
|
61
|
+
}).openapi("UserGroupEntry");
|
|
62
|
+
const ErrorResponse = z.object({ error: z.string() });
|
|
63
|
+
const PaginationParams = {
|
|
64
|
+
limit: z.string().optional().openapi({ description: "Max results (default 50, max 200)" }),
|
|
65
|
+
offset: z.string().optional().openapi({ description: "Skip this many results (default 0)" }),
|
|
66
|
+
};
|
|
67
|
+
const tags = ["Groups"];
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Router factory
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
export const createGroupsRouter = (config) => {
|
|
72
|
+
const router = createRouter();
|
|
73
|
+
// Normalize: managementRoutes: true is equivalent to managementRoutes: {}
|
|
74
|
+
const mgmt = config.managementRoutes === true ? {} : (config.managementRoutes ?? {});
|
|
75
|
+
const guard = mgmt.middleware ?? [userAuth, requireRole.global(mgmt.adminRole ?? "admin")];
|
|
76
|
+
// Defensive guard warning
|
|
77
|
+
if (guard.length === 0 && process.env.NODE_ENV !== "production") {
|
|
78
|
+
console.warn("[bunshot] groups.managementRoutes: resolved auth middleware is empty — management routes are unprotected");
|
|
79
|
+
}
|
|
80
|
+
// Apply auth guard to all group management routes
|
|
81
|
+
router.use("/groups/*", ...guard);
|
|
82
|
+
router.use("/users/:userId/groups", ...guard);
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// GET /groups — list groups
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
router.openapi(withSecurity(createRoute({
|
|
87
|
+
method: "get",
|
|
88
|
+
path: "/groups",
|
|
89
|
+
tags,
|
|
90
|
+
summary: "List groups",
|
|
91
|
+
description: "List groups. Returns tenant-scoped groups when tenantId is in context, app-wide groups otherwise.",
|
|
92
|
+
request: { query: z.object(PaginationParams) },
|
|
93
|
+
responses: {
|
|
94
|
+
200: { content: { "application/json": { schema: PaginatedGroupsResponse } }, description: "Groups list" },
|
|
95
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
96
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Forbidden" },
|
|
97
|
+
},
|
|
98
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
99
|
+
const tenantId = c.get("tenantId") ?? null;
|
|
100
|
+
const { limit, offset } = c.req.valid("query");
|
|
101
|
+
const result = await listGroups(tenantId, {
|
|
102
|
+
limit: limit ? Math.min(parseInt(limit, 10), 200) : 50,
|
|
103
|
+
offset: offset ? parseInt(offset, 10) : 0,
|
|
104
|
+
});
|
|
105
|
+
return c.json(result, 200);
|
|
106
|
+
});
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// POST /groups — create group
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
router.openapi(withSecurity(createRoute({
|
|
111
|
+
method: "post",
|
|
112
|
+
path: "/groups",
|
|
113
|
+
tags,
|
|
114
|
+
summary: "Create group",
|
|
115
|
+
request: { body: { content: { "application/json": { schema: GroupBody } }, required: true } },
|
|
116
|
+
responses: {
|
|
117
|
+
201: { content: { "application/json": { schema: z.object({ id: z.string() }) } }, description: "Group created" },
|
|
118
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
119
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Forbidden" },
|
|
120
|
+
409: { content: { "application/json": { schema: ErrorResponse } }, description: "Name already exists in scope" },
|
|
121
|
+
422: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
122
|
+
},
|
|
123
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
124
|
+
const body = c.req.valid("json");
|
|
125
|
+
const result = await createGroup({
|
|
126
|
+
name: body.name,
|
|
127
|
+
displayName: body.displayName,
|
|
128
|
+
description: body.description,
|
|
129
|
+
roles: body.roles,
|
|
130
|
+
tenantId: body.tenantId ?? null,
|
|
131
|
+
});
|
|
132
|
+
return c.json(result, 201);
|
|
133
|
+
});
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// GET /groups/:groupId — get group
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
router.openapi(withSecurity(createRoute({
|
|
138
|
+
method: "get",
|
|
139
|
+
path: "/groups/:groupId",
|
|
140
|
+
tags,
|
|
141
|
+
summary: "Get group",
|
|
142
|
+
request: { params: z.object({ groupId: z.string() }) },
|
|
143
|
+
responses: {
|
|
144
|
+
200: { content: { "application/json": { schema: GroupResponse } }, description: "Group" },
|
|
145
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
146
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Forbidden" },
|
|
147
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Not found" },
|
|
148
|
+
},
|
|
149
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
150
|
+
const { groupId } = c.req.valid("param");
|
|
151
|
+
const group = await getGroup(groupId);
|
|
152
|
+
if (!group)
|
|
153
|
+
return c.json({ error: "Group not found" }, 404);
|
|
154
|
+
return c.json(group, 200);
|
|
155
|
+
});
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// PATCH /groups/:groupId — update group
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
router.openapi(withSecurity(createRoute({
|
|
160
|
+
method: "patch",
|
|
161
|
+
path: "/groups/:groupId",
|
|
162
|
+
tags,
|
|
163
|
+
summary: "Update group",
|
|
164
|
+
request: {
|
|
165
|
+
params: z.object({ groupId: z.string() }),
|
|
166
|
+
body: { content: { "application/json": { schema: GroupUpdateBody } }, required: true },
|
|
167
|
+
},
|
|
168
|
+
responses: {
|
|
169
|
+
204: { description: "Updated" },
|
|
170
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
171
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Forbidden" },
|
|
172
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Not found" },
|
|
173
|
+
409: { content: { "application/json": { schema: ErrorResponse } }, description: "Name conflict" },
|
|
174
|
+
422: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
175
|
+
},
|
|
176
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
177
|
+
const { groupId } = c.req.valid("param");
|
|
178
|
+
const body = c.req.valid("json");
|
|
179
|
+
const group = await getGroup(groupId);
|
|
180
|
+
if (!group)
|
|
181
|
+
return c.json({ error: "Group not found" }, 404);
|
|
182
|
+
const updates = {};
|
|
183
|
+
if (body.name !== undefined)
|
|
184
|
+
updates.name = body.name;
|
|
185
|
+
if ("displayName" in body)
|
|
186
|
+
updates.displayName = body.displayName ?? undefined;
|
|
187
|
+
if ("description" in body)
|
|
188
|
+
updates.description = body.description ?? undefined;
|
|
189
|
+
if (body.roles !== undefined)
|
|
190
|
+
updates.roles = body.roles;
|
|
191
|
+
await updateGroup(groupId, updates);
|
|
192
|
+
return c.body(null, 204);
|
|
193
|
+
});
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// DELETE /groups/:groupId — delete group
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
router.openapi(withSecurity(createRoute({
|
|
198
|
+
method: "delete",
|
|
199
|
+
path: "/groups/:groupId",
|
|
200
|
+
tags,
|
|
201
|
+
summary: "Delete group",
|
|
202
|
+
description: "Delete a group. All memberships are cascade-deleted.",
|
|
203
|
+
request: { params: z.object({ groupId: z.string() }) },
|
|
204
|
+
responses: {
|
|
205
|
+
204: { description: "Deleted" },
|
|
206
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
207
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Forbidden" },
|
|
208
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Not found" },
|
|
209
|
+
},
|
|
210
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
211
|
+
const { groupId } = c.req.valid("param");
|
|
212
|
+
const group = await getGroup(groupId);
|
|
213
|
+
if (!group)
|
|
214
|
+
return c.json({ error: "Group not found" }, 404);
|
|
215
|
+
await deleteGroup(groupId);
|
|
216
|
+
return c.body(null, 204);
|
|
217
|
+
});
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// GET /groups/:groupId/members — list members
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
router.openapi(withSecurity(createRoute({
|
|
222
|
+
method: "get",
|
|
223
|
+
path: "/groups/:groupId/members",
|
|
224
|
+
tags,
|
|
225
|
+
summary: "List group members",
|
|
226
|
+
request: {
|
|
227
|
+
params: z.object({ groupId: z.string() }),
|
|
228
|
+
query: z.object(PaginationParams),
|
|
229
|
+
},
|
|
230
|
+
responses: {
|
|
231
|
+
200: { content: { "application/json": { schema: PaginatedMembersResponse } }, description: "Members list" },
|
|
232
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
233
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Forbidden" },
|
|
234
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Group not found" },
|
|
235
|
+
},
|
|
236
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
237
|
+
const { groupId } = c.req.valid("param");
|
|
238
|
+
const { limit, offset } = c.req.valid("query");
|
|
239
|
+
const group = await getGroup(groupId);
|
|
240
|
+
if (!group)
|
|
241
|
+
return c.json({ error: "Group not found" }, 404);
|
|
242
|
+
const result = await getGroupMembers(groupId, {
|
|
243
|
+
limit: limit ? Math.min(parseInt(limit, 10), 200) : 50,
|
|
244
|
+
offset: offset ? parseInt(offset, 10) : 0,
|
|
245
|
+
});
|
|
246
|
+
return c.json(result, 200);
|
|
247
|
+
});
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// POST /groups/:groupId/members — add member
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
router.openapi(withSecurity(createRoute({
|
|
252
|
+
method: "post",
|
|
253
|
+
path: "/groups/:groupId/members",
|
|
254
|
+
tags,
|
|
255
|
+
summary: "Add group member",
|
|
256
|
+
request: {
|
|
257
|
+
params: z.object({ groupId: z.string() }),
|
|
258
|
+
body: { content: { "application/json": { schema: MemberBody } }, required: true },
|
|
259
|
+
},
|
|
260
|
+
responses: {
|
|
261
|
+
201: { description: "Member added" },
|
|
262
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
263
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Forbidden" },
|
|
264
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Group not found" },
|
|
265
|
+
409: { content: { "application/json": { schema: ErrorResponse } }, description: "User already a member" },
|
|
266
|
+
},
|
|
267
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
268
|
+
const { groupId } = c.req.valid("param");
|
|
269
|
+
const body = c.req.valid("json");
|
|
270
|
+
try {
|
|
271
|
+
await addGroupMember(groupId, body.userId, body.roles);
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
if (err instanceof HttpError) {
|
|
275
|
+
return c.json({ error: err.message }, err.status);
|
|
276
|
+
}
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
return c.body(null, 201);
|
|
280
|
+
});
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// PATCH /groups/:groupId/members/:userId — update member roles
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
router.openapi(withSecurity(createRoute({
|
|
285
|
+
method: "patch",
|
|
286
|
+
path: "/groups/:groupId/members/:userId",
|
|
287
|
+
tags,
|
|
288
|
+
summary: "Update member roles",
|
|
289
|
+
description: "Update the per-membership roles for an existing group member.",
|
|
290
|
+
request: {
|
|
291
|
+
params: z.object({ groupId: z.string(), userId: z.string() }),
|
|
292
|
+
body: { content: { "application/json": { schema: MemberRolesBody } }, required: true },
|
|
293
|
+
},
|
|
294
|
+
responses: {
|
|
295
|
+
204: { description: "Updated" },
|
|
296
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
297
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Forbidden" },
|
|
298
|
+
},
|
|
299
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
300
|
+
const { groupId, userId } = c.req.valid("param");
|
|
301
|
+
const { roles } = c.req.valid("json");
|
|
302
|
+
await updateGroupMembership(groupId, userId, roles);
|
|
303
|
+
return c.body(null, 204);
|
|
304
|
+
});
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// DELETE /groups/:groupId/members/:userId — remove member
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
router.openapi(withSecurity(createRoute({
|
|
309
|
+
method: "delete",
|
|
310
|
+
path: "/groups/:groupId/members/:userId",
|
|
311
|
+
tags,
|
|
312
|
+
summary: "Remove group member",
|
|
313
|
+
request: { params: z.object({ groupId: z.string(), userId: z.string() }) },
|
|
314
|
+
responses: {
|
|
315
|
+
204: { description: "Removed" },
|
|
316
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
317
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Forbidden" },
|
|
318
|
+
},
|
|
319
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
320
|
+
const { groupId, userId } = c.req.valid("param");
|
|
321
|
+
await removeGroupMember(groupId, userId);
|
|
322
|
+
return c.body(null, 204);
|
|
323
|
+
});
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// GET /users/:userId/groups — list user's groups
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
router.openapi(withSecurity(createRoute({
|
|
328
|
+
method: "get",
|
|
329
|
+
path: "/users/:userId/groups",
|
|
330
|
+
tags,
|
|
331
|
+
summary: "List user's groups",
|
|
332
|
+
description: "List groups a user belongs to. Scoped to tenant context when present, app-wide otherwise.",
|
|
333
|
+
request: { params: z.object({ userId: z.string() }) },
|
|
334
|
+
responses: {
|
|
335
|
+
200: { content: { "application/json": { schema: z.array(UserGroupEntry) } }, description: "User's groups" },
|
|
336
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
337
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Forbidden" },
|
|
338
|
+
},
|
|
339
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
340
|
+
const { userId } = c.req.valid("param");
|
|
341
|
+
const tenantId = c.get("tenantId") ?? null;
|
|
342
|
+
const groups = await getUserGroups(userId, tenantId);
|
|
343
|
+
return c.json(groups, 200);
|
|
344
|
+
});
|
|
345
|
+
return router;
|
|
346
|
+
};
|
package/dist/routes/jobs.js
CHANGED
|
@@ -21,6 +21,9 @@ export const createJobsRouter = (config) => {
|
|
|
21
21
|
const allowedQueues = new Set(config.allowedQueues ?? []);
|
|
22
22
|
const authConfig = config.auth ?? "none";
|
|
23
23
|
const scopeToUser = config.scopeToUser ?? false;
|
|
24
|
+
if (process.env.NODE_ENV === "production" && authConfig === "none" && !config.unsafePublic) {
|
|
25
|
+
throw new Error("[security] jobs.auth is required in production. Set jobs.auth or set unsafePublic: true.");
|
|
26
|
+
}
|
|
24
27
|
// Determine if userAuth is involved (for scopeToUser and OpenAPI security schemes)
|
|
25
28
|
const hasUserAuth = authConfig === "userAuth" || Array.isArray(authConfig);
|
|
26
29
|
// Apply middleware based on config
|
|
@@ -141,7 +144,63 @@ export const createJobsRouter = (config) => {
|
|
|
141
144
|
filteredJobs = jobs.filter((job) => job.data?.userId === userId);
|
|
142
145
|
}
|
|
143
146
|
const result = await Promise.all(filteredJobs.map(jobToResponse));
|
|
144
|
-
|
|
147
|
+
// NOTE: When scopeToUser is active, total is a page-local filtered count, not a
|
|
148
|
+
// globally accurate total. BullMQ does not support server-side user filtering.
|
|
149
|
+
return c.json({ jobs: result, total: scopeToUser && hasUserAuth ? filteredJobs.length : total }, 200);
|
|
150
|
+
});
|
|
151
|
+
// ─── Dead letter queue ────────────────────────────────────────────────
|
|
152
|
+
// Must be registered BEFORE getJobRoute so that the literal path segment
|
|
153
|
+
// "dead-letters" is matched before the parameterised {id} segment.
|
|
154
|
+
const getDlqRoute = createRoute({
|
|
155
|
+
method: "get",
|
|
156
|
+
path: "/jobs/{queue}/dead-letters",
|
|
157
|
+
summary: "List dead letter queue jobs",
|
|
158
|
+
description: "Returns paginated list of jobs in the dead letter queue for a given source queue.",
|
|
159
|
+
tags,
|
|
160
|
+
request: {
|
|
161
|
+
params: z.object({ queue: z.string().describe("Source queue name (DLQ name is {queue}-dlq).") }),
|
|
162
|
+
query: z.object({
|
|
163
|
+
start: z.string().optional().describe("Start index. Default: 0."),
|
|
164
|
+
end: z.string().optional().describe("End index. Default: 19."),
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
responses: {
|
|
168
|
+
200: {
|
|
169
|
+
content: {
|
|
170
|
+
"application/json": {
|
|
171
|
+
schema: z.object({
|
|
172
|
+
jobs: z.array(JobStatusResponse),
|
|
173
|
+
total: z.number().describe("Total jobs in DLQ."),
|
|
174
|
+
}),
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
description: "DLQ jobs.",
|
|
178
|
+
},
|
|
179
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not allowed." },
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
router.openapi(applyRouteSecurity(getDlqRoute), async (c) => {
|
|
183
|
+
const { queue: queueName } = c.req.valid("param");
|
|
184
|
+
if (!isQueueAllowed(queueName)) {
|
|
185
|
+
return c.json({ error: "Queue not allowed" }, 403);
|
|
186
|
+
}
|
|
187
|
+
const { start: startStr, end: endStr } = c.req.valid("query");
|
|
188
|
+
const start = startStr ? parseInt(startStr) : 0;
|
|
189
|
+
const end = endStr ? parseInt(endStr) : 19;
|
|
190
|
+
const dlqQueue = createQueue(`${queueName}-dlq`);
|
|
191
|
+
const [jobs, total] = await Promise.all([
|
|
192
|
+
dlqQueue.getWaiting(start, end),
|
|
193
|
+
dlqQueue.getWaitingCount(),
|
|
194
|
+
]);
|
|
195
|
+
let filteredJobs = jobs;
|
|
196
|
+
if (scopeToUser && hasUserAuth) {
|
|
197
|
+
const userId = c.get("authUserId");
|
|
198
|
+
filteredJobs = jobs.filter((job) => job.data?.userId === userId);
|
|
199
|
+
}
|
|
200
|
+
const result = await Promise.all(filteredJobs.map(jobToResponse));
|
|
201
|
+
// NOTE: When scopeToUser is active, total is a page-local filtered count, not a
|
|
202
|
+
// globally accurate total. BullMQ does not support server-side user filtering.
|
|
203
|
+
return c.json({ jobs: result, total: scopeToUser && hasUserAuth ? filteredJobs.length : total }, 200);
|
|
145
204
|
});
|
|
146
205
|
// ─── Get job status ─────────────────────────────────────────────────────
|
|
147
206
|
const getJobRoute = createRoute({
|
|
@@ -218,53 +277,14 @@ export const createJobsRouter = (config) => {
|
|
|
218
277
|
const job = await queue.getJob(id);
|
|
219
278
|
if (!job)
|
|
220
279
|
return c.json({ error: "Job not found" }, 404);
|
|
280
|
+
if (scopeToUser && hasUserAuth) {
|
|
281
|
+
const userId = c.get("authUserId");
|
|
282
|
+
if (job.data?.userId !== userId) {
|
|
283
|
+
return c.json({ error: "Job not found" }, 404);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
221
286
|
const { logs, count } = await queue.getJobLogs(id);
|
|
222
287
|
return c.json({ logs, count }, 200);
|
|
223
288
|
});
|
|
224
|
-
// ─── Dead letter queue ────────────────────────────────────────────────
|
|
225
|
-
const getDlqRoute = createRoute({
|
|
226
|
-
method: "get",
|
|
227
|
-
path: "/jobs/{queue}/dead-letters",
|
|
228
|
-
summary: "List dead letter queue jobs",
|
|
229
|
-
description: "Returns paginated list of jobs in the dead letter queue for a given source queue.",
|
|
230
|
-
tags,
|
|
231
|
-
request: {
|
|
232
|
-
params: z.object({ queue: z.string().describe("Source queue name (DLQ name is {queue}-dlq).") }),
|
|
233
|
-
query: z.object({
|
|
234
|
-
start: z.string().optional().describe("Start index. Default: 0."),
|
|
235
|
-
end: z.string().optional().describe("End index. Default: 19."),
|
|
236
|
-
}),
|
|
237
|
-
},
|
|
238
|
-
responses: {
|
|
239
|
-
200: {
|
|
240
|
-
content: {
|
|
241
|
-
"application/json": {
|
|
242
|
-
schema: z.object({
|
|
243
|
-
jobs: z.array(JobStatusResponse),
|
|
244
|
-
total: z.number().describe("Total jobs in DLQ."),
|
|
245
|
-
}),
|
|
246
|
-
},
|
|
247
|
-
},
|
|
248
|
-
description: "DLQ jobs.",
|
|
249
|
-
},
|
|
250
|
-
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not allowed." },
|
|
251
|
-
},
|
|
252
|
-
});
|
|
253
|
-
router.openapi(applyRouteSecurity(getDlqRoute), async (c) => {
|
|
254
|
-
const { queue: queueName } = c.req.valid("param");
|
|
255
|
-
if (!isQueueAllowed(queueName)) {
|
|
256
|
-
return c.json({ error: "Queue not allowed" }, 403);
|
|
257
|
-
}
|
|
258
|
-
const { start: startStr, end: endStr } = c.req.valid("query");
|
|
259
|
-
const start = startStr ? parseInt(startStr) : 0;
|
|
260
|
-
const end = endStr ? parseInt(endStr) : 19;
|
|
261
|
-
const dlqQueue = createQueue(`${queueName}-dlq`);
|
|
262
|
-
const [jobs, total] = await Promise.all([
|
|
263
|
-
dlqQueue.getWaiting(start, end),
|
|
264
|
-
dlqQueue.getWaitingCount(),
|
|
265
|
-
]);
|
|
266
|
-
const result = await Promise.all(jobs.map(jobToResponse));
|
|
267
|
-
return c.json({ jobs: result, total }, 200);
|
|
268
|
-
});
|
|
269
289
|
return router;
|
|
270
290
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getAuthAdapter } from "../lib/authAdapter";
|
|
3
|
+
import { signToken } from "../lib/jwt";
|
|
4
|
+
import { HttpError } from "../lib/HttpError";
|
|
5
|
+
import { getM2MTokenExpiry } from "../lib/appConfig";
|
|
6
|
+
export function createM2MRouter() {
|
|
7
|
+
const router = new Hono();
|
|
8
|
+
/**
|
|
9
|
+
* POST /oauth/token
|
|
10
|
+
* OAuth 2.0 client_credentials grant
|
|
11
|
+
*/
|
|
12
|
+
router.post("/oauth/token", async (c) => {
|
|
13
|
+
let body;
|
|
14
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
15
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
16
|
+
const text = await c.req.text();
|
|
17
|
+
const params = new URLSearchParams(text);
|
|
18
|
+
body = {
|
|
19
|
+
grant_type: params.get("grant_type") ?? undefined,
|
|
20
|
+
client_id: params.get("client_id") ?? undefined,
|
|
21
|
+
client_secret: params.get("client_secret") ?? undefined,
|
|
22
|
+
scope: params.get("scope") ?? undefined,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
try {
|
|
27
|
+
body = await c.req.json();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new HttpError(400, "Invalid request body");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (body.grant_type !== "client_credentials") {
|
|
34
|
+
throw new HttpError(400, "Unsupported grant type", "UNSUPPORTED_GRANT_TYPE");
|
|
35
|
+
}
|
|
36
|
+
const { client_id: clientId, client_secret: clientSecret } = body;
|
|
37
|
+
if (!clientId || !clientSecret) {
|
|
38
|
+
throw new HttpError(400, "client_id and client_secret are required");
|
|
39
|
+
}
|
|
40
|
+
const adapter = getAuthAdapter();
|
|
41
|
+
if (!adapter.getM2MClient) {
|
|
42
|
+
throw new HttpError(501, "M2M client credentials not supported by auth adapter");
|
|
43
|
+
}
|
|
44
|
+
const client = await adapter.getM2MClient(clientId);
|
|
45
|
+
if (!client) {
|
|
46
|
+
throw new HttpError(401, "Invalid client credentials");
|
|
47
|
+
}
|
|
48
|
+
const secretValid = await Bun.password.verify(clientSecret, client.clientSecretHash);
|
|
49
|
+
if (!secretValid) {
|
|
50
|
+
throw new HttpError(401, "Invalid client credentials");
|
|
51
|
+
}
|
|
52
|
+
// Validate requested scopes against client's allowed scopes
|
|
53
|
+
let grantedScopes = client.scopes;
|
|
54
|
+
if (body.scope) {
|
|
55
|
+
const requested = body.scope.split(" ");
|
|
56
|
+
const invalid = requested.filter((s) => !client.scopes.includes(s));
|
|
57
|
+
if (invalid.length > 0) {
|
|
58
|
+
throw new HttpError(400, `Scope not allowed: ${invalid.join(", ")}`, "INVALID_SCOPE");
|
|
59
|
+
}
|
|
60
|
+
grantedScopes = requested;
|
|
61
|
+
}
|
|
62
|
+
const expiry = getM2MTokenExpiry();
|
|
63
|
+
const token = await signToken({ sub: clientId, scope: grantedScopes.join(" ") }, expiry);
|
|
64
|
+
return c.json({
|
|
65
|
+
access_token: token,
|
|
66
|
+
token_type: "Bearer",
|
|
67
|
+
expires_in: expiry,
|
|
68
|
+
scope: grantedScopes.join(" "),
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return router;
|
|
72
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
export interface MetricsRouteConfig {
|
|
4
|
+
auth?: "userAuth" | "none" | MiddlewareHandler<AppEnv>[];
|
|
5
|
+
queues?: string[];
|
|
6
|
+
unsafePublic?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const createMetricsRouter: (config: MetricsRouteConfig) => import("@hono/zod-openapi").OpenAPIHono<AppEnv, {}, "/">;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createRouter } from "../lib/context";
|
|
2
|
+
import { serializeMetrics, registerGaugeCallback, setMetricsQueues } from "../lib/metrics";
|
|
3
|
+
import { userAuth } from "../middleware/userAuth";
|
|
4
|
+
export const createMetricsRouter = (config) => {
|
|
5
|
+
const router = createRouter();
|
|
6
|
+
const authConfig = config.auth ?? "none";
|
|
7
|
+
if (process.env.NODE_ENV === "production" && authConfig === "none" && !config.unsafePublic) {
|
|
8
|
+
throw new Error("[security] metrics.auth is required in production. Set metrics.auth or set unsafePublic: true.");
|
|
9
|
+
}
|
|
10
|
+
// Apply auth middleware
|
|
11
|
+
if (authConfig === "userAuth") {
|
|
12
|
+
router.use("/metrics", userAuth);
|
|
13
|
+
}
|
|
14
|
+
else if (Array.isArray(authConfig)) {
|
|
15
|
+
for (const mw of authConfig) {
|
|
16
|
+
router.use("/metrics", mw);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Register BullMQ queue depth gauges if configured
|
|
20
|
+
if (config.queues?.length) {
|
|
21
|
+
const queueNames = config.queues;
|
|
22
|
+
const cachedQueues = new Map();
|
|
23
|
+
setMetricsQueues(cachedQueues);
|
|
24
|
+
registerGaugeCallback("bullmq_queue_depth", async () => {
|
|
25
|
+
const { createQueue } = await import("../lib/queue");
|
|
26
|
+
const results = [];
|
|
27
|
+
for (const name of queueNames) {
|
|
28
|
+
let queue = cachedQueues.get(name);
|
|
29
|
+
try {
|
|
30
|
+
if (!queue) {
|
|
31
|
+
queue = createQueue(name);
|
|
32
|
+
cachedQueues.set(name, queue);
|
|
33
|
+
}
|
|
34
|
+
const counts = await queue.getJobCounts("waiting", "active", "delayed", "failed");
|
|
35
|
+
for (const [state, count] of Object.entries(counts)) {
|
|
36
|
+
results.push({ labels: { queue: name, state }, value: count });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Discard cached instance on error so it's recreated next scrape
|
|
41
|
+
cachedQueues.delete(name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// Plain GET /metrics (not OpenAPI — infrastructure endpoint)
|
|
48
|
+
router.get("/metrics", async (c) => {
|
|
49
|
+
const body = await serializeMetrics();
|
|
50
|
+
return c.body(body, 200, {
|
|
51
|
+
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
return router;
|
|
55
|
+
};
|