@simple-auth-kit/cli 1.3.2 → 1.4.0

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 (75) hide show
  1. package/package.json +1 -1
  2. package/registry/admin-apps/nextjs/shared/.husky/commit-msg +1 -0
  3. package/registry/admin-apps/nextjs/shared/.husky/pre-commit +1 -0
  4. package/registry/admin-apps/nextjs/shared/.lintstagedrc.json +4 -0
  5. package/registry/admin-apps/nextjs/shared/.prettierrc.json +3 -0
  6. package/registry/admin-apps/nextjs/shared/commitlint.config.js +5 -0
  7. package/registry/admin-apps/nextjs/shared/eslint.config.js +28 -0
  8. package/registry/admin-apps/nextjs/variants/base/package.json +11 -2
  9. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/audit-log/page.tsx +72 -16
  10. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/customers/[id]/edit/page.tsx +107 -26
  11. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/customers/[id]/page.tsx +131 -29
  12. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/languages/[id]/edit/page.tsx +111 -25
  13. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/languages/[id]/page.tsx +97 -22
  14. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/permissions/[id]/edit/page.tsx +129 -46
  15. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/permissions/[id]/page.tsx +64 -14
  16. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/roles/[id]/page.tsx +91 -22
  17. package/registry/admin-apps/nextjs/variants/base/src/app/(console)/users/[id]/edit/page.tsx +94 -23
  18. package/registry/admin-apps/nextjs/variants/workspaces/package.json +11 -2
  19. package/registry/admin-apps/nextjs/variants/workspaces/src/app/(console)/account/page.tsx +179 -41
  20. package/registry/admin-apps/react/shared/.husky/commit-msg +1 -0
  21. package/registry/admin-apps/react/shared/.husky/pre-commit +1 -0
  22. package/registry/admin-apps/react/shared/.lintstagedrc.json +4 -0
  23. package/registry/admin-apps/react/shared/.prettierrc.json +3 -0
  24. package/registry/admin-apps/react/shared/commitlint.config.js +3 -0
  25. package/registry/admin-apps/react/shared/eslint.config.js +38 -0
  26. package/registry/admin-apps/react/variants/base/package.json +15 -2
  27. package/registry/admin-apps/react/variants/base/src/pages/CountryDetailPage.tsx +76 -19
  28. package/registry/admin-apps/react/variants/base/src/pages/CustomerDetailPage.tsx +110 -26
  29. package/registry/admin-apps/react/variants/base/src/pages/EditCountryPage.tsx +115 -29
  30. package/registry/admin-apps/react/variants/base/src/pages/EditCustomerPage.tsx +123 -31
  31. package/registry/admin-apps/react/variants/base/src/pages/EditLanguagePage.tsx +100 -24
  32. package/registry/admin-apps/react/variants/base/src/pages/EditRolePage.tsx +99 -26
  33. package/registry/admin-apps/react/variants/base/src/pages/EditUserPage.tsx +126 -31
  34. package/registry/admin-apps/react/variants/base/src/pages/LanguageDetailPage.tsx +75 -19
  35. package/registry/admin-apps/react/variants/base/src/pages/PermissionDetailPage.tsx +58 -13
  36. package/registry/admin-apps/react/variants/base/src/pages/PermissionsPage.tsx +132 -29
  37. package/registry/admin-apps/react/variants/base/src/pages/RoleDetailPage.tsx +70 -17
  38. package/registry/admin-apps/react/variants/base/src/pages/RolesPage.tsx +277 -59
  39. package/registry/admin-apps/react/variants/workspaces/package.json +15 -2
  40. package/registry/combos/express-drizzle/eslint.config.js +18 -0
  41. package/registry/combos/express-drizzle/package.json +5 -1
  42. package/registry/combos/express-drizzle/shared/src/request-context.ts +3 -0
  43. package/registry/combos/express-prisma/eslint.config.js +18 -0
  44. package/registry/combos/express-prisma/package.json +5 -1
  45. package/registry/combos/express-prisma/shared/src/request-context.ts +3 -0
  46. package/registry/combos/nestjs-drizzle/eslint.config.js +18 -0
  47. package/registry/combos/nestjs-drizzle/package.json +5 -1
  48. package/registry/combos/nestjs-drizzle/shared/src/auth.controller.ts +138 -33
  49. package/registry/combos/nestjs-drizzle/shared/src/route-tiers.ts +71 -18
  50. package/registry/combos/nestjs-prisma/eslint.config.js +18 -0
  51. package/registry/combos/nestjs-prisma/package.json +5 -1
  52. package/registry/combos/nestjs-prisma/shared/src/auth.controller.ts +148 -35
  53. package/registry/combos/nestjs-prisma/shared/src/route-tiers.ts +72 -18
  54. package/registry/mobile-apps/bare-rn/shared/.husky/commit-msg +1 -0
  55. package/registry/mobile-apps/bare-rn/shared/.husky/pre-commit +1 -0
  56. package/registry/mobile-apps/bare-rn/shared/.lintstagedrc.json +4 -0
  57. package/registry/mobile-apps/bare-rn/shared/.prettierrc.json +5 -0
  58. package/registry/mobile-apps/bare-rn/shared/commitlint.config.js +3 -0
  59. package/registry/mobile-apps/bare-rn/shared/eslint.config.js +19 -0
  60. package/registry/mobile-apps/bare-rn/variants/base/package.json +17 -12
  61. package/registry/mobile-apps/bare-rn/variants/workspaces/package.json +17 -12
  62. package/registry/mobile-apps/expo/shared/.husky/commit-msg +1 -0
  63. package/registry/mobile-apps/expo/shared/.husky/pre-commit +1 -0
  64. package/registry/mobile-apps/expo/shared/.lintstagedrc.json +4 -0
  65. package/registry/mobile-apps/expo/shared/.prettierrc.json +5 -0
  66. package/registry/mobile-apps/expo/shared/commitlint.config.js +3 -0
  67. package/registry/mobile-apps/expo/shared/eslint.config.js +9 -0
  68. package/registry/mobile-apps/expo/shared/src/screens/SessionsScreen.tsx +6 -17
  69. package/registry/mobile-apps/expo/shared/src/store/sessionsStore.ts +35 -0
  70. package/registry/mobile-apps/expo/variants/base/package.json +12 -3
  71. package/registry/mobile-apps/expo/variants/workspaces/package.json +12 -3
  72. package/registry/mobile-apps/expo/variants/workspaces/src/screens/WorkspaceSelectScreen.tsx +1 -1
  73. package/registry.json +13 -36
  74. package/registry/mobile-apps/bare-rn/shared/.eslintrc.js +0 -4
  75. package/registry/mobile-apps/bare-rn/shared/.prettierrc.js +0 -5
@@ -1,8 +1,33 @@
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 { SkipThrottle } from "@nestjs/throttler";
4
25
  import type { Request } from "express";
5
- import { AUTH_CONFIG, AuthConfig, defaultThrottleBuckets } from "./auth.config.js";
26
+ import {
27
+ AUTH_CONFIG,
28
+ AuthConfig,
29
+ defaultThrottleBuckets,
30
+ } from "./auth.config.js";
6
31
  import { AuthGuard } from "./auth.guard.js";
7
32
  import { AuthService } from "./auth.service.js";
8
33
  import { AuthzGuard } from "./authz.guard.js";
@@ -24,17 +49,18 @@ import {
24
49
  SelfProfileDto,
25
50
  SessionSummaryDto,
26
51
  SignupDto,
27
- TwoFactorChallengeDto,
28
52
  TwoFactorCodeDto,
29
53
  UpdateUserDto,
30
54
  } from "./dto/auth.dto.js";
31
55
 
32
56
  function requireString(value: unknown, field: string): string {
33
- if (typeof value !== "string" || value.length === 0) throw new BadRequestException(`${field} is required`);
57
+ if (typeof value !== "string" || value.length === 0)
58
+ throw new BadRequestException(`${field} is required`);
34
59
  return value;
35
60
  }
36
61
 
37
- const optionalString = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
62
+ const optionalString = (value: unknown): string | undefined =>
63
+ typeof value === "string" ? value : undefined;
38
64
 
39
65
  /**
40
66
  * Identity endpoints — everything that is about *a user*, never about a group of them.
@@ -53,7 +79,11 @@ export class AuthController {
53
79
  @ApiOperation({ summary: "Create a user and issue a session" })
54
80
  @ApiBody({ type: SignupDto })
55
81
  @ApiResponse({ status: 201, type: AuthTokensDto })
56
- async signup(@Body() body: Record<string, unknown>, @Req() req: Request, @Ip() ip: string) {
82
+ async signup(
83
+ @Body() body: Record<string, unknown>,
84
+ @Req() req: Request,
85
+ @Ip() ip: string,
86
+ ) {
57
87
  return this.auth.signup({
58
88
  email: requireString(body.email, "email"),
59
89
  password: requireString(body.password, "password"),
@@ -71,11 +101,24 @@ export class AuthController {
71
101
  @Public()
72
102
  @ApiOperation({
73
103
  summary: "Log in with email, username, or phone + password",
74
- 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.",
104
+ description:
105
+ "identifier is matched against email, username, and phone, in that order. Returns tokens directly, or a 2FA challenge if the account has 2FA enabled.",
75
106
  })
76
107
  @ApiBody({ type: LoginDto })
77
- @ApiResponse({ status: 201, schema: { oneOf: [{ $ref: "#/components/schemas/AuthTokensDto" }, { $ref: "#/components/schemas/TwoFactorChallengeDto" }] } })
78
- async login(@Body() body: Record<string, unknown>, @Req() req: Request, @Ip() ip: string) {
108
+ @ApiResponse({
109
+ status: 201,
110
+ schema: {
111
+ oneOf: [
112
+ { $ref: "#/components/schemas/AuthTokensDto" },
113
+ { $ref: "#/components/schemas/TwoFactorChallengeDto" },
114
+ ],
115
+ },
116
+ })
117
+ async login(
118
+ @Body() body: Record<string, unknown>,
119
+ @Req() req: Request,
120
+ @Ip() ip: string,
121
+ ) {
79
122
  return this.auth.login({
80
123
  identifier: requireString(body.identifier, "identifier"),
81
124
  password: requireString(body.password, "password"),
@@ -89,7 +132,11 @@ export class AuthController {
89
132
  @ApiOperation({ summary: "Complete the 2FA challenge from POST /auth/login" })
90
133
  @ApiBody({ type: LoginTwoFactorDto })
91
134
  @ApiResponse({ status: 201, type: AuthTokensDto })
92
- async loginTwoFactor(@Body() body: Record<string, unknown>, @Req() req: Request, @Ip() ip: string) {
135
+ async loginTwoFactor(
136
+ @Body() body: Record<string, unknown>,
137
+ @Req() req: Request,
138
+ @Ip() ip: string,
139
+ ) {
93
140
  return this.auth.loginTwoFactor({
94
141
  challengeToken: requireString(body.challengeToken, "challengeToken"),
95
142
  code: requireString(body.code, "code"),
@@ -100,7 +147,9 @@ export class AuthController {
100
147
 
101
148
  @Post("refresh")
102
149
  @Public()
103
- @ApiOperation({ summary: "Rotate a refresh token for a new access+refresh pair" })
150
+ @ApiOperation({
151
+ summary: "Rotate a refresh token for a new access+refresh pair",
152
+ })
104
153
  @ApiBody({ type: RefreshDto })
105
154
  @ApiResponse({ status: 201, type: RefreshResponseDto })
106
155
  async refresh(@Body() body: Record<string, unknown>) {
@@ -111,7 +160,11 @@ export class AuthController {
111
160
  @Authenticated()
112
161
  // Clients hit this on every navigation to verify the token is still live — identity checks
113
162
  // must not consume the abuse budget. Metadata only in variants that don't register the guard.
114
- @SkipThrottle(Object.fromEntries(defaultThrottleBuckets.map((bucket) => [bucket.name, true])))
163
+ @SkipThrottle(
164
+ Object.fromEntries(
165
+ defaultThrottleBuckets.map((bucket) => [bucket.name, true]),
166
+ ),
167
+ )
115
168
  @ApiBearerAuth()
116
169
  @UseGuards(AuthGuard, AuthzGuard)
117
170
  @ApiOperation({
@@ -125,7 +178,10 @@ export class AuthController {
125
178
  @ApiResponse({ status: 200, type: CurrentUserDto })
126
179
  async me(@Req() req: Request) {
127
180
  const { sub, sessionId } = req.auth!;
128
- const [{ twoFactorEnabled }, profile] = await Promise.all([this.auth.getTwoFactorStatus(sub), this.auth.getProfile(sub)]);
181
+ const [{ twoFactorEnabled }, profile] = await Promise.all([
182
+ this.auth.getTwoFactorStatus(sub),
183
+ this.auth.getProfile(sub),
184
+ ]);
129
185
  // The same `req.authz` AbilityGuard's ability was built from — not re-resolved here.
130
186
  return {
131
187
  sub,
@@ -147,7 +203,9 @@ export class AuthController {
147
203
  @Authenticated()
148
204
  @ApiBearerAuth()
149
205
  @UseGuards(AuthGuard)
150
- @ApiOperation({ summary: "List the current user's active (non-revoked) sessions" })
206
+ @ApiOperation({
207
+ summary: "List the current user's active (non-revoked) sessions",
208
+ })
151
209
  @ApiResponse({ status: 200, type: [SessionSummaryDto] })
152
210
  async sessions(@Req() req: Request) {
153
211
  return this.auth.listActiveSessions(req.auth!.sub);
@@ -163,7 +221,10 @@ export class AuthController {
163
221
  const { sessionId, jti, sub } = req.auth!;
164
222
  // The revoker is the caller themselves. Recorded so the row can later be told apart from a
165
223
  // session an administrator ended — see `sessions.revoked_by`.
166
- await this.auth.logout(sessionId, jti, this.config.accessTokenTtlSeconds, { userId: sub, ip });
224
+ await this.auth.logout(sessionId, jti, this.config.accessTokenTtlSeconds, {
225
+ userId: sub,
226
+ ip,
227
+ });
167
228
  return { ok: true };
168
229
  }
169
230
 
@@ -185,7 +246,10 @@ export class AuthController {
185
246
  @ApiOperation({ summary: "Revoke every session except the current one" })
186
247
  @ApiResponse({ status: 201, type: OkResponseDto })
187
248
  async logoutOthers(@Req() req: Request, @Ip() ip: string) {
188
- await this.auth.logoutOthers(req.auth!.sub, req.auth!.sessionId, { userId: req.auth!.sub, ip });
249
+ await this.auth.logoutOthers(req.auth!.sub, req.auth!.sessionId, {
250
+ userId: req.auth!.sub,
251
+ ip,
252
+ });
189
253
  return { ok: true };
190
254
  }
191
255
 
@@ -195,11 +259,15 @@ export class AuthController {
195
259
  @UseGuards(AuthGuard)
196
260
  @ApiOperation({
197
261
  summary: "Change the current password",
198
- description: "Requires the current password. Every other session is revoked; the one making this call is left alone.",
262
+ description:
263
+ "Requires the current password. Every other session is revoked; the one making this call is left alone.",
199
264
  })
200
265
  @ApiBody({ type: ChangePasswordDto })
201
266
  @ApiResponse({ status: 201, type: OkResponseDto })
202
- async changePassword(@Body() body: Record<string, unknown>, @Req() req: Request) {
267
+ async changePassword(
268
+ @Body() body: Record<string, unknown>,
269
+ @Req() req: Request,
270
+ ) {
203
271
  await this.auth.changePassword(req.auth!.sub, req.auth!.sessionId, {
204
272
  currentPassword: requireString(body.currentPassword, "currentPassword"),
205
273
  newPassword: requireString(body.newPassword, "newPassword"),
@@ -213,15 +281,18 @@ export class AuthController {
213
281
  @UseGuards(AuthGuard)
214
282
  @ApiOperation({
215
283
  summary: "Update the current user's own profile",
216
- description: "Self-service — no admin permission required. Updates the caller's own row directly; unrelated to any workspace.",
284
+ description:
285
+ "Self-service — no admin permission required. Updates the caller's own row directly; unrelated to any workspace.",
217
286
  })
218
287
  @ApiBody({ type: UpdateUserDto })
219
288
  @ApiResponse({ status: 200, type: SelfProfileDto })
220
289
  async updateMe(@Body() body: Record<string, unknown>, @Req() req: Request) {
221
290
  return this.auth.updateProfile(req.auth!.sub, {
222
- firstName: body.firstName === null ? null : optionalString(body.firstName),
291
+ firstName:
292
+ body.firstName === null ? null : optionalString(body.firstName),
223
293
  lastName: body.lastName === null ? null : optionalString(body.lastName),
224
- displayName: body.displayName === null ? null : optionalString(body.displayName),
294
+ displayName:
295
+ body.displayName === null ? null : optionalString(body.displayName),
225
296
  phone: body.phone === null ? null : optionalString(body.phone),
226
297
  username: body.username === null ? null : optionalString(body.username),
227
298
  photo: body.photo === null ? null : optionalString(body.photo),
@@ -232,7 +303,10 @@ export class AuthController {
232
303
  @Authenticated()
233
304
  @ApiBearerAuth()
234
305
  @UseGuards(AuthGuard)
235
- @ApiOperation({ summary: "Begin TOTP enrollment", description: "Not active until confirmed via POST /auth/2fa/confirm." })
306
+ @ApiOperation({
307
+ summary: "Begin TOTP enrollment",
308
+ description: "Not active until confirmed via POST /auth/2fa/confirm.",
309
+ })
236
310
  @ApiResponse({ status: 201, type: EnrollTwoFactorResponseDto })
237
311
  async enrollTwoFactor(@Req() req: Request) {
238
312
  return this.auth.enrollTwoFactor(req.auth!.sub);
@@ -242,28 +316,51 @@ export class AuthController {
242
316
  @Authenticated()
243
317
  @ApiBearerAuth()
244
318
  @UseGuards(AuthGuard)
245
- @ApiOperation({ summary: "Confirm TOTP enrollment with a code", description: "Enables 2FA and returns one-time backup codes, shown only here." })
319
+ @ApiOperation({
320
+ summary: "Confirm TOTP enrollment with a code",
321
+ description:
322
+ "Enables 2FA and returns one-time backup codes, shown only here.",
323
+ })
246
324
  @ApiBody({ type: TwoFactorCodeDto })
247
325
  @ApiResponse({ status: 201, type: ConfirmTwoFactorResponseDto })
248
- async confirmTwoFactor(@Body() body: Record<string, unknown>, @Req() req: Request) {
249
- return this.auth.confirmTwoFactor(req.auth!.sub, requireString(body.code, "code"));
326
+ async confirmTwoFactor(
327
+ @Body() body: Record<string, unknown>,
328
+ @Req() req: Request,
329
+ ) {
330
+ return this.auth.confirmTwoFactor(
331
+ req.auth!.sub,
332
+ requireString(body.code, "code"),
333
+ );
250
334
  }
251
335
 
252
336
  @Post("2fa/disable")
253
337
  @Authenticated()
254
338
  @ApiBearerAuth()
255
339
  @UseGuards(AuthGuard)
256
- @ApiOperation({ summary: "Disable 2FA", description: "Requires a valid TOTP or backup code." })
340
+ @ApiOperation({
341
+ summary: "Disable 2FA",
342
+ description: "Requires a valid TOTP or backup code.",
343
+ })
257
344
  @ApiBody({ type: TwoFactorCodeDto })
258
345
  @ApiResponse({ status: 201, type: OkResponseDto })
259
- async disableTwoFactor(@Body() body: Record<string, unknown>, @Req() req: Request) {
260
- await this.auth.disableTwoFactor(req.auth!.sub, requireString(body.code, "code"));
346
+ async disableTwoFactor(
347
+ @Body() body: Record<string, unknown>,
348
+ @Req() req: Request,
349
+ ) {
350
+ await this.auth.disableTwoFactor(
351
+ req.auth!.sub,
352
+ requireString(body.code, "code"),
353
+ );
261
354
  return { ok: true };
262
355
  }
263
356
 
264
357
  @Post("password/forgot")
265
358
  @Public()
266
- @ApiOperation({ summary: "Request a password reset", description: "Always reports success, whether or not the email exists — avoids account enumeration." })
359
+ @ApiOperation({
360
+ summary: "Request a password reset",
361
+ description:
362
+ "Always reports success, whether or not the email exists — avoids account enumeration.",
363
+ })
267
364
  @ApiBody({ type: ForgotPasswordDto })
268
365
  @ApiResponse({ status: 201, type: OkResponseDto })
269
366
  async forgotPassword(@Body() body: Record<string, unknown>) {
@@ -273,11 +370,17 @@ export class AuthController {
273
370
 
274
371
  @Post("password/reset")
275
372
  @Public()
276
- @ApiOperation({ summary: "Complete a password reset", description: "Revokes every existing session for the user on success." })
373
+ @ApiOperation({
374
+ summary: "Complete a password reset",
375
+ description: "Revokes every existing session for the user on success.",
376
+ })
277
377
  @ApiBody({ type: ResetPasswordDto })
278
378
  @ApiResponse({ status: 201, type: OkResponseDto })
279
379
  async resetPassword(@Body() body: Record<string, unknown>) {
280
- await this.auth.resetPassword(requireString(body.token, "token"), requireString(body.newPassword, "newPassword"));
380
+ await this.auth.resetPassword(
381
+ requireString(body.token, "token"),
382
+ requireString(body.newPassword, "newPassword"),
383
+ );
281
384
  return { ok: true };
282
385
  }
283
386
 
@@ -292,12 +395,22 @@ export class AuthController {
292
395
 
293
396
  @Get("oauth/:provider/callback")
294
397
  @Public()
295
- @ApiOperation({ summary: "OAuth provider callback — exchanges the code and completes login" })
398
+ @ApiOperation({
399
+ summary: "OAuth provider callback — exchanges the code and completes login",
400
+ })
296
401
  @ApiParam({ name: "provider", enum: ["google", "apple"] })
297
402
  @ApiQuery({ name: "code", required: true })
298
403
  @ApiQuery({ name: "state", required: true })
299
404
  @ApiResponse({ status: 200, type: AuthTokensDto })
300
- async oauthCallback(@Param("provider") provider: string, @Query("code") code: string, @Query("state") state: string) {
301
- return this.auth.completeOAuthCallback(provider, requireString(code, "code"), requireString(state, "state"));
405
+ async oauthCallback(
406
+ @Param("provider") provider: string,
407
+ @Query("code") code: string,
408
+ @Query("state") state: string,
409
+ ) {
410
+ return this.auth.completeOAuthCallback(
411
+ provider,
412
+ requireString(code, "code"),
413
+ requireString(state, "state"),
414
+ );
302
415
  }
303
416
  }
@@ -5,7 +5,11 @@
5
5
  // also verifies the guards actually attached match the declared tier, so a route marked
6
6
  // @Authenticated() with no AuthGuard can't masquerade as gated.
7
7
  import { RequestMethod, SetMetadata } from "@nestjs/common";
8
- import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from "@nestjs/common/constants.js";
8
+ import {
9
+ GUARDS_METADATA,
10
+ METHOD_METADATA,
11
+ PATH_METADATA,
12
+ } from "@nestjs/common/constants.js";
9
13
  import type { PermissionSlug } from "./rbac.defaults.js";
10
14
 
11
15
  export const ROUTE_TIER_KEY = "routeTier";
@@ -13,17 +17,22 @@ export const CHECK_ABILITY_KEY = "checkAbility";
13
17
 
14
18
  export type RouteTier = "public" | "authenticated" | "ability";
15
19
 
16
- export const Public = () => SetMetadata(ROUTE_TIER_KEY, "public" satisfies RouteTier);
20
+ export const Public = () =>
21
+ SetMetadata(ROUTE_TIER_KEY, "public" satisfies RouteTier);
17
22
 
18
23
  // Pair with @UseGuards(AuthGuard) — the startup check refuses a route that claims this tier
19
24
  // without an authentication guard behind it.
20
- export const Authenticated = () => SetMetadata(ROUTE_TIER_KEY, "authenticated" satisfies RouteTier);
25
+ export const Authenticated = () =>
26
+ SetMetadata(ROUTE_TIER_KEY, "authenticated" satisfies RouteTier);
21
27
 
22
28
  // Requires AuthGuard, then the variant's authorization guard, then AbilityGuard. All named
23
29
  // slugs must be held — the guard ANDs them. The argument is typed as `PermissionSlug` (the
24
30
  // catalog in rbac.defaults.ts) so a route can't be gated on a slug nothing will ever grant.
25
31
  export const CheckAbility = (...abilities: PermissionSlug[]) =>
26
- applyBoth(SetMetadata(ROUTE_TIER_KEY, "ability" satisfies RouteTier), SetMetadata(CHECK_ABILITY_KEY, abilities as string[]));
32
+ applyBoth(
33
+ SetMetadata(ROUTE_TIER_KEY, "ability" satisfies RouteTier),
34
+ SetMetadata(CHECK_ABILITY_KEY, abilities as string[]),
35
+ );
27
36
 
28
37
  /** `applyDecorators` from @nestjs/common, minus its class-decorator branch — these two are method decorators. */
29
38
  function applyBoth(...decorators: MethodDecorator[]): MethodDecorator {
@@ -33,11 +42,27 @@ function applyBoth(...decorators: MethodDecorator[]): MethodDecorator {
33
42
  };
34
43
  }
35
44
 
45
+ // `any` here is real type erasure, not laziness: controllers/guards have heterogeneous
46
+ // constructor signatures (AdminController takes an AuthService, AuthGuard takes different deps,
47
+ // etc.), and these types only need to carry a class/method identity through reflection — never
48
+ // construct or invoke anything — so `unknown` params would reject every real, differently-typed
49
+ // class assigned to them.
50
+ /* eslint-disable @typescript-eslint/no-explicit-any -- see comment above */
51
+ /** A NestJS controller/guard class reference — reflected on via metadata, never constructed here. */
52
+ type Ctor = abstract new (...args: any[]) => object;
53
+
54
+ /** A route-handler method — reflected on and invoked by Nest's own pipeline, never called directly here. */
55
+ type HandlerFn = (...args: any[]) => unknown;
56
+ /* eslint-enable @typescript-eslint/no-explicit-any */
57
+
36
58
  /** A guard entry in `@UseGuards` may be a class or an already-constructed instance; both reduce to a class. */
37
- type GuardEntry = Function | { constructor: Function };
59
+ type GuardEntry = Ctor | { constructor: Ctor };
38
60
 
39
- const guardClassesOn = (target: object): Function[] =>
40
- ((Reflect.getMetadata(GUARDS_METADATA, target) as GuardEntry[] | undefined) ?? []).map((g) => (typeof g === "function" ? g : g.constructor));
61
+ const guardClassesOn = (target: object): Ctor[] =>
62
+ (
63
+ (Reflect.getMetadata(GUARDS_METADATA, target) as
64
+ GuardEntry[] | undefined) ?? []
65
+ ).map((g) => (typeof g === "function" ? g : g.constructor));
41
66
 
42
67
  const joinPath = (...parts: unknown[]) =>
43
68
  "/" +
@@ -48,20 +73,31 @@ const joinPath = (...parts: unknown[]) =>
48
73
  .join("/");
49
74
 
50
75
  /** Own methods carrying Nest's route metadata. Controllers here are flat classes; inherited routes are not a shape this combo uses. */
51
- function routeHandlersOf(controller: Function): Array<{ name: string; handler: Function }> {
76
+ function routeHandlersOf(
77
+ controller: Ctor,
78
+ ): Array<{ name: string; handler: HandlerFn }> {
52
79
  const prototype = controller.prototype as Record<string, unknown>;
53
80
  return Object.getOwnPropertyNames(prototype)
54
81
  .filter((name) => name !== "constructor")
55
82
  .map((name) => ({ name, handler: prototype[name] }))
56
- .filter((entry): entry is { name: string; handler: Function } => typeof entry.handler === "function")
57
- .filter((entry) => Reflect.getMetadata(PATH_METADATA, entry.handler) !== undefined);
83
+ .filter(
84
+ (entry): entry is { name: string; handler: HandlerFn } =>
85
+ typeof entry.handler === "function",
86
+ )
87
+ .filter(
88
+ (entry) =>
89
+ Reflect.getMetadata(PATH_METADATA, entry.handler) !== undefined,
90
+ );
58
91
  }
59
92
 
60
93
  // Fail-closed at startup over the whole route table this library owns. Called from
61
94
  // `AuthModule.forRoot` with the same array that populates `controllers:`, so nothing can be
62
95
  // added to the module and left out of the check. Covers only this combo's own controllers, not
63
96
  // the consuming application's.
64
- export function assertEveryRouteDeclaresATier(controllers: readonly Function[], guards: { authentication: Function; ability: Function }): void {
97
+ export function assertEveryRouteDeclaresATier(
98
+ controllers: readonly Ctor[],
99
+ guards: { authentication: Ctor; ability: Ctor },
100
+ ): void {
65
101
  const offenders: string[] = [];
66
102
 
67
103
  for (const controller of controllers) {
@@ -69,12 +105,19 @@ export function assertEveryRouteDeclaresATier(controllers: readonly Function[],
69
105
  const controllerGuards = guardClassesOn(controller);
70
106
 
71
107
  for (const { name, handler } of routeHandlersOf(controller)) {
72
- const method = RequestMethod[Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod] ?? "?";
108
+ const method =
109
+ RequestMethod[
110
+ Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod
111
+ ] ?? "?";
73
112
  const route = `${method} ${joinPath(controllerPath, Reflect.getMetadata(PATH_METADATA, handler))} (${controller.name}.${name})`;
74
113
 
75
- const tier: RouteTier | undefined = Reflect.getMetadata(ROUTE_TIER_KEY, handler) ?? Reflect.getMetadata(ROUTE_TIER_KEY, controller);
114
+ const tier: RouteTier | undefined =
115
+ Reflect.getMetadata(ROUTE_TIER_KEY, handler) ??
116
+ Reflect.getMetadata(ROUTE_TIER_KEY, controller);
76
117
  if (!tier) {
77
- offenders.push(`${route} — declares no tier: add @Public(), @Authenticated(), or @CheckAbility(slug)`);
118
+ offenders.push(
119
+ `${route} — declares no tier: add @Public(), @Authenticated(), or @CheckAbility(slug)`,
120
+ );
78
121
  continue;
79
122
  }
80
123
 
@@ -84,11 +127,22 @@ export function assertEveryRouteDeclaresATier(controllers: readonly Function[],
84
127
  const authenticated = attached.includes(guards.authentication);
85
128
  const ability = attached.includes(guards.ability);
86
129
 
87
- if (tier === "public" && (authenticated || ability)) offenders.push(`${route} — declares @Public() but is behind ${attached.map((g) => g.name).join(", ")}`);
88
- if (tier === "authenticated" && !authenticated) offenders.push(`${route} — declares @Authenticated() but has no ${guards.authentication.name}`);
89
- if (tier === "authenticated" && ability) offenders.push(`${route} — declares @Authenticated() but is behind ${guards.ability.name}; use @CheckAbility instead`);
130
+ if (tier === "public" && (authenticated || ability))
131
+ offenders.push(
132
+ `${route} — declares @Public() but is behind ${attached.map((g) => g.name).join(", ")}`,
133
+ );
134
+ if (tier === "authenticated" && !authenticated)
135
+ offenders.push(
136
+ `${route} — declares @Authenticated() but has no ${guards.authentication.name}`,
137
+ );
138
+ if (tier === "authenticated" && ability)
139
+ offenders.push(
140
+ `${route} — declares @Authenticated() but is behind ${guards.ability.name}; use @CheckAbility instead`,
141
+ );
90
142
  if (tier === "ability" && !(authenticated && ability))
91
- offenders.push(`${route} — declares @CheckAbility but is not behind ${guards.authentication.name} + ${guards.ability.name}`);
143
+ offenders.push(
144
+ `${route} — declares @CheckAbility but is not behind ${guards.authentication.name} + ${guards.ability.name}`,
145
+ );
92
146
  }
93
147
  }
94
148
 
@@ -0,0 +1 @@
1
+ npx --no -- commitlint --edit "$1"
@@ -0,0 +1 @@
1
+ npx lint-staged
@@ -0,0 +1,4 @@
1
+ {
2
+ "*.{ts,tsx,js,jsx}": "eslint --fix",
3
+ "*.{ts,tsx,js,jsx,json,md,css,scss}": "prettier --write"
4
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "arrowParens": "avoid",
3
+ "singleQuote": true,
4
+ "trailingComma": "all"
5
+ }
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ extends: ['@commitlint/config-conventional'],
3
+ };
@@ -0,0 +1,19 @@
1
+ const { defineConfig } = require('eslint/config');
2
+ const reactNativeConfig = require('@react-native/eslint-config/flat');
3
+
4
+ module.exports = defineConfig([
5
+ {
6
+ ignores: ['node_modules/**', 'ios/**', 'android/**', 'coverage/**'],
7
+ },
8
+ ...reactNativeConfig,
9
+ {
10
+ // This app is TypeScript-only, no Flow — and eslint-plugin-ft-flow (pulled in by
11
+ // @react-native/eslint-config/flat for the .js override) calls the removed
12
+ // context.getAllComments() API under ESLint 9.3x+, crashing the linter on every .js file.
13
+ files: ['**/*.js'],
14
+ rules: {
15
+ 'ft-flow/define-flow-type': 'off',
16
+ 'ft-flow/use-flow-type': 'off',
17
+ },
18
+ },
19
+ ]);
@@ -7,6 +7,7 @@
7
7
  "android": "react-native run-android",
8
8
  "ios": "react-native run-ios",
9
9
  "lint": "eslint .",
10
+ "prepare": "husky",
10
11
  "start": "react-native start",
11
12
  "test": "jest",
12
13
  "typecheck": "tsc --noEmit"
@@ -14,12 +15,12 @@
14
15
  "dependencies": {
15
16
  "@simple-auth-kit/auth-client": "^1.0.2",
16
17
  "@react-native-async-storage/async-storage": "^3.1.1",
17
- "@react-native/new-app-screen": "0.86.2",
18
+ "@react-native/new-app-screen": "0.86.3",
18
19
  "@react-navigation/native": "^7.3.16",
19
20
  "@react-navigation/native-stack": "^7.18.8",
20
21
  "nativewind": "5.0.0-preview.4",
21
22
  "react": "19.2.3",
22
- "react-native": "0.86.2",
23
+ "react-native": "0.86.3",
23
24
  "react-native-config": "^1.6.1",
24
25
  "react-native-css": "^3.0.7",
25
26
  "react-native-gesture-handler": "^3.1.0",
@@ -35,20 +36,24 @@
35
36
  "@babel/preset-env": "^7.25.3",
36
37
  "@babel/runtime": "^7.25.0",
37
38
  "@expo/metro-config": "^57.0.7",
38
- "@react-native-community/cli": "20.1.0",
39
- "@react-native-community/cli-platform-android": "20.1.0",
40
- "@react-native-community/cli-platform-ios": "20.1.0",
41
- "@react-native/babel-preset": "0.86.2",
42
- "@react-native/eslint-config": "0.86.2",
43
- "@react-native/jest-preset": "0.86.2",
44
- "@react-native/metro-config": "0.86.2",
45
- "@react-native/typescript-config": "0.86.2",
39
+ "@react-native-community/cli": "20.2.0",
40
+ "@react-native-community/cli-platform-android": "20.2.0",
41
+ "@react-native-community/cli-platform-ios": "20.2.0",
42
+ "@react-native/babel-preset": "0.86.3",
43
+ "@react-native/eslint-config": "0.86.3",
44
+ "@react-native/jest-preset": "0.86.3",
45
+ "@react-native/metro-config": "0.86.3",
46
+ "@react-native/typescript-config": "0.86.3",
46
47
  "@types/jest": "^29.5.13",
47
48
  "@types/react": "^19.2.0",
48
49
  "@types/react-test-renderer": "^19.1.0",
49
- "eslint": "^8.19.0",
50
+ "@commitlint/cli": "^21.2.2",
51
+ "@commitlint/config-conventional": "^21.2.2",
52
+ "eslint": "^9.39.5",
53
+ "husky": "^9.1.7",
50
54
  "jest": "^29.6.3",
51
- "prettier": "2.8.8",
55
+ "lint-staged": "^17.5.1",
56
+ "prettier": "^3.9.6",
52
57
  "react-test-renderer": "19.2.3",
53
58
  "typescript": "^5.8.3"
54
59
  },
@@ -7,6 +7,7 @@
7
7
  "android": "react-native run-android",
8
8
  "ios": "react-native run-ios",
9
9
  "lint": "eslint .",
10
+ "prepare": "husky",
10
11
  "start": "react-native start",
11
12
  "test": "jest",
12
13
  "typecheck": "tsc --noEmit"
@@ -14,12 +15,12 @@
14
15
  "dependencies": {
15
16
  "@simple-auth-kit/auth-client": "^1.0.2",
16
17
  "@react-native-async-storage/async-storage": "^3.1.1",
17
- "@react-native/new-app-screen": "0.86.2",
18
+ "@react-native/new-app-screen": "0.86.3",
18
19
  "@react-navigation/native": "^7.3.16",
19
20
  "@react-navigation/native-stack": "^7.18.8",
20
21
  "nativewind": "5.0.0-preview.4",
21
22
  "react": "19.2.3",
22
- "react-native": "0.86.2",
23
+ "react-native": "0.86.3",
23
24
  "react-native-config": "^1.6.1",
24
25
  "react-native-css": "^3.0.7",
25
26
  "react-native-gesture-handler": "^3.1.0",
@@ -35,20 +36,24 @@
35
36
  "@babel/preset-env": "^7.25.3",
36
37
  "@babel/runtime": "^7.25.0",
37
38
  "@expo/metro-config": "^57.0.7",
38
- "@react-native-community/cli": "20.1.0",
39
- "@react-native-community/cli-platform-android": "20.1.0",
40
- "@react-native-community/cli-platform-ios": "20.1.0",
41
- "@react-native/babel-preset": "0.86.2",
42
- "@react-native/eslint-config": "0.86.2",
43
- "@react-native/jest-preset": "0.86.2",
44
- "@react-native/metro-config": "0.86.2",
45
- "@react-native/typescript-config": "0.86.2",
39
+ "@react-native-community/cli": "20.2.0",
40
+ "@react-native-community/cli-platform-android": "20.2.0",
41
+ "@react-native-community/cli-platform-ios": "20.2.0",
42
+ "@react-native/babel-preset": "0.86.3",
43
+ "@react-native/eslint-config": "0.86.3",
44
+ "@react-native/jest-preset": "0.86.3",
45
+ "@react-native/metro-config": "0.86.3",
46
+ "@react-native/typescript-config": "0.86.3",
46
47
  "@types/jest": "^29.5.13",
47
48
  "@types/react": "^19.2.0",
48
49
  "@types/react-test-renderer": "^19.1.0",
49
- "eslint": "^8.19.0",
50
+ "@commitlint/cli": "^21.2.2",
51
+ "@commitlint/config-conventional": "^21.2.2",
52
+ "eslint": "^9.39.5",
53
+ "husky": "^9.1.7",
50
54
  "jest": "^29.6.3",
51
- "prettier": "2.8.8",
55
+ "lint-staged": "^17.5.1",
56
+ "prettier": "^3.9.6",
52
57
  "react-test-renderer": "19.2.3",
53
58
  "typescript": "^5.8.3"
54
59
  },
@@ -0,0 +1 @@
1
+ npx --no -- commitlint --edit "$1"
@@ -0,0 +1 @@
1
+ npx lint-staged