@simple-auth-kit/cli 1.3.2 → 1.4.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 (80) hide show
  1. package/lib/copy.ts +67 -10
  2. package/package.json +1 -1
  3. package/registry/admin-apps/nextjs/shared/.husky/commit-msg +1 -0
  4. package/registry/admin-apps/nextjs/shared/.husky/pre-commit +1 -0
  5. package/registry/admin-apps/nextjs/shared/.lintstagedrc.json +4 -0
  6. package/registry/admin-apps/nextjs/shared/.prettierrc.json +3 -0
  7. package/registry/admin-apps/nextjs/shared/commitlint.config.js +5 -0
  8. package/registry/admin-apps/nextjs/shared/eslint.config.js +28 -0
  9. package/registry/admin-apps/nextjs/shared/gitignore +11 -0
  10. package/registry/admin-apps/nextjs/variants/base/package.json +11 -2
  11. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/audit-log/page.tsx +72 -16
  12. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/customers/[id]/edit/page.tsx +107 -26
  13. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/customers/[id]/page.tsx +131 -29
  14. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/languages/[id]/edit/page.tsx +111 -25
  15. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/languages/[id]/page.tsx +97 -22
  16. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/permissions/[id]/edit/page.tsx +129 -46
  17. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/permissions/[id]/page.tsx +64 -14
  18. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/roles/[id]/page.tsx +91 -22
  19. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/users/[id]/edit/page.tsx +94 -23
  20. package/registry/admin-apps/nextjs/variants/workspaces/package.json +11 -2
  21. package/registry/admin-apps/nextjs/variants/workspaces/src/app/(console)/account/page.tsx +179 -41
  22. package/registry/admin-apps/react/shared/.husky/commit-msg +1 -0
  23. package/registry/admin-apps/react/shared/.husky/pre-commit +1 -0
  24. package/registry/admin-apps/react/shared/.lintstagedrc.json +4 -0
  25. package/registry/admin-apps/react/shared/.prettierrc.json +3 -0
  26. package/registry/admin-apps/react/shared/commitlint.config.js +3 -0
  27. package/registry/admin-apps/react/shared/eslint.config.js +38 -0
  28. package/registry/admin-apps/react/shared/gitignore +17 -0
  29. package/registry/admin-apps/react/variants/base/package.json +15 -2
  30. package/registry/admin-apps/react/variants/base/src/pages/CountryDetailPage.tsx +76 -19
  31. package/registry/admin-apps/react/variants/base/src/pages/CustomerDetailPage.tsx +110 -26
  32. package/registry/admin-apps/react/variants/base/src/pages/EditCountryPage.tsx +115 -29
  33. package/registry/admin-apps/react/variants/base/src/pages/EditCustomerPage.tsx +123 -31
  34. package/registry/admin-apps/react/variants/base/src/pages/EditLanguagePage.tsx +100 -24
  35. package/registry/admin-apps/react/variants/base/src/pages/EditRolePage.tsx +99 -26
  36. package/registry/admin-apps/react/variants/base/src/pages/EditUserPage.tsx +126 -31
  37. package/registry/admin-apps/react/variants/base/src/pages/LanguageDetailPage.tsx +75 -19
  38. package/registry/admin-apps/react/variants/base/src/pages/PermissionDetailPage.tsx +58 -13
  39. package/registry/admin-apps/react/variants/base/src/pages/PermissionsPage.tsx +132 -29
  40. package/registry/admin-apps/react/variants/base/src/pages/RoleDetailPage.tsx +70 -17
  41. package/registry/admin-apps/react/variants/base/src/pages/RolesPage.tsx +277 -59
  42. package/registry/admin-apps/react/variants/workspaces/package.json +15 -2
  43. package/registry/combos/express-drizzle/eslint.config.js +18 -0
  44. package/registry/combos/express-drizzle/package.json +5 -1
  45. package/registry/combos/express-drizzle/shared/src/request-context.ts +3 -0
  46. package/registry/combos/express-prisma/eslint.config.js +18 -0
  47. package/registry/combos/express-prisma/package.json +5 -1
  48. package/registry/combos/express-prisma/shared/src/request-context.ts +3 -0
  49. package/registry/combos/nestjs-drizzle/eslint.config.js +18 -0
  50. package/registry/combos/nestjs-drizzle/package.json +5 -1
  51. package/registry/combos/nestjs-drizzle/shared/src/auth.controller.ts +138 -33
  52. package/registry/combos/nestjs-drizzle/shared/src/route-tiers.ts +71 -18
  53. package/registry/combos/nestjs-prisma/eslint.config.js +18 -0
  54. package/registry/combos/nestjs-prisma/package.json +5 -1
  55. package/registry/combos/nestjs-prisma/shared/src/auth.controller.ts +148 -35
  56. package/registry/combos/nestjs-prisma/shared/src/route-tiers.ts +72 -18
  57. package/registry/mobile-apps/bare-rn/shared/.husky/commit-msg +1 -0
  58. package/registry/mobile-apps/bare-rn/shared/.husky/pre-commit +1 -0
  59. package/registry/mobile-apps/bare-rn/shared/.lintstagedrc.json +4 -0
  60. package/registry/mobile-apps/bare-rn/shared/.prettierrc.json +5 -0
  61. package/registry/mobile-apps/bare-rn/shared/commitlint.config.js +3 -0
  62. package/registry/mobile-apps/bare-rn/shared/eslint.config.js +19 -0
  63. package/registry/mobile-apps/bare-rn/shared/gitignore +78 -0
  64. package/registry/mobile-apps/bare-rn/variants/base/package.json +17 -12
  65. package/registry/mobile-apps/bare-rn/variants/workspaces/package.json +17 -12
  66. package/registry/mobile-apps/expo/shared/.husky/commit-msg +1 -0
  67. package/registry/mobile-apps/expo/shared/.husky/pre-commit +1 -0
  68. package/registry/mobile-apps/expo/shared/.lintstagedrc.json +4 -0
  69. package/registry/mobile-apps/expo/shared/.prettierrc.json +5 -0
  70. package/registry/mobile-apps/expo/shared/commitlint.config.js +3 -0
  71. package/registry/mobile-apps/expo/shared/eslint.config.js +9 -0
  72. package/registry/mobile-apps/expo/shared/gitignore +44 -0
  73. package/registry/mobile-apps/expo/shared/src/screens/SessionsScreen.tsx +6 -17
  74. package/registry/mobile-apps/expo/shared/src/store/sessionsStore.ts +35 -0
  75. package/registry/mobile-apps/expo/variants/base/package.json +12 -3
  76. package/registry/mobile-apps/expo/variants/workspaces/package.json +12 -3
  77. package/registry/mobile-apps/expo/variants/workspaces/src/screens/WorkspaceSelectScreen.tsx +1 -1
  78. package/registry.json +13 -36
  79. package/registry/mobile-apps/bare-rn/shared/.eslintrc.js +0 -4
  80. package/registry/mobile-apps/bare-rn/shared/.prettierrc.js +0 -5
@@ -7,6 +7,7 @@
7
7
  "scripts": {
8
8
  "materialize": "node scripts/materialize.mjs",
9
9
  "typecheck": "node scripts/materialize.mjs --all && tsc -p .variant/base && tsc -p .variant/workspaces",
10
+ "lint": "eslint shared variants",
10
11
  "prove-cycle": "node scripts/prove-cycle.mjs",
11
12
  "migrate": "node scripts/migrate.mjs",
12
13
  "seed": "node scripts/seed.mjs"
@@ -19,14 +20,17 @@
19
20
  "swagger-ui-express": "^5.0.1"
20
21
  },
21
22
  "devDependencies": {
23
+ "@eslint/js": "^9.39.5",
22
24
  "@types/express": "^5.0.6",
23
25
  "@types/node": "^26.2.0",
24
26
  "@types/pg": "^8.21.0",
25
27
  "@types/swagger-ui-express": "^4.1.8",
26
28
  "dotenv": "^17.4.2",
27
29
  "drizzle-kit": "^0.31.10",
30
+ "eslint": "^9.39.5",
28
31
  "tsx": "^4.23.11",
29
- "typescript": "^7.0.2",
32
+ "typescript": "^6.0.3",
33
+ "typescript-eslint": "^8.70.0",
30
34
  "vitest": "^4.1.10"
31
35
  }
32
36
  }
@@ -23,6 +23,9 @@ import type { AppAbility } from "./ability.js";
23
23
  import type { AuthzContext } from "./authz.middleware.js";
24
24
 
25
25
  declare global {
26
+ // Express's own declaration-merging style for augmenting its Request type requires a
27
+ // `namespace` here; there's no ES module form.
28
+ // eslint-disable-next-line @typescript-eslint/no-namespace
26
29
  namespace Express {
27
30
  interface Request {
28
31
  auth?: AccessTokenClaims;
@@ -0,0 +1,18 @@
1
+ import js from "@eslint/js";
2
+ import tseslint from "typescript-eslint";
3
+
4
+ export default tseslint.config(
5
+ { ignores: [".variant/**", "node_modules/**", "dist/**", "generated/**"] },
6
+ js.configs.recommended,
7
+ ...tseslint.configs.recommended,
8
+ {
9
+ rules: {
10
+ // This combo's leading-underscore convention (e.g. an interface-required parameter that's
11
+ // deliberately unused) predates this eslint config — respect it rather than flag it.
12
+ "@typescript-eslint/no-unused-vars": [
13
+ "error",
14
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
15
+ ],
16
+ },
17
+ },
18
+ );
@@ -8,6 +8,7 @@
8
8
  "materialize": "node scripts/materialize.mjs",
9
9
  "prisma:generate": "node scripts/materialize.mjs --all --generate",
10
10
  "typecheck": "npm run prisma:generate && tsc -p .variant/base && tsc -p .variant/workspaces",
11
+ "lint": "eslint shared variants",
11
12
  "prove-cycle": "node scripts/prove-cycle.mjs",
12
13
  "migrate": "node scripts/migrate.mjs",
13
14
  "seed": "node scripts/seed.mjs"
@@ -20,13 +21,16 @@
20
21
  "swagger-ui-express": "^5.0.1"
21
22
  },
22
23
  "devDependencies": {
24
+ "@eslint/js": "^9.39.5",
23
25
  "@types/express": "^5.0.6",
24
26
  "@types/node": "^26.2.0",
25
27
  "@types/swagger-ui-express": "^4.1.8",
26
28
  "dotenv": "^17.4.2",
29
+ "eslint": "^9.39.5",
27
30
  "prisma": "^7.9.1",
28
31
  "tsx": "^4.23.11",
29
- "typescript": "^7.0.2",
32
+ "typescript": "^6.0.3",
33
+ "typescript-eslint": "^8.70.0",
30
34
  "vitest": "^4.1.10"
31
35
  }
32
36
  }
@@ -23,6 +23,9 @@ import type { AppAbility } from "./ability.js";
23
23
  import type { AuthzContext } from "./authz.middleware.js";
24
24
 
25
25
  declare global {
26
+ // Express's own declaration-merging style for augmenting its Request type requires a
27
+ // `namespace` here; there's no ES module form.
28
+ // eslint-disable-next-line @typescript-eslint/no-namespace
26
29
  namespace Express {
27
30
  interface Request {
28
31
  auth?: AccessTokenClaims;
@@ -0,0 +1,18 @@
1
+ import js from "@eslint/js";
2
+ import tseslint from "typescript-eslint";
3
+
4
+ export default tseslint.config(
5
+ { ignores: [".variant/**", "node_modules/**", "dist/**", "generated/**"] },
6
+ js.configs.recommended,
7
+ ...tseslint.configs.recommended,
8
+ {
9
+ rules: {
10
+ // This combo's leading-underscore convention (e.g. an interface-required parameter that's
11
+ // deliberately unused) predates this eslint config — respect it rather than flag it.
12
+ "@typescript-eslint/no-unused-vars": [
13
+ "error",
14
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
15
+ ],
16
+ },
17
+ },
18
+ );
@@ -7,6 +7,7 @@
7
7
  "scripts": {
8
8
  "materialize": "node scripts/materialize.mjs",
9
9
  "typecheck": "node scripts/materialize.mjs --all && tsc -p .variant/base && tsc -p .variant/workspaces",
10
+ "lint": "eslint shared variants",
10
11
  "prove-cycle": "node scripts/prove-cycle.mjs",
11
12
  "migrate": "node scripts/migrate.mjs",
12
13
  "seed": "node scripts/seed.mjs"
@@ -24,13 +25,16 @@
24
25
  "rxjs": "^7.8.2"
25
26
  },
26
27
  "devDependencies": {
28
+ "@eslint/js": "^9.39.5",
27
29
  "@types/express": "^5.0.6",
28
30
  "@types/node": "^26.2.0",
29
31
  "@types/pg": "^8.21.0",
30
32
  "dotenv": "^17.4.2",
31
33
  "drizzle-kit": "^0.31.10",
34
+ "eslint": "^9.39.5",
32
35
  "tsx": "^4.23.11",
33
- "typescript": "^7.0.2",
36
+ "typescript": "^6.0.3",
37
+ "typescript-eslint": "^8.70.0",
34
38
  "vitest": "^4.1.10"
35
39
  }
36
40
  }
@@ -1,5 +1,26 @@
1
- import { BadRequestException, Body, Controller, Get, Inject, Ip, Param, Patch, Post, Query, Req, UseGuards } from "@nestjs/common";
2
- import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger";
1
+ import {
2
+ BadRequestException,
3
+ Body,
4
+ Controller,
5
+ Get,
6
+ Inject,
7
+ Ip,
8
+ Param,
9
+ Patch,
10
+ Post,
11
+ Query,
12
+ Req,
13
+ UseGuards,
14
+ } from "@nestjs/common";
15
+ import {
16
+ ApiBearerAuth,
17
+ ApiBody,
18
+ ApiOperation,
19
+ ApiParam,
20
+ ApiQuery,
21
+ ApiResponse,
22
+ ApiTags,
23
+ } from "@nestjs/swagger";
3
24
  import type { Request } from "express";
4
25
  import { AUTH_CONFIG, AuthConfig } from "./auth.config.js";
5
26
  import { AuthGuard } from "./auth.guard.js";
@@ -23,17 +44,18 @@ import {
23
44
  SelfProfileDto,
24
45
  SessionSummaryDto,
25
46
  SignupDto,
26
- TwoFactorChallengeDto,
27
47
  TwoFactorCodeDto,
28
48
  UpdateUserDto,
29
49
  } from "./dto/auth.dto.js";
30
50
 
31
51
  function requireString(value: unknown, field: string): string {
32
- if (typeof value !== "string" || value.length === 0) throw new BadRequestException(`${field} is required`);
52
+ if (typeof value !== "string" || value.length === 0)
53
+ throw new BadRequestException(`${field} is required`);
33
54
  return value;
34
55
  }
35
56
 
36
- const optionalString = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
57
+ const optionalString = (value: unknown): string | undefined =>
58
+ typeof value === "string" ? value : undefined;
37
59
 
38
60
  /**
39
61
  * Identity endpoints — everything that is about *a user*, never about a group of them.
@@ -52,7 +74,11 @@ export class AuthController {
52
74
  @ApiOperation({ summary: "Create a user and issue a session" })
53
75
  @ApiBody({ type: SignupDto })
54
76
  @ApiResponse({ status: 201, type: AuthTokensDto })
55
- async signup(@Body() body: Record<string, unknown>, @Req() req: Request, @Ip() ip: string) {
77
+ async signup(
78
+ @Body() body: Record<string, unknown>,
79
+ @Req() req: Request,
80
+ @Ip() ip: string,
81
+ ) {
56
82
  return this.auth.signup({
57
83
  email: requireString(body.email, "email"),
58
84
  password: requireString(body.password, "password"),
@@ -70,11 +96,24 @@ export class AuthController {
70
96
  @Public()
71
97
  @ApiOperation({
72
98
  summary: "Log in with email, username, or phone + password",
73
- description: "identifier is matched against email, username, and phone, in that order. Returns tokens directly, or a 2FA challenge if the account has 2FA enabled.",
99
+ description:
100
+ "identifier is matched against email, username, and phone, in that order. Returns tokens directly, or a 2FA challenge if the account has 2FA enabled.",
74
101
  })
75
102
  @ApiBody({ type: LoginDto })
76
- @ApiResponse({ status: 201, schema: { oneOf: [{ $ref: "#/components/schemas/AuthTokensDto" }, { $ref: "#/components/schemas/TwoFactorChallengeDto" }] } })
77
- async login(@Body() body: Record<string, unknown>, @Req() req: Request, @Ip() ip: string) {
103
+ @ApiResponse({
104
+ status: 201,
105
+ schema: {
106
+ oneOf: [
107
+ { $ref: "#/components/schemas/AuthTokensDto" },
108
+ { $ref: "#/components/schemas/TwoFactorChallengeDto" },
109
+ ],
110
+ },
111
+ })
112
+ async login(
113
+ @Body() body: Record<string, unknown>,
114
+ @Req() req: Request,
115
+ @Ip() ip: string,
116
+ ) {
78
117
  return this.auth.login({
79
118
  identifier: requireString(body.identifier, "identifier"),
80
119
  password: requireString(body.password, "password"),
@@ -88,7 +127,11 @@ export class AuthController {
88
127
  @ApiOperation({ summary: "Complete the 2FA challenge from POST /auth/login" })
89
128
  @ApiBody({ type: LoginTwoFactorDto })
90
129
  @ApiResponse({ status: 201, type: AuthTokensDto })
91
- async loginTwoFactor(@Body() body: Record<string, unknown>, @Req() req: Request, @Ip() ip: string) {
130
+ async loginTwoFactor(
131
+ @Body() body: Record<string, unknown>,
132
+ @Req() req: Request,
133
+ @Ip() ip: string,
134
+ ) {
92
135
  return this.auth.loginTwoFactor({
93
136
  challengeToken: requireString(body.challengeToken, "challengeToken"),
94
137
  code: requireString(body.code, "code"),
@@ -99,7 +142,9 @@ export class AuthController {
99
142
 
100
143
  @Post("refresh")
101
144
  @Public()
102
- @ApiOperation({ summary: "Rotate a refresh token for a new access+refresh pair" })
145
+ @ApiOperation({
146
+ summary: "Rotate a refresh token for a new access+refresh pair",
147
+ })
103
148
  @ApiBody({ type: RefreshDto })
104
149
  @ApiResponse({ status: 201, type: RefreshResponseDto })
105
150
  async refresh(@Body() body: Record<string, unknown>) {
@@ -121,7 +166,10 @@ export class AuthController {
121
166
  @ApiResponse({ status: 200, type: CurrentUserDto })
122
167
  async me(@Req() req: Request) {
123
168
  const { sub, sessionId } = req.auth!;
124
- const [{ twoFactorEnabled }, profile] = await Promise.all([this.auth.getTwoFactorStatus(sub), this.auth.getProfile(sub)]);
169
+ const [{ twoFactorEnabled }, profile] = await Promise.all([
170
+ this.auth.getTwoFactorStatus(sub),
171
+ this.auth.getProfile(sub),
172
+ ]);
125
173
  // The same `req.authz` AbilityGuard's ability was built from — not re-resolved here.
126
174
  return {
127
175
  sub,
@@ -143,7 +191,9 @@ export class AuthController {
143
191
  @Authenticated()
144
192
  @ApiBearerAuth()
145
193
  @UseGuards(AuthGuard)
146
- @ApiOperation({ summary: "List the current user's active (non-revoked) sessions" })
194
+ @ApiOperation({
195
+ summary: "List the current user's active (non-revoked) sessions",
196
+ })
147
197
  @ApiResponse({ status: 200, type: [SessionSummaryDto] })
148
198
  async sessions(@Req() req: Request) {
149
199
  return this.auth.listActiveSessions(req.auth!.sub);
@@ -159,7 +209,10 @@ export class AuthController {
159
209
  const { sessionId, jti, sub } = req.auth!;
160
210
  // The revoker is the caller themselves. Recorded so the row can later be told apart from a
161
211
  // session an administrator ended — see `sessions.revoked_by`.
162
- await this.auth.logout(sessionId, jti, this.config.accessTokenTtlSeconds, { userId: sub, ip });
212
+ await this.auth.logout(sessionId, jti, this.config.accessTokenTtlSeconds, {
213
+ userId: sub,
214
+ ip,
215
+ });
163
216
  return { ok: true };
164
217
  }
165
218
 
@@ -181,7 +234,10 @@ export class AuthController {
181
234
  @ApiOperation({ summary: "Revoke every session except the current one" })
182
235
  @ApiResponse({ status: 201, type: OkResponseDto })
183
236
  async logoutOthers(@Req() req: Request, @Ip() ip: string) {
184
- await this.auth.logoutOthers(req.auth!.sub, req.auth!.sessionId, { userId: req.auth!.sub, ip });
237
+ await this.auth.logoutOthers(req.auth!.sub, req.auth!.sessionId, {
238
+ userId: req.auth!.sub,
239
+ ip,
240
+ });
185
241
  return { ok: true };
186
242
  }
187
243
 
@@ -191,11 +247,15 @@ export class AuthController {
191
247
  @UseGuards(AuthGuard)
192
248
  @ApiOperation({
193
249
  summary: "Change the current password",
194
- description: "Requires the current password. Every other session is revoked; the one making this call is left alone.",
250
+ description:
251
+ "Requires the current password. Every other session is revoked; the one making this call is left alone.",
195
252
  })
196
253
  @ApiBody({ type: ChangePasswordDto })
197
254
  @ApiResponse({ status: 201, type: OkResponseDto })
198
- async changePassword(@Body() body: Record<string, unknown>, @Req() req: Request) {
255
+ async changePassword(
256
+ @Body() body: Record<string, unknown>,
257
+ @Req() req: Request,
258
+ ) {
199
259
  await this.auth.changePassword(req.auth!.sub, req.auth!.sessionId, {
200
260
  currentPassword: requireString(body.currentPassword, "currentPassword"),
201
261
  newPassword: requireString(body.newPassword, "newPassword"),
@@ -209,15 +269,18 @@ export class AuthController {
209
269
  @UseGuards(AuthGuard)
210
270
  @ApiOperation({
211
271
  summary: "Update the current user's own profile",
212
- description: "Self-service — no admin permission required. Updates the caller's own row directly; unrelated to any workspace.",
272
+ description:
273
+ "Self-service — no admin permission required. Updates the caller's own row directly; unrelated to any workspace.",
213
274
  })
214
275
  @ApiBody({ type: UpdateUserDto })
215
276
  @ApiResponse({ status: 200, type: SelfProfileDto })
216
277
  async updateMe(@Body() body: Record<string, unknown>, @Req() req: Request) {
217
278
  return this.auth.updateProfile(req.auth!.sub, {
218
- firstName: body.firstName === null ? null : optionalString(body.firstName),
279
+ firstName:
280
+ body.firstName === null ? null : optionalString(body.firstName),
219
281
  lastName: body.lastName === null ? null : optionalString(body.lastName),
220
- displayName: body.displayName === null ? null : optionalString(body.displayName),
282
+ displayName:
283
+ body.displayName === null ? null : optionalString(body.displayName),
221
284
  phone: body.phone === null ? null : optionalString(body.phone),
222
285
  username: body.username === null ? null : optionalString(body.username),
223
286
  photo: body.photo === null ? null : optionalString(body.photo),
@@ -228,7 +291,10 @@ export class AuthController {
228
291
  @Authenticated()
229
292
  @ApiBearerAuth()
230
293
  @UseGuards(AuthGuard)
231
- @ApiOperation({ summary: "Begin TOTP enrollment", description: "Not active until confirmed via POST /auth/2fa/confirm." })
294
+ @ApiOperation({
295
+ summary: "Begin TOTP enrollment",
296
+ description: "Not active until confirmed via POST /auth/2fa/confirm.",
297
+ })
232
298
  @ApiResponse({ status: 201, type: EnrollTwoFactorResponseDto })
233
299
  async enrollTwoFactor(@Req() req: Request) {
234
300
  return this.auth.enrollTwoFactor(req.auth!.sub);
@@ -238,28 +304,51 @@ export class AuthController {
238
304
  @Authenticated()
239
305
  @ApiBearerAuth()
240
306
  @UseGuards(AuthGuard)
241
- @ApiOperation({ summary: "Confirm TOTP enrollment with a code", description: "Enables 2FA and returns one-time backup codes, shown only here." })
307
+ @ApiOperation({
308
+ summary: "Confirm TOTP enrollment with a code",
309
+ description:
310
+ "Enables 2FA and returns one-time backup codes, shown only here.",
311
+ })
242
312
  @ApiBody({ type: TwoFactorCodeDto })
243
313
  @ApiResponse({ status: 201, type: ConfirmTwoFactorResponseDto })
244
- async confirmTwoFactor(@Body() body: Record<string, unknown>, @Req() req: Request) {
245
- return this.auth.confirmTwoFactor(req.auth!.sub, requireString(body.code, "code"));
314
+ async confirmTwoFactor(
315
+ @Body() body: Record<string, unknown>,
316
+ @Req() req: Request,
317
+ ) {
318
+ return this.auth.confirmTwoFactor(
319
+ req.auth!.sub,
320
+ requireString(body.code, "code"),
321
+ );
246
322
  }
247
323
 
248
324
  @Post("2fa/disable")
249
325
  @Authenticated()
250
326
  @ApiBearerAuth()
251
327
  @UseGuards(AuthGuard)
252
- @ApiOperation({ summary: "Disable 2FA", description: "Requires a valid TOTP or backup code." })
328
+ @ApiOperation({
329
+ summary: "Disable 2FA",
330
+ description: "Requires a valid TOTP or backup code.",
331
+ })
253
332
  @ApiBody({ type: TwoFactorCodeDto })
254
333
  @ApiResponse({ status: 201, type: OkResponseDto })
255
- async disableTwoFactor(@Body() body: Record<string, unknown>, @Req() req: Request) {
256
- await this.auth.disableTwoFactor(req.auth!.sub, requireString(body.code, "code"));
334
+ async disableTwoFactor(
335
+ @Body() body: Record<string, unknown>,
336
+ @Req() req: Request,
337
+ ) {
338
+ await this.auth.disableTwoFactor(
339
+ req.auth!.sub,
340
+ requireString(body.code, "code"),
341
+ );
257
342
  return { ok: true };
258
343
  }
259
344
 
260
345
  @Post("password/forgot")
261
346
  @Public()
262
- @ApiOperation({ summary: "Request a password reset", description: "Always reports success, whether or not the email exists — avoids account enumeration." })
347
+ @ApiOperation({
348
+ summary: "Request a password reset",
349
+ description:
350
+ "Always reports success, whether or not the email exists — avoids account enumeration.",
351
+ })
263
352
  @ApiBody({ type: ForgotPasswordDto })
264
353
  @ApiResponse({ status: 201, type: OkResponseDto })
265
354
  async forgotPassword(@Body() body: Record<string, unknown>) {
@@ -269,11 +358,17 @@ export class AuthController {
269
358
 
270
359
  @Post("password/reset")
271
360
  @Public()
272
- @ApiOperation({ summary: "Complete a password reset", description: "Revokes every existing session for the user on success." })
361
+ @ApiOperation({
362
+ summary: "Complete a password reset",
363
+ description: "Revokes every existing session for the user on success.",
364
+ })
273
365
  @ApiBody({ type: ResetPasswordDto })
274
366
  @ApiResponse({ status: 201, type: OkResponseDto })
275
367
  async resetPassword(@Body() body: Record<string, unknown>) {
276
- await this.auth.resetPassword(requireString(body.token, "token"), requireString(body.newPassword, "newPassword"));
368
+ await this.auth.resetPassword(
369
+ requireString(body.token, "token"),
370
+ requireString(body.newPassword, "newPassword"),
371
+ );
277
372
  return { ok: true };
278
373
  }
279
374
 
@@ -288,12 +383,22 @@ export class AuthController {
288
383
 
289
384
  @Get("oauth/:provider/callback")
290
385
  @Public()
291
- @ApiOperation({ summary: "OAuth provider callback — exchanges the code and completes login" })
386
+ @ApiOperation({
387
+ summary: "OAuth provider callback — exchanges the code and completes login",
388
+ })
292
389
  @ApiParam({ name: "provider", enum: ["google", "apple"] })
293
390
  @ApiQuery({ name: "code", required: true })
294
391
  @ApiQuery({ name: "state", required: true })
295
392
  @ApiResponse({ status: 200, type: AuthTokensDto })
296
- async oauthCallback(@Param("provider") provider: string, @Query("code") code: string, @Query("state") state: string) {
297
- return this.auth.completeOAuthCallback(provider, requireString(code, "code"), requireString(state, "state"));
393
+ async oauthCallback(
394
+ @Param("provider") provider: string,
395
+ @Query("code") code: string,
396
+ @Query("state") state: string,
397
+ ) {
398
+ return this.auth.completeOAuthCallback(
399
+ provider,
400
+ requireString(code, "code"),
401
+ requireString(state, "state"),
402
+ );
298
403
  }
299
404
  }
@@ -18,7 +18,11 @@
18
18
  // with no AuthGuard, or @CheckAbility(...) with no AbilityGuard, is the same class of mistake as
19
19
  // declaring nothing at all, and would otherwise be an open route that looks closed in the source.
20
20
  import { RequestMethod, SetMetadata } from "@nestjs/common";
21
- import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from "@nestjs/common/constants.js";
21
+ import {
22
+ GUARDS_METADATA,
23
+ METHOD_METADATA,
24
+ PATH_METADATA,
25
+ } from "@nestjs/common/constants.js";
22
26
  import type { PermissionSlug } from "./rbac.defaults.js";
23
27
 
24
28
  export const ROUTE_TIER_KEY = "routeTier";
@@ -32,14 +36,16 @@ export type RouteTier = "public" | "authenticated" | "ability";
32
36
  * said out loud — "this route is open" must be a decision someone wrote down, not the absence of
33
37
  * one.
34
38
  */
35
- export const Public = () => SetMetadata(ROUTE_TIER_KEY, "public" satisfies RouteTier);
39
+ export const Public = () =>
40
+ SetMetadata(ROUTE_TIER_KEY, "public" satisfies RouteTier);
36
41
 
37
42
  /**
38
43
  * Tier 2: any authenticated caller, no permission required. Pair with `@UseGuards(AuthGuard)`
39
44
  * (plus the variant's authorization guard where the handler reads `req.authz`) — the startup
40
45
  * check refuses a route that claims this tier without an authentication guard behind it.
41
46
  */
42
- export const Authenticated = () => SetMetadata(ROUTE_TIER_KEY, "authenticated" satisfies RouteTier);
47
+ export const Authenticated = () =>
48
+ SetMetadata(ROUTE_TIER_KEY, "authenticated" satisfies RouteTier);
43
49
 
44
50
  /**
45
51
  * Tier 3: `@CheckAbility("users:block")`. Requires `AuthGuard`, then the variant's authorization
@@ -53,7 +59,10 @@ export const Authenticated = () => SetMetadata(ROUTE_TIER_KEY, "authenticated" s
53
59
  * explain. Which *users* hold the slug is entirely a matter of rows.
54
60
  */
55
61
  export const CheckAbility = (...abilities: PermissionSlug[]) =>
56
- applyBoth(SetMetadata(ROUTE_TIER_KEY, "ability" satisfies RouteTier), SetMetadata(CHECK_ABILITY_KEY, abilities as string[]));
62
+ applyBoth(
63
+ SetMetadata(ROUTE_TIER_KEY, "ability" satisfies RouteTier),
64
+ SetMetadata(CHECK_ABILITY_KEY, abilities as string[]),
65
+ );
57
66
 
58
67
  /** `applyDecorators` from @nestjs/common, minus its class-decorator branch — these two are method decorators. */
59
68
  function applyBoth(...decorators: MethodDecorator[]): MethodDecorator {
@@ -63,11 +72,26 @@ function applyBoth(...decorators: MethodDecorator[]): MethodDecorator {
63
72
  };
64
73
  }
65
74
 
75
+ // `any` here is real type erasure, not laziness: controllers/guards have heterogeneous
76
+ // constructor signatures, and these types only need to carry a class/method identity through
77
+ // reflection — never construct or invoke anything — so `unknown` params would reject every real,
78
+ // differently-typed class assigned to them.
79
+ /* eslint-disable @typescript-eslint/no-explicit-any -- see comment above */
80
+ /** A NestJS controller/guard class reference — reflected on via metadata, never constructed here. */
81
+ type Ctor = abstract new (...args: any[]) => object;
82
+
83
+ /** A route-handler method — reflected on and invoked by Nest's own pipeline, never called directly here. */
84
+ type HandlerFn = (...args: any[]) => unknown;
85
+ /* eslint-enable @typescript-eslint/no-explicit-any */
86
+
66
87
  /** A guard entry in `@UseGuards` may be a class or an already-constructed instance; both reduce to a class. */
67
- type GuardEntry = Function | { constructor: Function };
88
+ type GuardEntry = Ctor | { constructor: Ctor };
68
89
 
69
- const guardClassesOn = (target: object): Function[] =>
70
- ((Reflect.getMetadata(GUARDS_METADATA, target) as GuardEntry[] | undefined) ?? []).map((g) => (typeof g === "function" ? g : g.constructor));
90
+ const guardClassesOn = (target: object): Ctor[] =>
91
+ (
92
+ (Reflect.getMetadata(GUARDS_METADATA, target) as
93
+ GuardEntry[] | undefined) ?? []
94
+ ).map((g) => (typeof g === "function" ? g : g.constructor));
71
95
 
72
96
  const joinPath = (...parts: unknown[]) =>
73
97
  "/" +
@@ -78,13 +102,21 @@ const joinPath = (...parts: unknown[]) =>
78
102
  .join("/");
79
103
 
80
104
  /** Own methods carrying Nest's route metadata. Controllers here are flat classes; inherited routes are not a shape this combo uses. */
81
- function routeHandlersOf(controller: Function): Array<{ name: string; handler: Function }> {
105
+ function routeHandlersOf(
106
+ controller: Ctor,
107
+ ): Array<{ name: string; handler: HandlerFn }> {
82
108
  const prototype = controller.prototype as Record<string, unknown>;
83
109
  return Object.getOwnPropertyNames(prototype)
84
110
  .filter((name) => name !== "constructor")
85
111
  .map((name) => ({ name, handler: prototype[name] }))
86
- .filter((entry): entry is { name: string; handler: Function } => typeof entry.handler === "function")
87
- .filter((entry) => Reflect.getMetadata(PATH_METADATA, entry.handler) !== undefined);
112
+ .filter(
113
+ (entry): entry is { name: string; handler: HandlerFn } =>
114
+ typeof entry.handler === "function",
115
+ )
116
+ .filter(
117
+ (entry) =>
118
+ Reflect.getMetadata(PATH_METADATA, entry.handler) !== undefined,
119
+ );
88
120
  }
89
121
 
90
122
  /**
@@ -103,7 +135,10 @@ function routeHandlersOf(controller: Function): Array<{ name: string; handler: F
103
135
  * @param controllers the module's controller classes
104
136
  * @param guards the two guard classes whose presence defines tiers 2 and 3
105
137
  */
106
- export function assertEveryRouteDeclaresATier(controllers: readonly Function[], guards: { authentication: Function; ability: Function }): void {
138
+ export function assertEveryRouteDeclaresATier(
139
+ controllers: readonly Ctor[],
140
+ guards: { authentication: Ctor; ability: Ctor },
141
+ ): void {
107
142
  const offenders: string[] = [];
108
143
 
109
144
  for (const controller of controllers) {
@@ -111,12 +146,19 @@ export function assertEveryRouteDeclaresATier(controllers: readonly Function[],
111
146
  const controllerGuards = guardClassesOn(controller);
112
147
 
113
148
  for (const { name, handler } of routeHandlersOf(controller)) {
114
- const method = RequestMethod[Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod] ?? "?";
149
+ const method =
150
+ RequestMethod[
151
+ Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod
152
+ ] ?? "?";
115
153
  const route = `${method} ${joinPath(controllerPath, Reflect.getMetadata(PATH_METADATA, handler))} (${controller.name}.${name})`;
116
154
 
117
- const tier: RouteTier | undefined = Reflect.getMetadata(ROUTE_TIER_KEY, handler) ?? Reflect.getMetadata(ROUTE_TIER_KEY, controller);
155
+ const tier: RouteTier | undefined =
156
+ Reflect.getMetadata(ROUTE_TIER_KEY, handler) ??
157
+ Reflect.getMetadata(ROUTE_TIER_KEY, controller);
118
158
  if (!tier) {
119
- offenders.push(`${route} — declares no tier: add @Public(), @Authenticated(), or @CheckAbility(slug)`);
159
+ offenders.push(
160
+ `${route} — declares no tier: add @Public(), @Authenticated(), or @CheckAbility(slug)`,
161
+ );
120
162
  continue;
121
163
  }
122
164
 
@@ -126,11 +168,22 @@ export function assertEveryRouteDeclaresATier(controllers: readonly Function[],
126
168
  const authenticated = attached.includes(guards.authentication);
127
169
  const ability = attached.includes(guards.ability);
128
170
 
129
- if (tier === "public" && (authenticated || ability)) offenders.push(`${route} — declares @Public() but is behind ${attached.map((g) => g.name).join(", ")}`);
130
- if (tier === "authenticated" && !authenticated) offenders.push(`${route} — declares @Authenticated() but has no ${guards.authentication.name}`);
131
- if (tier === "authenticated" && ability) offenders.push(`${route} — declares @Authenticated() but is behind ${guards.ability.name}; use @CheckAbility instead`);
171
+ if (tier === "public" && (authenticated || ability))
172
+ offenders.push(
173
+ `${route} — declares @Public() but is behind ${attached.map((g) => g.name).join(", ")}`,
174
+ );
175
+ if (tier === "authenticated" && !authenticated)
176
+ offenders.push(
177
+ `${route} — declares @Authenticated() but has no ${guards.authentication.name}`,
178
+ );
179
+ if (tier === "authenticated" && ability)
180
+ offenders.push(
181
+ `${route} — declares @Authenticated() but is behind ${guards.ability.name}; use @CheckAbility instead`,
182
+ );
132
183
  if (tier === "ability" && !(authenticated && ability))
133
- offenders.push(`${route} — declares @CheckAbility but is not behind ${guards.authentication.name} + ${guards.ability.name}`);
184
+ offenders.push(
185
+ `${route} — declares @CheckAbility but is not behind ${guards.authentication.name} + ${guards.ability.name}`,
186
+ );
134
187
  }
135
188
  }
136
189
 
@@ -0,0 +1,18 @@
1
+ import js from "@eslint/js";
2
+ import tseslint from "typescript-eslint";
3
+
4
+ export default tseslint.config(
5
+ { ignores: [".variant/**", "node_modules/**", "dist/**", "generated/**"] },
6
+ js.configs.recommended,
7
+ ...tseslint.configs.recommended,
8
+ {
9
+ rules: {
10
+ // This combo's leading-underscore convention (e.g. an interface-required parameter that's
11
+ // deliberately unused) predates this eslint config — respect it rather than flag it.
12
+ "@typescript-eslint/no-unused-vars": [
13
+ "error",
14
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
15
+ ],
16
+ },
17
+ },
18
+ );
@@ -8,6 +8,7 @@
8
8
  "materialize": "node scripts/materialize.mjs",
9
9
  "prisma:generate": "node scripts/materialize.mjs --all --generate",
10
10
  "typecheck": "npm run prisma:generate && tsc -p .variant/base && tsc -p .variant/workspaces",
11
+ "lint": "eslint shared variants",
11
12
  "prove-cycle": "node scripts/prove-cycle.mjs",
12
13
  "migrate": "node scripts/migrate.mjs",
13
14
  "seed": "node scripts/seed.mjs"
@@ -30,12 +31,15 @@
30
31
  "socket.io": "^4.8.3"
31
32
  },
32
33
  "devDependencies": {
34
+ "@eslint/js": "^9.39.5",
33
35
  "@types/express": "^5.0.6",
34
36
  "@types/node": "^26.2.0",
35
37
  "dotenv": "^17.4.2",
38
+ "eslint": "^9.39.5",
36
39
  "prisma": "^7.9.1",
37
40
  "tsx": "^4.23.11",
38
- "typescript": "^7.0.2",
41
+ "typescript": "^6.0.3",
42
+ "typescript-eslint": "^8.70.0",
39
43
  "vitest": "^4.1.10"
40
44
  }
41
45
  }