@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,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
|
@@ -143,6 +143,53 @@ export const createJobsRouter = (config) => {
|
|
|
143
143
|
const result = await Promise.all(filteredJobs.map(jobToResponse));
|
|
144
144
|
return c.json({ jobs: result, total }, 200);
|
|
145
145
|
});
|
|
146
|
+
// ─── Dead letter queue ────────────────────────────────────────────────
|
|
147
|
+
// Must be registered BEFORE getJobRoute so that the literal path segment
|
|
148
|
+
// "dead-letters" is matched before the parameterised {id} segment.
|
|
149
|
+
const getDlqRoute = createRoute({
|
|
150
|
+
method: "get",
|
|
151
|
+
path: "/jobs/{queue}/dead-letters",
|
|
152
|
+
summary: "List dead letter queue jobs",
|
|
153
|
+
description: "Returns paginated list of jobs in the dead letter queue for a given source queue.",
|
|
154
|
+
tags,
|
|
155
|
+
request: {
|
|
156
|
+
params: z.object({ queue: z.string().describe("Source queue name (DLQ name is {queue}-dlq).") }),
|
|
157
|
+
query: z.object({
|
|
158
|
+
start: z.string().optional().describe("Start index. Default: 0."),
|
|
159
|
+
end: z.string().optional().describe("End index. Default: 19."),
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
responses: {
|
|
163
|
+
200: {
|
|
164
|
+
content: {
|
|
165
|
+
"application/json": {
|
|
166
|
+
schema: z.object({
|
|
167
|
+
jobs: z.array(JobStatusResponse),
|
|
168
|
+
total: z.number().describe("Total jobs in DLQ."),
|
|
169
|
+
}),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
description: "DLQ jobs.",
|
|
173
|
+
},
|
|
174
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not allowed." },
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
router.openapi(applyRouteSecurity(getDlqRoute), async (c) => {
|
|
178
|
+
const { queue: queueName } = c.req.valid("param");
|
|
179
|
+
if (!isQueueAllowed(queueName)) {
|
|
180
|
+
return c.json({ error: "Queue not allowed" }, 403);
|
|
181
|
+
}
|
|
182
|
+
const { start: startStr, end: endStr } = c.req.valid("query");
|
|
183
|
+
const start = startStr ? parseInt(startStr) : 0;
|
|
184
|
+
const end = endStr ? parseInt(endStr) : 19;
|
|
185
|
+
const dlqQueue = createQueue(`${queueName}-dlq`);
|
|
186
|
+
const [jobs, total] = await Promise.all([
|
|
187
|
+
dlqQueue.getWaiting(start, end),
|
|
188
|
+
dlqQueue.getWaitingCount(),
|
|
189
|
+
]);
|
|
190
|
+
const result = await Promise.all(jobs.map(jobToResponse));
|
|
191
|
+
return c.json({ jobs: result, total }, 200);
|
|
192
|
+
});
|
|
146
193
|
// ─── Get job status ─────────────────────────────────────────────────────
|
|
147
194
|
const getJobRoute = createRoute({
|
|
148
195
|
method: "get",
|
|
@@ -221,50 +268,5 @@ export const createJobsRouter = (config) => {
|
|
|
221
268
|
const { logs, count } = await queue.getJobLogs(id);
|
|
222
269
|
return c.json({ logs, count }, 200);
|
|
223
270
|
});
|
|
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
271
|
return router;
|
|
270
272
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
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
|
+
}
|
|
7
|
+
export declare const createMetricsRouter: (config: MetricsRouteConfig) => import("@hono/zod-openapi").OpenAPIHono<AppEnv, {}, "/">;
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
// Apply auth middleware
|
|
8
|
+
if (authConfig === "userAuth") {
|
|
9
|
+
router.use("/metrics", userAuth);
|
|
10
|
+
}
|
|
11
|
+
else if (Array.isArray(authConfig)) {
|
|
12
|
+
for (const mw of authConfig) {
|
|
13
|
+
router.use("/metrics", mw);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Register BullMQ queue depth gauges if configured
|
|
17
|
+
if (config.queues?.length) {
|
|
18
|
+
const queueNames = config.queues;
|
|
19
|
+
const cachedQueues = new Map();
|
|
20
|
+
setMetricsQueues(cachedQueues);
|
|
21
|
+
registerGaugeCallback("bullmq_queue_depth", async () => {
|
|
22
|
+
const { createQueue } = await import("../lib/queue");
|
|
23
|
+
const results = [];
|
|
24
|
+
for (const name of queueNames) {
|
|
25
|
+
let queue = cachedQueues.get(name);
|
|
26
|
+
try {
|
|
27
|
+
if (!queue) {
|
|
28
|
+
queue = createQueue(name);
|
|
29
|
+
cachedQueues.set(name, queue);
|
|
30
|
+
}
|
|
31
|
+
const counts = await queue.getJobCounts("waiting", "active", "delayed", "failed");
|
|
32
|
+
for (const [state, count] of Object.entries(counts)) {
|
|
33
|
+
results.push({ labels: { queue: name, state }, value: count });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Discard cached instance on error so it's recreated next scrape
|
|
38
|
+
cachedQueues.delete(name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return results;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// Plain GET /metrics (not OpenAPI — infrastructure endpoint)
|
|
45
|
+
router.get("/metrics", async (c) => {
|
|
46
|
+
const body = await serializeMetrics();
|
|
47
|
+
return c.body(body, 200, {
|
|
48
|
+
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
return router;
|
|
52
|
+
};
|
package/dist/routes/mfa.js
CHANGED
|
@@ -56,10 +56,14 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
|
|
|
56
56
|
description: "TOTP secret generated. Scan the QR code with an authenticator app.",
|
|
57
57
|
},
|
|
58
58
|
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
59
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many MFA setup attempts. Try again later." },
|
|
59
60
|
501: { content: { "application/json": { schema: ErrorResponse } }, description: "Auth adapter does not support MFA." },
|
|
60
61
|
},
|
|
61
62
|
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
62
63
|
const userId = c.get("authUserId");
|
|
64
|
+
if (await trackAttempt(`mfa-setup:${userId}`, { windowMs: 15 * 60 * 1000, max: 5 })) {
|
|
65
|
+
return c.json({ error: "Too many MFA setup attempts. Try again later." }, 429);
|
|
66
|
+
}
|
|
63
67
|
const result = await MfaService.setupMfa(userId);
|
|
64
68
|
return c.json(result, 200);
|
|
65
69
|
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createRouter } from "../lib/context";
|
|
3
|
+
import { createRoute } from "../lib/createRoute";
|
|
4
|
+
import { userAuth } from "../middleware/userAuth";
|
|
5
|
+
import { getStorageAdapter, getUploadConfig } from "../lib/upload";
|
|
6
|
+
import { getSigningConfig, getSigningSecret } from "../lib/appConfig";
|
|
7
|
+
import { createPresignedUrl } from "../lib/signing";
|
|
8
|
+
const tags = ["Uploads"];
|
|
9
|
+
export const createUploadsRouter = (config) => {
|
|
10
|
+
const router = createRouter();
|
|
11
|
+
const basePath = (config.path ?? "/uploads").replace(/\/$/, "");
|
|
12
|
+
router.use(`${basePath}/*`, userAuth);
|
|
13
|
+
const presignRoute = createRoute({
|
|
14
|
+
method: "post",
|
|
15
|
+
path: `${basePath}/presign`,
|
|
16
|
+
tags,
|
|
17
|
+
summary: "Generate presigned upload URL",
|
|
18
|
+
request: {
|
|
19
|
+
body: {
|
|
20
|
+
content: {
|
|
21
|
+
"application/json": {
|
|
22
|
+
schema: z.object({
|
|
23
|
+
key: z.string().describe("Storage key for the upload"),
|
|
24
|
+
mimeType: z.string().optional().describe("MIME type of the file"),
|
|
25
|
+
expirySeconds: z.number().int().positive().optional().describe("URL expiry in seconds"),
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
responses: {
|
|
32
|
+
200: {
|
|
33
|
+
description: "Presigned URL generated",
|
|
34
|
+
content: { "application/json": { schema: z.object({ url: z.string(), key: z.string() }) } },
|
|
35
|
+
},
|
|
36
|
+
501: {
|
|
37
|
+
description: "Not implemented by adapter",
|
|
38
|
+
content: { "application/json": { schema: z.object({ error: z.string() }) } },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
router.openapi(presignRoute, async (c) => {
|
|
43
|
+
const adapter = getStorageAdapter();
|
|
44
|
+
if (!adapter?.presignPut) {
|
|
45
|
+
return c.json({ error: "Presigned URLs not supported by the configured storage adapter" }, 501);
|
|
46
|
+
}
|
|
47
|
+
const { key, mimeType, expirySeconds } = c.req.valid("json");
|
|
48
|
+
const _uploadConfig = getUploadConfig();
|
|
49
|
+
const expiry = expirySeconds ?? (typeof config.expirySeconds === "number" ? config.expirySeconds : 3600);
|
|
50
|
+
const url = await adapter.presignPut(key, { expirySeconds: expiry, mimeType });
|
|
51
|
+
return c.json({ url, key }, 200);
|
|
52
|
+
});
|
|
53
|
+
const presignGetRoute = createRoute({
|
|
54
|
+
method: "get",
|
|
55
|
+
path: `${basePath}/presign/:key{.+}`,
|
|
56
|
+
tags,
|
|
57
|
+
summary: "Generate presigned download URL",
|
|
58
|
+
request: {
|
|
59
|
+
params: z.object({ key: z.string() }),
|
|
60
|
+
query: z.object({
|
|
61
|
+
expiry: z.string().optional().describe("URL expiry in seconds (default: 3600)"),
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
responses: {
|
|
65
|
+
200: {
|
|
66
|
+
description: "Presigned download URL",
|
|
67
|
+
content: {
|
|
68
|
+
"application/json": {
|
|
69
|
+
schema: z.object({
|
|
70
|
+
url: z.string(),
|
|
71
|
+
expiresAt: z.number().describe("Unix timestamp (seconds) when the URL expires"),
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
501: {
|
|
77
|
+
description: "Not implemented",
|
|
78
|
+
content: { "application/json": { schema: z.object({ error: z.string() }) } },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
router.openapi(presignGetRoute, async (c) => {
|
|
83
|
+
const { key } = c.req.valid("param");
|
|
84
|
+
const { expiry: expiryStr } = c.req.valid("query");
|
|
85
|
+
const expirySeconds = expiryStr ? parseInt(expiryStr, 10) : (typeof config.expirySeconds === "number" ? config.expirySeconds : 3600);
|
|
86
|
+
const signingCfg = getSigningConfig();
|
|
87
|
+
if (signingCfg?.presignedUrls) {
|
|
88
|
+
const secret = getSigningSecret();
|
|
89
|
+
if (!secret)
|
|
90
|
+
return c.json({ error: "Signing secret not configured" }, 501);
|
|
91
|
+
const defaultExpiry = typeof signingCfg.presignedUrls === "object"
|
|
92
|
+
? (signingCfg.presignedUrls.defaultExpiry ?? expirySeconds)
|
|
93
|
+
: expirySeconds;
|
|
94
|
+
const base = new URL(c.req.url);
|
|
95
|
+
base.pathname = `${basePath}/download/${key}`;
|
|
96
|
+
base.search = "";
|
|
97
|
+
const url = createPresignedUrl(base.toString(), key, { method: "GET", expiry: defaultExpiry }, secret);
|
|
98
|
+
const expiresAt = Math.floor(Date.now() / 1000) + defaultExpiry;
|
|
99
|
+
return c.json({ url, expiresAt }, 200);
|
|
100
|
+
}
|
|
101
|
+
// Fallback: adapter.presignGet (S3 only)
|
|
102
|
+
const adapter = getStorageAdapter();
|
|
103
|
+
if (!adapter?.presignGet) {
|
|
104
|
+
return c.json({ error: "Presigned download URLs not supported. Enable signing.presignedUrls or use an S3 adapter." }, 501);
|
|
105
|
+
}
|
|
106
|
+
const url = await adapter.presignGet(key, { expirySeconds });
|
|
107
|
+
const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds;
|
|
108
|
+
return c.json({ url, expiresAt }, 200);
|
|
109
|
+
});
|
|
110
|
+
const deleteRoute = createRoute({
|
|
111
|
+
method: "delete",
|
|
112
|
+
path: `${basePath}/:key{.+}`,
|
|
113
|
+
tags,
|
|
114
|
+
summary: "Delete an uploaded file",
|
|
115
|
+
request: {
|
|
116
|
+
params: z.object({ key: z.string() }),
|
|
117
|
+
},
|
|
118
|
+
responses: {
|
|
119
|
+
204: { description: "Deleted" },
|
|
120
|
+
500: {
|
|
121
|
+
description: "No storage adapter configured",
|
|
122
|
+
content: { "application/json": { schema: z.object({ error: z.string() }) } },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
router.openapi(deleteRoute, async (c) => {
|
|
127
|
+
const adapter = getStorageAdapter();
|
|
128
|
+
if (!adapter)
|
|
129
|
+
return c.json({ error: "No storage adapter configured" }, 500);
|
|
130
|
+
const { key } = c.req.valid("param");
|
|
131
|
+
await adapter.delete(key);
|
|
132
|
+
return c.body(null, 204);
|
|
133
|
+
});
|
|
134
|
+
return router;
|
|
135
|
+
};
|