@simple-auth-kit/cli 1.4.3 → 1.5.1

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 (134) hide show
  1. package/lib/copy.ts +1 -1
  2. package/package.json +1 -1
  3. package/registry/combos/express-drizzle/shared/src/{auth-core-error.middleware.ts → middleware/auth-core-error.middleware.ts} +17 -4
  4. package/registry/combos/express-drizzle/shared/src/{auth.middleware.ts → middleware/auth.middleware.ts} +23 -7
  5. package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/middleware}/response-envelope.middleware.ts +15 -3
  6. package/registry/combos/express-drizzle/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +32 -9
  7. package/registry/combos/express-drizzle/shared/src/repositories/password-reset.repository.ts +59 -0
  8. package/registry/combos/express-drizzle/shared/src/{session.repository.ts → repositories/session.repository.ts} +52 -14
  9. package/registry/combos/express-drizzle/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +20 -6
  10. package/registry/combos/express-drizzle/shared/src/request-context.ts +1 -1
  11. package/registry/combos/express-drizzle/shared/src/routers/auth.router.ts +395 -0
  12. package/registry/combos/express-drizzle/variants/base/src/create-auth-app.ts +52 -19
  13. package/registry/combos/express-drizzle/variants/base/src/{authz.middleware.ts → middleware/authz.middleware.ts} +15 -7
  14. package/registry/combos/express-drizzle/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +28 -10
  15. package/registry/combos/express-drizzle/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +239 -48
  16. package/registry/combos/express-drizzle/variants/base/src/routers/admin.router.ts +497 -0
  17. package/registry/combos/express-drizzle/variants/base/src/{auth.service.ts → services/auth.service.ts} +480 -109
  18. package/registry/combos/express-drizzle/variants/workspaces/src/create-auth-app.ts +69 -23
  19. package/registry/combos/express-drizzle/variants/workspaces/src/{authz.middleware.ts → middleware/authz.middleware.ts} +32 -12
  20. package/registry/combos/express-drizzle/variants/workspaces/src/openapi-workspace.ts +147 -28
  21. package/registry/combos/express-drizzle/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +30 -11
  22. package/registry/combos/express-drizzle/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +319 -70
  23. package/registry/combos/express-drizzle/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +127 -30
  24. package/registry/combos/express-drizzle/variants/workspaces/src/routers/admin.router.ts +534 -0
  25. package/registry/combos/express-drizzle/variants/workspaces/src/routers/workspace.router.ts +194 -0
  26. package/registry/combos/express-drizzle/variants/workspaces/src/{auth.service.ts → services/auth.service.ts} +544 -122
  27. package/registry/combos/express-prisma/shared/src/{auth-core-error.middleware.ts → middleware/auth-core-error.middleware.ts} +17 -4
  28. package/registry/combos/express-prisma/shared/src/{auth.middleware.ts → middleware/auth.middleware.ts} +19 -6
  29. package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/middleware}/response-envelope.middleware.ts +15 -3
  30. package/registry/combos/express-prisma/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +27 -9
  31. package/registry/combos/express-prisma/shared/src/repositories/password-reset.repository.ts +55 -0
  32. package/registry/combos/express-prisma/shared/src/{session.repository.ts → repositories/session.repository.ts} +30 -9
  33. package/registry/combos/express-prisma/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +11 -4
  34. package/registry/combos/express-prisma/shared/src/request-context.ts +1 -1
  35. package/registry/combos/express-prisma/shared/src/routers/auth.router.ts +398 -0
  36. package/registry/combos/express-prisma/variants/base/src/create-auth-app.ts +56 -21
  37. package/registry/combos/express-prisma/variants/base/src/{authz.middleware.ts → middleware/authz.middleware.ts} +15 -7
  38. package/registry/combos/express-prisma/variants/base/src/rbac.defaults.ts +66 -18
  39. package/registry/combos/express-prisma/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  40. package/registry/combos/express-prisma/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +199 -48
  41. package/registry/combos/express-prisma/variants/base/src/routers/admin.router.ts +508 -0
  42. package/registry/combos/express-prisma/variants/base/src/seed.ts +77 -19
  43. package/registry/combos/express-prisma/variants/base/src/{auth.service.ts → services/auth.service.ts} +454 -105
  44. package/registry/combos/express-prisma/variants/workspaces/src/create-auth-app.ts +80 -25
  45. package/registry/combos/express-prisma/variants/workspaces/src/{authz.middleware.ts → middleware/authz.middleware.ts} +32 -12
  46. package/registry/combos/express-prisma/variants/workspaces/src/openapi-workspace.ts +147 -28
  47. package/registry/combos/express-prisma/variants/workspaces/src/rbac.defaults.ts +72 -20
  48. package/registry/combos/express-prisma/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  49. package/registry/combos/express-prisma/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +272 -64
  50. package/registry/combos/express-prisma/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +86 -23
  51. package/registry/combos/express-prisma/variants/workspaces/src/routers/admin.router.ts +545 -0
  52. package/registry/combos/express-prisma/variants/workspaces/src/routers/workspace.router.ts +194 -0
  53. package/registry/combos/express-prisma/variants/workspaces/src/seed.ts +108 -28
  54. package/registry/combos/express-prisma/variants/workspaces/src/{auth.service.ts → services/auth.service.ts} +518 -119
  55. package/registry/combos/nestjs-drizzle/shared/src/{auth.controller.ts → controllers/auth.controller.ts} +6 -6
  56. package/registry/combos/nestjs-drizzle/shared/src/{auth-core-error.filter.ts → filters/auth-core-error.filter.ts} +29 -5
  57. package/registry/combos/nestjs-drizzle/shared/src/{ability.guard.ts → guards/ability.guard.ts} +20 -7
  58. package/registry/combos/nestjs-drizzle/shared/src/{auth.guard.ts → guards/auth.guard.ts} +19 -7
  59. package/registry/combos/{nestjs-prisma/shared/src → nestjs-drizzle/shared/src/interceptors}/response.interceptor.ts +10 -2
  60. package/registry/combos/nestjs-drizzle/shared/src/repositories/oauth.repository.ts +63 -0
  61. package/registry/combos/nestjs-drizzle/shared/src/repositories/password-reset.repository.ts +59 -0
  62. package/registry/combos/nestjs-drizzle/shared/src/{session.repository.ts → repositories/session.repository.ts} +52 -14
  63. package/registry/combos/nestjs-drizzle/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +21 -7
  64. package/registry/combos/nestjs-drizzle/shared/src/request-context.ts +1 -1
  65. package/registry/combos/nestjs-drizzle/test/prove-cycle.ts +1097 -307
  66. package/registry/combos/nestjs-drizzle/variants/base/src/auth.module.ts +44 -20
  67. package/registry/combos/nestjs-drizzle/variants/base/src/{admin.controller.ts → controllers/admin.controller.ts} +228 -62
  68. package/registry/combos/nestjs-drizzle/variants/base/src/{authz.guard.ts → guards/authz.guard.ts} +12 -5
  69. package/registry/combos/nestjs-drizzle/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +27 -10
  70. package/registry/combos/nestjs-drizzle/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +249 -51
  71. package/registry/combos/nestjs-drizzle/variants/base/src/{auth.service.ts → services/auth.service.ts} +499 -116
  72. package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.module.ts +48 -22
  73. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{admin.controller.ts → controllers/admin.controller.ts} +269 -70
  74. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{workspace.controller.ts → controllers/workspace.controller.ts} +93 -26
  75. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{authz.guard.ts → guards/authz.guard.ts} +26 -8
  76. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +29 -11
  77. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +323 -71
  78. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +131 -30
  79. package/registry/combos/nestjs-drizzle/variants/workspaces/src/services/auth.service.ts +988 -0
  80. package/registry/combos/nestjs-prisma/shared/src/{auth.controller.ts → controllers/auth.controller.ts} +6 -6
  81. package/registry/combos/nestjs-prisma/shared/src/{auth-core-error.filter.ts → filters/auth-core-error.filter.ts} +29 -5
  82. package/registry/combos/nestjs-prisma/shared/src/{ability.guard.ts → guards/ability.guard.ts} +20 -7
  83. package/registry/combos/nestjs-prisma/shared/src/{auth.guard.ts → guards/auth.guard.ts} +19 -7
  84. package/registry/combos/{nestjs-drizzle/shared/src → nestjs-prisma/shared/src/interceptors}/response.interceptor.ts +10 -2
  85. package/registry/combos/nestjs-prisma/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +25 -8
  86. package/registry/combos/nestjs-prisma/shared/src/repositories/password-reset.repository.ts +55 -0
  87. package/registry/combos/nestjs-prisma/shared/src/{session.repository.ts → repositories/session.repository.ts} +30 -9
  88. package/registry/combos/nestjs-prisma/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +11 -4
  89. package/registry/combos/nestjs-prisma/shared/src/request-context.ts +1 -1
  90. package/registry/combos/nestjs-prisma/test/prove-cycle.ts +1097 -307
  91. package/registry/combos/nestjs-prisma/variants/base/src/auth.module.ts +58 -27
  92. package/registry/combos/nestjs-prisma/variants/base/src/{admin.controller.ts → controllers/admin.controller.ts} +469 -110
  93. package/registry/combos/nestjs-prisma/variants/base/src/{audit-log.gateway.ts → gateways/audit-log.gateway.ts} +22 -6
  94. package/registry/combos/nestjs-prisma/variants/base/src/{authz.guard.ts → guards/authz.guard.ts} +12 -5
  95. package/registry/combos/nestjs-prisma/variants/base/src/rbac.defaults.ts +97 -26
  96. package/registry/combos/nestjs-prisma/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  97. package/registry/combos/nestjs-prisma/variants/base/src/{country.repository.ts → repositories/country.repository.ts} +98 -25
  98. package/registry/combos/nestjs-prisma/variants/base/src/{customer.repository.ts → repositories/customer.repository.ts} +106 -27
  99. package/registry/combos/nestjs-prisma/variants/base/src/{language.repository.ts → repositories/language.repository.ts} +93 -25
  100. package/registry/combos/nestjs-prisma/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +229 -55
  101. package/registry/combos/nestjs-prisma/variants/base/src/seed.ts +77 -19
  102. package/registry/combos/nestjs-prisma/variants/base/src/services/auth.service.ts +1064 -0
  103. package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.module.ts +54 -26
  104. package/registry/combos/nestjs-prisma/variants/workspaces/src/{admin.controller.ts → controllers/admin.controller.ts} +544 -128
  105. package/registry/combos/nestjs-prisma/variants/workspaces/src/{workspace.controller.ts → controllers/workspace.controller.ts} +93 -26
  106. package/registry/combos/nestjs-prisma/variants/workspaces/src/{authz.guard.ts → guards/authz.guard.ts} +26 -8
  107. package/registry/combos/nestjs-prisma/variants/workspaces/src/rbac.defaults.ts +103 -28
  108. package/registry/combos/nestjs-prisma/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  109. package/registry/combos/nestjs-prisma/variants/workspaces/src/{country.repository.ts → repositories/country.repository.ts} +127 -26
  110. package/registry/combos/nestjs-prisma/variants/workspaces/src/{customer.repository.ts → repositories/customer.repository.ts} +137 -28
  111. package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/language.repository.ts +243 -0
  112. package/registry/combos/nestjs-prisma/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +314 -74
  113. package/registry/combos/nestjs-prisma/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +94 -24
  114. package/registry/combos/nestjs-prisma/variants/workspaces/src/seed.ts +108 -28
  115. package/registry/combos/nestjs-prisma/variants/workspaces/src/services/auth.service.ts +961 -0
  116. package/registry.json +4 -4
  117. package/simple-auth-kit.ts +42 -11
  118. package/registry/combos/express-drizzle/shared/src/auth.router.ts +0 -266
  119. package/registry/combos/express-drizzle/shared/src/password-reset.repository.ts +0 -35
  120. package/registry/combos/express-drizzle/variants/base/src/admin.router.ts +0 -307
  121. package/registry/combos/express-drizzle/variants/workspaces/src/admin.router.ts +0 -323
  122. package/registry/combos/express-drizzle/variants/workspaces/src/workspace.router.ts +0 -123
  123. package/registry/combos/express-prisma/shared/src/auth.router.ts +0 -269
  124. package/registry/combos/express-prisma/shared/src/password-reset.repository.ts +0 -29
  125. package/registry/combos/express-prisma/variants/base/src/admin.router.ts +0 -314
  126. package/registry/combos/express-prisma/variants/workspaces/src/admin.router.ts +0 -330
  127. package/registry/combos/express-prisma/variants/workspaces/src/workspace.router.ts +0 -123
  128. package/registry/combos/nestjs-drizzle/shared/src/oauth.repository.ts +0 -36
  129. package/registry/combos/nestjs-drizzle/shared/src/password-reset.repository.ts +0 -29
  130. package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.service.ts +0 -553
  131. package/registry/combos/nestjs-prisma/shared/src/password-reset.repository.ts +0 -29
  132. package/registry/combos/nestjs-prisma/variants/base/src/auth.service.ts +0 -618
  133. package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.service.ts +0 -509
  134. package/registry/combos/nestjs-prisma/variants/workspaces/src/language.repository.ts +0 -143
@@ -0,0 +1,508 @@
1
+ import type { NextFunction, Request, RequestHandler, Response } from "express";
2
+ import { AuthService } from "../services/auth.service.js";
3
+ import { HttpError } from "../http-error.js";
4
+ import {
5
+ ability,
6
+ createTieredRouter,
7
+ type TierMiddleware,
8
+ } from "../route-tiers.js";
9
+ import "../request-context.js";
10
+
11
+ function requireString(value: unknown, field: string): string {
12
+ if (typeof value !== "string" || value.length === 0)
13
+ throw new HttpError(400, `${field} is required`);
14
+ return value;
15
+ }
16
+
17
+ const optionalString = (value: unknown): string | undefined =>
18
+ typeof value === "string" ? value : undefined;
19
+
20
+ export interface AdminRouterDeps extends TierMiddleware {
21
+ auth: AuthService;
22
+ }
23
+
24
+ /**
25
+ * Administration of the whole deployment. Authority is a permission, never a role name: the authz
26
+ * middleware resolves the caller's roles and permissions from the database on this request, builds
27
+ * the CASL ability from them, and each route's `ability(...)` tier checks its slugs against it.
28
+ * There is no role-based bypass — holding a role called "admin" that carries no permissions gets
29
+ * exactly the same 403 as holding no role at all, which is why `npm run seed` (which provisions the
30
+ * catalog and the roles that carry it) is a prerequisite for this router doing anything.
31
+ *
32
+ * Each route says what it demands; the rows say who is granted it. The slugs below are checked at
33
+ * compile time against `PERMISSION_CATALOG` in `rbac.defaults.ts`, while who holds them — and
34
+ * whether the permission is active at all — is edited through this very router and takes effect on
35
+ * the next request. `GET /auth/me` returns the caller's slugs, and a console rebuilds the same
36
+ * ability from them with the same function the middleware used.
37
+ *
38
+ * Replaces the reference combo's `AdminController`. Mounted under `/auth/admin` by
39
+ * create-auth-app.ts; `createTieredRouter` stands in for its
40
+ * `@UseGuards(AuthGuard, AuthzGuard, AbilityGuard)` — see route-tiers.ts for why the tier is a
41
+ * required argument rather than a decorator.
42
+ */
43
+ export function createAdminRouter(deps: AdminRouterDeps): RequestHandler {
44
+ const { auth, authentication, authorization } = deps;
45
+ const admin = createTieredRouter({ authentication, authorization });
46
+
47
+ admin.route(
48
+ "get",
49
+ "/users",
50
+ ability("users:read"),
51
+ async (req: Request, res: Response, next: NextFunction) => {
52
+ try {
53
+ const { search, page, limit } = req.query as Record<
54
+ string,
55
+ string | undefined
56
+ >;
57
+ res
58
+ .status(200)
59
+ .json(
60
+ await auth.listUsers({
61
+ search,
62
+ page: page ? Number(page) : undefined,
63
+ limit: limit ? Number(limit) : undefined,
64
+ }),
65
+ );
66
+ } catch (err) {
67
+ next(err);
68
+ }
69
+ },
70
+ );
71
+
72
+ // No invitation email — the account is usable immediately with the password given here.
73
+ admin.route(
74
+ "post",
75
+ "/users",
76
+ ability("users:manage"),
77
+ async (req: Request, res: Response, next: NextFunction) => {
78
+ try {
79
+ const body = req.body as Record<string, unknown>;
80
+ res.status(201).json(
81
+ await auth.createUser(
82
+ {
83
+ email: requireString(body.email, "email"),
84
+ password: requireString(body.password, "password"),
85
+ firstName: optionalString(body.firstName),
86
+ lastName: optionalString(body.lastName),
87
+ displayName: optionalString(body.displayName),
88
+ phone: optionalString(body.phone),
89
+ username: optionalString(body.username),
90
+ roles: Array.isArray(body.roles)
91
+ ? body.roles.filter(
92
+ (role): role is string => typeof role === "string",
93
+ )
94
+ : undefined,
95
+ },
96
+ req.auth!.sub,
97
+ ),
98
+ );
99
+ } catch (err) {
100
+ next(err);
101
+ }
102
+ },
103
+ );
104
+
105
+ admin.route(
106
+ "get",
107
+ "/users/:userId",
108
+ ability("users:read"),
109
+ async (req: Request, res: Response, next: NextFunction) => {
110
+ try {
111
+ res
112
+ .status(200)
113
+ .json(await auth.getUser(requireString(req.params.userId, "userId")));
114
+ } catch (err) {
115
+ next(err);
116
+ }
117
+ },
118
+ );
119
+
120
+ // Profile fields only — email is the login identifier and is not editable here.
121
+ admin.route(
122
+ "patch",
123
+ "/users/:userId",
124
+ ability("users:manage"),
125
+ async (req: Request, res: Response, next: NextFunction) => {
126
+ try {
127
+ const userId = requireString(req.params.userId, "userId");
128
+ const body = req.body as Record<string, unknown>;
129
+ res.status(200).json(
130
+ await auth.updateUser(
131
+ userId,
132
+ {
133
+ firstName:
134
+ body.firstName === null ? null : optionalString(body.firstName),
135
+ lastName:
136
+ body.lastName === null ? null : optionalString(body.lastName),
137
+ displayName:
138
+ body.displayName === null
139
+ ? null
140
+ : optionalString(body.displayName),
141
+ phone: body.phone === null ? null : optionalString(body.phone),
142
+ username:
143
+ body.username === null ? null : optionalString(body.username),
144
+ photo: body.photo === null ? null : optionalString(body.photo),
145
+ },
146
+ req.auth!.sub,
147
+ ),
148
+ );
149
+ } catch (err) {
150
+ next(err);
151
+ }
152
+ },
153
+ );
154
+
155
+ // Soft-delete: the row survives for audit purposes, stops appearing in listings, and can no
156
+ // longer authenticate.
157
+ admin.route(
158
+ "delete",
159
+ "/users/:userId",
160
+ ability("users:manage"),
161
+ async (req: Request, res: Response, next: NextFunction) => {
162
+ try {
163
+ const userId = requireString(req.params.userId, "userId");
164
+ if (userId === req.auth!.sub)
165
+ throw new HttpError(403, "cannot delete your own account");
166
+ await auth.deleteUser(
167
+ userId,
168
+ req.auth!.sub,
169
+ optionalString(
170
+ (req.body as Record<string, unknown> | undefined)?.reason,
171
+ ),
172
+ );
173
+ res.status(200).json({ ok: true });
174
+ } catch (err) {
175
+ next(err);
176
+ }
177
+ },
178
+ );
179
+
180
+ admin.route(
181
+ "get",
182
+ "/audit-log",
183
+ ability("audit-log:read"),
184
+ async (req: Request, res: Response, next: NextFunction) => {
185
+ try {
186
+ const { userId, action, since, until, page, limit } =
187
+ req.query as Record<string, string | undefined>;
188
+ res
189
+ .status(200)
190
+ .json(
191
+ await auth.listAuditLog({
192
+ userId,
193
+ action,
194
+ since,
195
+ until,
196
+ page: page ? Number(page) : undefined,
197
+ limit: limit ? Number(limit) : undefined,
198
+ }),
199
+ );
200
+ } catch (err) {
201
+ next(err);
202
+ }
203
+ },
204
+ );
205
+
206
+ admin.route(
207
+ "get",
208
+ "/permissions",
209
+ ability("permissions:read"),
210
+ async (_req: Request, res: Response, next: NextFunction) => {
211
+ try {
212
+ res.status(200).json(await auth.listPermissions());
213
+ } catch (err) {
214
+ next(err);
215
+ }
216
+ },
217
+ );
218
+
219
+ admin.route(
220
+ "post",
221
+ "/permissions",
222
+ ability("permissions:define"),
223
+ async (req: Request, res: Response, next: NextFunction) => {
224
+ try {
225
+ const body = req.body as Record<string, unknown>;
226
+ res.status(201).json(
227
+ await auth.definePermission(
228
+ {
229
+ slug: requireString(body.slug, "slug"),
230
+ name: optionalString(body.name),
231
+ displayName: optionalString(body.displayName),
232
+ description:
233
+ body.description === null
234
+ ? null
235
+ : optionalString(body.description),
236
+ group: optionalString(body.group),
237
+ groupOrder:
238
+ typeof body.groupOrder === "number"
239
+ ? body.groupOrder
240
+ : undefined,
241
+ order: typeof body.order === "number" ? body.order : undefined,
242
+ isActive:
243
+ typeof body.isActive === "boolean" ? body.isActive : undefined,
244
+ },
245
+ req.auth!.sub,
246
+ ),
247
+ );
248
+ } catch (err) {
249
+ next(err);
250
+ }
251
+ },
252
+ );
253
+
254
+ admin.route(
255
+ "get",
256
+ "/roles",
257
+ ability("roles:manage"),
258
+ async (_req: Request, res: Response, next: NextFunction) => {
259
+ try {
260
+ res.status(200).json(await auth.listRoles());
261
+ } catch (err) {
262
+ next(err);
263
+ }
264
+ },
265
+ );
266
+
267
+ admin.route(
268
+ "post",
269
+ "/roles",
270
+ ability("roles:manage"),
271
+ async (req: Request, res: Response, next: NextFunction) => {
272
+ try {
273
+ const body = req.body as Record<string, unknown>;
274
+ res.status(201).json(
275
+ await auth.createRole(
276
+ {
277
+ slug: requireString(body.slug, "slug"),
278
+ name: optionalString(body.name),
279
+ displayName: optionalString(body.displayName),
280
+ description: optionalString(body.description) ?? null,
281
+ },
282
+ req.auth!.sub,
283
+ ),
284
+ );
285
+ } catch (err) {
286
+ next(err);
287
+ }
288
+ },
289
+ );
290
+
291
+ admin.route(
292
+ "patch",
293
+ "/roles/:roleId",
294
+ ability("roles:manage"),
295
+ async (req: Request, res: Response, next: NextFunction) => {
296
+ try {
297
+ const roleId = requireString(req.params.roleId, "roleId");
298
+ const body = req.body as Record<string, unknown>;
299
+ res.status(200).json(
300
+ await auth.updateRole(
301
+ roleId,
302
+ {
303
+ name: optionalString(body.name),
304
+ displayName: optionalString(body.displayName),
305
+ description:
306
+ body.description === null
307
+ ? null
308
+ : optionalString(body.description),
309
+ isActive:
310
+ typeof body.isActive === "boolean" ? body.isActive : undefined,
311
+ },
312
+ req.auth!.sub,
313
+ ),
314
+ );
315
+ } catch (err) {
316
+ next(err);
317
+ }
318
+ },
319
+ );
320
+
321
+ // Soft-delete: existing assignments are left in place rather than cascade-deleted, and the role
322
+ // simply stops being resolved.
323
+ admin.route(
324
+ "delete",
325
+ "/roles/:roleId",
326
+ ability("roles:manage"),
327
+ async (req: Request, res: Response, next: NextFunction) => {
328
+ try {
329
+ const roleId = requireString(req.params.roleId, "roleId");
330
+ await auth.deleteRole(
331
+ roleId,
332
+ req.auth!.sub,
333
+ optionalString(
334
+ (req.body as Record<string, unknown> | undefined)?.reason,
335
+ ),
336
+ );
337
+ res.status(200).json({ ok: true });
338
+ } catch (err) {
339
+ next(err);
340
+ }
341
+ },
342
+ );
343
+
344
+ admin.route(
345
+ "post",
346
+ "/roles/:roleId/permissions",
347
+ ability("roles:manage"),
348
+ async (req: Request, res: Response, next: NextFunction) => {
349
+ try {
350
+ const body = req.body as Record<string, unknown>;
351
+ await auth.attachPermissionToRole(
352
+ requireString(req.params.roleId, "roleId"),
353
+ requireString(body.permission, "permission"),
354
+ req.auth!.sub,
355
+ );
356
+ res.status(201).json({ ok: true });
357
+ } catch (err) {
358
+ next(err);
359
+ }
360
+ },
361
+ );
362
+
363
+ admin.route(
364
+ "post",
365
+ "/users/:userId/roles",
366
+ ability("roles:assign"),
367
+ async (req: Request, res: Response, next: NextFunction) => {
368
+ try {
369
+ const body = req.body as Record<string, unknown>;
370
+ await auth.assignRole(
371
+ requireString(req.params.userId, "userId"),
372
+ requireString(body.role, "role"),
373
+ );
374
+ res.status(201).json({ ok: true });
375
+ } catch (err) {
376
+ next(err);
377
+ }
378
+ },
379
+ );
380
+
381
+ admin.route(
382
+ "post",
383
+ "/users/:userId/roles/:roleSlug/revoke",
384
+ ability("roles:assign"),
385
+ async (req: Request, res: Response, next: NextFunction) => {
386
+ try {
387
+ const userId = requireString(req.params.userId, "userId");
388
+ // Revoking your own `admin` would strip the very permission that let you call this,
389
+ // with no route back in. Assigning to yourself is fine — it can't lock anyone out.
390
+ if (userId === req.auth!.sub)
391
+ throw new HttpError(403, "cannot change your own roles");
392
+ await auth.revokeRole(
393
+ userId,
394
+ requireString(req.params.roleSlug, "roleSlug"),
395
+ );
396
+ res.status(201).json({ ok: true });
397
+ } catch (err) {
398
+ next(err);
399
+ }
400
+ },
401
+ );
402
+
403
+ admin.route(
404
+ "post",
405
+ "/users/:userId/permissions",
406
+ ability("permissions:grant"),
407
+ async (req: Request, res: Response, next: NextFunction) => {
408
+ try {
409
+ const body = req.body as Record<string, unknown>;
410
+ await auth.grantPermission(
411
+ requireString(req.params.userId, "userId"),
412
+ requireString(body.permission, "permission"),
413
+ req.auth!.sub,
414
+ );
415
+ res.status(201).json({ ok: true });
416
+ } catch (err) {
417
+ next(err);
418
+ }
419
+ },
420
+ );
421
+
422
+ admin.route(
423
+ "post",
424
+ "/users/:userId/permissions/:permissionSlug/revoke",
425
+ ability("permissions:grant"),
426
+ async (req: Request, res: Response, next: NextFunction) => {
427
+ try {
428
+ await auth.revokePermission(
429
+ requireString(req.params.userId, "userId"),
430
+ requireString(req.params.permissionSlug, "permissionSlug"),
431
+ );
432
+ res.status(201).json({ ok: true });
433
+ } catch (err) {
434
+ next(err);
435
+ }
436
+ },
437
+ );
438
+
439
+ admin.route(
440
+ "post",
441
+ "/users/:userId/block",
442
+ ability("users:block"),
443
+ async (req: Request, res: Response, next: NextFunction) => {
444
+ try {
445
+ const userId = requireString(req.params.userId, "userId");
446
+ if (userId === req.auth!.sub)
447
+ throw new HttpError(403, "cannot block your own account");
448
+ await auth.block(userId, { userId: req.auth!.sub, ip: req.ip });
449
+ res.status(201).json({ ok: true });
450
+ } catch (err) {
451
+ next(err);
452
+ }
453
+ },
454
+ );
455
+
456
+ admin.route(
457
+ "post",
458
+ "/users/:userId/unblock",
459
+ ability("users:block"),
460
+ async (req: Request, res: Response, next: NextFunction) => {
461
+ try {
462
+ await auth.unblock(requireString(req.params.userId, "userId"), {
463
+ userId: req.auth!.sub,
464
+ ip: req.ip,
465
+ });
466
+ res.status(201).json({ ok: true });
467
+ } catch (err) {
468
+ next(err);
469
+ }
470
+ },
471
+ );
472
+
473
+ admin.route(
474
+ "post",
475
+ "/users/:userId/deactivate",
476
+ ability("users:block"),
477
+ async (req: Request, res: Response, next: NextFunction) => {
478
+ try {
479
+ const userId = requireString(req.params.userId, "userId");
480
+ if (userId === req.auth!.sub)
481
+ throw new HttpError(403, "cannot deactivate your own account");
482
+ await auth.deactivate(userId, { userId: req.auth!.sub, ip: req.ip });
483
+ res.status(201).json({ ok: true });
484
+ } catch (err) {
485
+ next(err);
486
+ }
487
+ },
488
+ );
489
+
490
+ admin.route(
491
+ "post",
492
+ "/users/:userId/activate",
493
+ ability("users:block"),
494
+ async (req: Request, res: Response, next: NextFunction) => {
495
+ try {
496
+ await auth.activate(requireString(req.params.userId, "userId"), {
497
+ userId: req.auth!.sub,
498
+ ip: req.ip,
499
+ });
500
+ res.status(201).json({ ok: true });
501
+ } catch (err) {
502
+ next(err);
503
+ }
504
+ },
505
+ );
506
+
507
+ return admin.handler;
508
+ }
@@ -23,11 +23,20 @@
23
23
  import { PrismaPg } from "@prisma/adapter-pg";
24
24
  import { hashPassword } from "@/lib/auth/core/crypto.js";
25
25
  import { PrismaClient } from "../generated/prisma/client.js";
26
- import { DEFAULT_ROLES, PERMISSION_SLUGS, provisionDefaultRoles, SEED_ADMIN_ROLES, SEED_SUPERADMIN_ROLES } from "./rbac.defaults.js";
26
+ import {
27
+ DEFAULT_ROLES,
28
+ PERMISSION_SLUGS,
29
+ provisionDefaultRoles,
30
+ SEED_ADMIN_ROLES,
31
+ SEED_SUPERADMIN_ROLES,
32
+ } from "./rbac.defaults.js";
27
33
 
28
34
  function requireEnv(name: string): string {
29
35
  const value = process.env[name];
30
- if (!value) throw new Error(`${name} is not set — the seeder cannot reach the database without it`);
36
+ if (!value)
37
+ throw new Error(
38
+ `${name} is not set — the seeder cannot reach the database without it`,
39
+ );
31
40
  return value;
32
41
  }
33
42
 
@@ -35,17 +44,30 @@ function requireEnv(name: string): string {
35
44
  async function seedRbacDefaults(prisma: PrismaClient): Promise<void> {
36
45
  await provisionDefaultRoles(prisma);
37
46
  console.log(`permissions: ${PERMISSION_SLUGS.length} slug(s) in the catalog`);
38
- for (const role of DEFAULT_ROLES) console.log(`role "${role.slug}": ${role.permissions.length} permission(s)${role.isDefault ? " (signup default)" : ""}`);
47
+ for (const role of DEFAULT_ROLES)
48
+ console.log(
49
+ `role "${role.slug}": ${role.permissions.length} permission(s)${role.isDefault ? " (signup default)" : ""}`,
50
+ );
39
51
 
40
52
  // Slugs that exist in the database but not in this build's catalog. They are not an error —
41
53
  // `POST /auth/admin/permissions` exists so a deployment can define its own, and a grant can
42
54
  // create one — but a slug no route names grants nothing, so a typo would otherwise be a
43
55
  // permission that mysteriously never works. Printing them is what makes it visible.
44
- const rows = await prisma.permission.findMany({ select: { slug: true, isActive: true } });
45
- const unknown = rows.filter((row) => !(PERMISSION_SLUGS as string[]).includes(row.slug));
46
- if (unknown.length) console.log(`permissions: ${unknown.length} slug(s) outside this build's catalog (no route names them): ${unknown.map((r) => r.slug).join(", ")}`);
56
+ const rows = await prisma.permission.findMany({
57
+ select: { slug: true, isActive: true },
58
+ });
59
+ const unknown = rows.filter(
60
+ (row) => !(PERMISSION_SLUGS as string[]).includes(row.slug),
61
+ );
62
+ if (unknown.length)
63
+ console.log(
64
+ `permissions: ${unknown.length} slug(s) outside this build's catalog (no route names them): ${unknown.map((r) => r.slug).join(", ")}`,
65
+ );
47
66
  const inactive = rows.filter((row) => !row.isActive);
48
- if (inactive.length) console.log(`permissions: ${inactive.length} deactivated, granting nothing: ${inactive.map((r) => r.slug).join(", ")}`);
67
+ if (inactive.length)
68
+ console.log(
69
+ `permissions: ${inactive.length} deactivated, granting nothing: ${inactive.map((r) => r.slug).join(", ")}`,
70
+ );
49
71
  }
50
72
 
51
73
  /** Roles are global here, so administrative authority is a property of the user row itself. */
@@ -53,18 +75,34 @@ async function seedAdminUser(prisma: PrismaClient): Promise<void> {
53
75
  const email = process.env["SEED_ADMIN_EMAIL"];
54
76
  const password = process.env["SEED_ADMIN_PASSWORD"];
55
77
  if (!email || !password) {
56
- console.log("admin: skipped — set SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD to create one (there is no default password)");
78
+ console.log(
79
+ "admin: skipped — set SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD to create one (there is no default password)",
80
+ );
57
81
  return;
58
82
  }
59
83
 
60
84
  // Their password may have been changed since; rewriting it here would silently reset it.
61
85
  const existing = await prisma.user.findUnique({ where: { email } });
62
86
  // Hashed by the same function signup uses, so the seeded admin can actually log in.
63
- const user = existing ?? (await prisma.user.create({ data: { email, passwordHash: await hashPassword(password) } }));
64
- console.log(existing ? `admin: ${email} already exists — password left unchanged` : `admin: created ${email}`);
87
+ const user =
88
+ existing ??
89
+ (await prisma.user.create({
90
+ data: { email, passwordHash: await hashPassword(password) },
91
+ }));
92
+ console.log(
93
+ existing
94
+ ? `admin: ${email} already exists — password left unchanged`
95
+ : `admin: created ${email}`,
96
+ );
65
97
 
66
- const roles = await prisma.role.findMany({ where: { slug: { in: SEED_ADMIN_ROLES } }, select: { id: true, slug: true } });
67
- await prisma.roleUser.createMany({ data: roles.map((role) => ({ userId: user.id, roleId: role.id })), skipDuplicates: true });
98
+ const roles = await prisma.role.findMany({
99
+ where: { slug: { in: SEED_ADMIN_ROLES } },
100
+ select: { id: true, slug: true },
101
+ });
102
+ await prisma.roleUser.createMany({
103
+ data: roles.map((role) => ({ userId: user.id, roleId: role.id })),
104
+ skipDuplicates: true,
105
+ });
68
106
  console.log(`admin: holds roles ${roles.map((r) => r.slug).join(", ")}`);
69
107
  }
70
108
 
@@ -73,7 +111,9 @@ async function seedSuperAdminUser(prisma: PrismaClient): Promise<void> {
73
111
  const email = process.env["SEED_SUPERADMIN_EMAIL"];
74
112
  const password = process.env["SEED_SUPERADMIN_PASSWORD"];
75
113
  if (!email || !password) {
76
- console.log("super admin: skipped — set SEED_SUPERADMIN_EMAIL and SEED_SUPERADMIN_PASSWORD to create one (there is no default password)");
114
+ console.log(
115
+ "super admin: skipped — set SEED_SUPERADMIN_EMAIL and SEED_SUPERADMIN_PASSWORD to create one (there is no default password)",
116
+ );
77
117
  return;
78
118
  }
79
119
  const username = process.env["SEED_SUPERADMIN_USERNAME"] || undefined;
@@ -81,12 +121,28 @@ async function seedSuperAdminUser(prisma: PrismaClient): Promise<void> {
81
121
  // Their password may have been changed since; rewriting it here would silently reset it.
82
122
  const existing = await prisma.user.findUnique({ where: { email } });
83
123
  // Hashed by the same function signup uses, so the seeded super admin can actually log in.
84
- const user = existing ?? (await prisma.user.create({ data: { email, username, passwordHash: await hashPassword(password) } }));
85
- console.log(existing ? `super admin: ${email} already exists — password left unchanged` : `super admin: created ${email}${username ? ` (username ${username})` : ""}`);
124
+ const user =
125
+ existing ??
126
+ (await prisma.user.create({
127
+ data: { email, username, passwordHash: await hashPassword(password) },
128
+ }));
129
+ console.log(
130
+ existing
131
+ ? `super admin: ${email} already exists — password left unchanged`
132
+ : `super admin: created ${email}${username ? ` (username ${username})` : ""}`,
133
+ );
86
134
 
87
- const roles = await prisma.role.findMany({ where: { slug: { in: SEED_SUPERADMIN_ROLES } }, select: { id: true, slug: true } });
88
- await prisma.roleUser.createMany({ data: roles.map((role) => ({ userId: user.id, roleId: role.id })), skipDuplicates: true });
89
- console.log(`super admin: holds roles ${roles.map((r) => r.slug).join(", ")}`);
135
+ const roles = await prisma.role.findMany({
136
+ where: { slug: { in: SEED_SUPERADMIN_ROLES } },
137
+ select: { id: true, slug: true },
138
+ });
139
+ await prisma.roleUser.createMany({
140
+ data: roles.map((role) => ({ userId: user.id, roleId: role.id })),
141
+ skipDuplicates: true,
142
+ });
143
+ console.log(
144
+ `super admin: holds roles ${roles.map((r) => r.slug).join(", ")}`,
145
+ );
90
146
  }
91
147
 
92
148
  async function main(): Promise<void> {
@@ -98,7 +154,9 @@ async function main(): Promise<void> {
98
154
  // No .env here — the environment is expected to carry the variables itself (docker, CI, ...).
99
155
  }
100
156
 
101
- const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: requireEnv("DATABASE_URL") }) });
157
+ const prisma = new PrismaClient({
158
+ adapter: new PrismaPg({ connectionString: requireEnv("DATABASE_URL") }),
159
+ });
102
160
  try {
103
161
  await seedRbacDefaults(prisma);
104
162
  await seedAdminUser(prisma);