@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.
Files changed (122) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +44 -0
  4. package/dist/adapters/memoryAuth.d.ts +7 -0
  5. package/dist/adapters/memoryAuth.js +144 -0
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +120 -0
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +7 -0
  12. package/dist/adapters/sqliteAuth.js +199 -0
  13. package/dist/app.d.ts +100 -3
  14. package/dist/app.js +247 -46
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +49 -7
  17. package/dist/index.js +35 -5
  18. package/dist/lib/HttpError.d.ts +5 -0
  19. package/dist/lib/HttpError.js +7 -0
  20. package/dist/lib/appConfig.d.ts +44 -0
  21. package/dist/lib/appConfig.js +16 -0
  22. package/dist/lib/auditLog.d.ts +52 -0
  23. package/dist/lib/auditLog.js +201 -0
  24. package/dist/lib/authAdapter.d.ts +69 -0
  25. package/dist/lib/constants.d.ts +4 -0
  26. package/dist/lib/constants.js +4 -0
  27. package/dist/lib/context.d.ts +19 -1
  28. package/dist/lib/context.js +17 -3
  29. package/dist/lib/createRoute.d.ts +28 -2
  30. package/dist/lib/createRoute.js +54 -3
  31. package/dist/lib/deletionCancelToken.d.ts +12 -0
  32. package/dist/lib/deletionCancelToken.js +88 -0
  33. package/dist/lib/groups.d.ts +113 -0
  34. package/dist/lib/groups.js +133 -0
  35. package/dist/lib/idempotency.d.ts +22 -0
  36. package/dist/lib/idempotency.js +182 -0
  37. package/dist/lib/metrics.d.ts +14 -0
  38. package/dist/lib/metrics.js +158 -0
  39. package/dist/lib/pagination.d.ts +119 -0
  40. package/dist/lib/pagination.js +166 -0
  41. package/dist/lib/session.d.ts +4 -0
  42. package/dist/lib/session.js +56 -2
  43. package/dist/lib/signing.d.ts +52 -0
  44. package/dist/lib/signing.js +180 -0
  45. package/dist/lib/storageAdapter.d.ts +30 -0
  46. package/dist/lib/storageAdapter.js +1 -0
  47. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  48. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  49. package/dist/lib/tenant.js +2 -2
  50. package/dist/lib/upload.d.ts +35 -0
  51. package/dist/lib/upload.js +87 -0
  52. package/dist/lib/validate.js +2 -2
  53. package/dist/lib/ws.d.ts +1 -0
  54. package/dist/lib/ws.js +21 -0
  55. package/dist/lib/wsHeartbeat.d.ts +12 -0
  56. package/dist/lib/wsHeartbeat.js +57 -0
  57. package/dist/lib/wsMessages.d.ts +40 -0
  58. package/dist/lib/wsMessages.js +330 -0
  59. package/dist/lib/wsPresence.d.ts +25 -0
  60. package/dist/lib/wsPresence.js +99 -0
  61. package/dist/middleware/auditLog.d.ts +22 -0
  62. package/dist/middleware/auditLog.js +39 -0
  63. package/dist/middleware/cacheResponse.js +5 -1
  64. package/dist/middleware/csrf.js +10 -0
  65. package/dist/middleware/identify.js +57 -9
  66. package/dist/middleware/metrics.d.ts +9 -0
  67. package/dist/middleware/metrics.js +26 -0
  68. package/dist/middleware/requestId.d.ts +3 -0
  69. package/dist/middleware/requestId.js +7 -0
  70. package/dist/middleware/requestLogger.d.ts +38 -0
  71. package/dist/middleware/requestLogger.js +68 -0
  72. package/dist/middleware/requestSigning.d.ts +20 -0
  73. package/dist/middleware/requestSigning.js +99 -0
  74. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  75. package/dist/middleware/requireMfaSetup.js +36 -0
  76. package/dist/middleware/requireRole.d.ts +9 -3
  77. package/dist/middleware/requireRole.js +23 -36
  78. package/dist/middleware/upload.d.ts +5 -0
  79. package/dist/middleware/upload.js +27 -0
  80. package/dist/middleware/webhookAuth.d.ts +30 -0
  81. package/dist/middleware/webhookAuth.js +57 -0
  82. package/dist/models/AuditLog.d.ts +30 -0
  83. package/dist/models/AuditLog.js +39 -0
  84. package/dist/models/Group.d.ts +21 -0
  85. package/dist/models/Group.js +28 -0
  86. package/dist/models/GroupMembership.d.ts +21 -0
  87. package/dist/models/GroupMembership.js +25 -0
  88. package/dist/routes/auth.js +84 -6
  89. package/dist/routes/groups.d.ts +21 -0
  90. package/dist/routes/groups.js +346 -0
  91. package/dist/routes/jobs.js +47 -45
  92. package/dist/routes/metrics.d.ts +7 -0
  93. package/dist/routes/metrics.js +52 -0
  94. package/dist/routes/mfa.js +4 -0
  95. package/dist/routes/uploads.d.ts +2 -0
  96. package/dist/routes/uploads.js +135 -0
  97. package/dist/server.d.ts +26 -0
  98. package/dist/server.js +46 -3
  99. package/dist/ws/index.js +3 -0
  100. package/docs/sections/auth-flow/full.md +779 -634
  101. package/docs/sections/auth-flow/overview.md +2 -2
  102. package/docs/sections/auth-security-examples/full.md +365 -0
  103. package/docs/sections/authentication/full.md +130 -0
  104. package/docs/sections/authentication/overview.md +5 -0
  105. package/docs/sections/cli/full.md +13 -1
  106. package/docs/sections/configuration/full.md +17 -0
  107. package/docs/sections/configuration/overview.md +1 -0
  108. package/docs/sections/exports/full.md +34 -3
  109. package/docs/sections/logging/full.md +83 -0
  110. package/docs/sections/metrics/full.md +127 -0
  111. package/docs/sections/oauth/full.md +189 -189
  112. package/docs/sections/oauth/overview.md +1 -1
  113. package/docs/sections/pagination/full.md +93 -0
  114. package/docs/sections/roles/full.md +224 -135
  115. package/docs/sections/roles/overview.md +3 -1
  116. package/docs/sections/signing/full.md +203 -0
  117. package/docs/sections/uploads/full.md +199 -0
  118. package/docs/sections/versioning/full.md +85 -0
  119. package/docs/sections/webhook-auth/full.md +100 -0
  120. package/docs/sections/websocket/full.md +83 -0
  121. package/docs/sections/websocket-rooms/full.md +6 -1
  122. 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
+ };
@@ -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
+ };
@@ -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,2 @@
1
+ import type { PresignedUrlConfig } from "../app";
2
+ export declare const createUploadsRouter: (config: PresignedUrlConfig) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
@@ -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
+ };