@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
@@ -12,9 +12,17 @@ import { randomUUID } from "node:crypto";
12
12
  import { Get } from "@nestjs/common";
13
13
  import { generateTotpCode } from "@/lib/auth/core/two-factor.js";
14
14
  import { ABILITY_SUBJECT, defineAbilitiesFor } from "../src/ability.js";
15
- import { AuthController } from "../src/auth.controller.js";
15
+ import { AuthController } from "../src/controllers/auth.controller.js";
16
16
  import { bootstrap, capturedResetTokens } from "./bootstrap.js";
17
- import { adminRouteProbes, AuthTokens, BASE_PORT, CallOptions, decodeJwtPayload, ProofContext, RouteProbe } from "./harness.js";
17
+ import {
18
+ adminRouteProbes,
19
+ AuthTokens,
20
+ BASE_PORT,
21
+ CallOptions,
22
+ decodeJwtPayload,
23
+ ProofContext,
24
+ RouteProbe,
25
+ } from "./harness.js";
18
26
  import { hooks } from "./variant-hooks.js";
19
27
 
20
28
  const BASE = `http://localhost:${BASE_PORT}`;
@@ -29,7 +37,11 @@ function assert(condition: boolean, message: string) {
29
37
  }
30
38
  }
31
39
 
32
- async function call(method: string, path: string, opts: CallOptions = {}): Promise<{ status: number; body: any }> {
40
+ async function call(
41
+ method: string,
42
+ path: string,
43
+ opts: CallOptions = {},
44
+ ): Promise<{ status: number; body: any }> {
33
45
  const res = await fetch(`${BASE}${path}`, {
34
46
  method,
35
47
  headers: {
@@ -45,7 +57,10 @@ async function call(method: string, path: string, opts: CallOptions = {}): Promi
45
57
  // message, ...}` (error) — unwrap `data` here, once, so every assertion below reads the actual
46
58
  // payload without knowing the envelope exists. Error bodies have no `data` key, so they pass
47
59
  // through unchanged.
48
- const body = envelope && typeof envelope === "object" && "data" in envelope ? envelope.data : envelope;
60
+ const body =
61
+ envelope && typeof envelope === "object" && "data" in envelope
62
+ ? envelope.data
63
+ : envelope;
49
64
  return { status: res.status, body: body as any };
50
65
  }
51
66
 
@@ -56,7 +71,10 @@ const ROLE_SLUG = `billing-manager-${RUN_ID}`;
56
71
 
57
72
  async function signup(email: string, password: string): Promise<AuthTokens> {
58
73
  const res = await call("POST", "/auth/signup", { body: { email, password } });
59
- if (res.status !== 200 && res.status !== 201) throw new Error(`signup failed (${res.status}): ${JSON.stringify(res.body)}`);
74
+ if (res.status !== 200 && res.status !== 201)
75
+ throw new Error(
76
+ `signup failed (${res.status}): ${JSON.stringify(res.body)}`,
77
+ );
60
78
  return res.body as AuthTokens;
61
79
  }
62
80
 
@@ -72,17 +90,31 @@ const ctx: ProofContext = { call, assert, signup, uniqueEmail };
72
90
  * proves that the running application refuses to start.
73
91
  */
74
92
  function addUntieredRoute(): () => void {
75
- const prototype = AuthController.prototype as unknown as Record<string, unknown>;
93
+ const prototype = AuthController.prototype as unknown as Record<
94
+ string,
95
+ unknown
96
+ >;
76
97
  const name = "untieredProofRoute";
77
- Object.defineProperty(prototype, name, { value: () => ({ ok: true }), configurable: true, writable: true, enumerable: false });
78
- Get("__tier-proof")(prototype, name, Object.getOwnPropertyDescriptor(prototype, name)!);
98
+ Object.defineProperty(prototype, name, {
99
+ value: () => ({ ok: true }),
100
+ configurable: true,
101
+ writable: true,
102
+ enumerable: false,
103
+ });
104
+ Get("__tier-proof")(
105
+ prototype,
106
+ name,
107
+ Object.getOwnPropertyDescriptor(prototype, name)!,
108
+ );
79
109
  return () => {
80
110
  delete prototype[name];
81
111
  };
82
112
  }
83
113
 
84
114
  async function main() {
85
- console.log("0. fail-closed at startup: a route with no tier marker stops the app from booting");
115
+ console.log(
116
+ "0. fail-closed at startup: a route with no tier marker stops the app from booting",
117
+ );
86
118
  const removeUntieredRoute = addUntieredRoute();
87
119
  let bootError: unknown;
88
120
  try {
@@ -92,24 +124,40 @@ async function main() {
92
124
  bootError = err;
93
125
  }
94
126
  removeUntieredRoute();
95
- const bootMessage = bootError instanceof Error ? bootError.message : String(bootError);
96
- assert(bootError !== undefined, "adding a route that declares no @Public/@Authenticated/@CheckAbility makes the app refuse to boot");
127
+ const bootMessage =
128
+ bootError instanceof Error ? bootError.message : String(bootError);
97
129
  assert(
98
- bootMessage.includes("GET /auth/__tier-proof") && bootMessage.includes("AuthController.untieredProofRoute"),
130
+ bootError !== undefined,
131
+ "adding a route that declares no @Public/@Authenticated/@CheckAbility makes the app refuse to boot",
132
+ );
133
+ assert(
134
+ bootMessage.includes("GET /auth/__tier-proof") &&
135
+ bootMessage.includes("AuthController.untieredProofRoute"),
99
136
  `…and the error names the offending route (got: ${bootMessage.split("\n")[0]})`,
100
137
  );
101
138
 
102
139
  const app = await bootstrap(BASE_PORT);
103
- assert(true, "…and with the route removed again, the app boots — the check is about the marker, not the boot path");
140
+ assert(
141
+ true,
142
+ "…and with the route removed again, the app boots — the check is about the marker, not the boot path",
143
+ );
104
144
  console.log(`variant: ${hooks.variant}\n`);
105
145
 
106
146
  try {
107
147
  console.log("1. signup (session A)");
108
148
  const emailA = uniqueEmail("a");
109
- const signupA = await call("POST", "/auth/signup", { body: { email: emailA, password: "correct-horse-battery" } });
110
- assert(signupA.status === 201 || signupA.status === 200, `signup succeeds with only email + password in the body (got ${signupA.status})`);
149
+ const signupA = await call("POST", "/auth/signup", {
150
+ body: { email: emailA, password: "correct-horse-battery" },
151
+ });
152
+ assert(
153
+ signupA.status === 201 || signupA.status === 200,
154
+ `signup succeeds with only email + password in the body (got ${signupA.status})`,
155
+ );
111
156
  const tokensA = signupA.body as AuthTokens;
112
- assert(!!tokensA.accessToken && !!tokensA.refreshToken, "signup returns access + refresh tokens");
157
+ assert(
158
+ !!tokensA.accessToken && !!tokensA.refreshToken,
159
+ "signup returns access + refresh tokens",
160
+ );
113
161
 
114
162
  // Asserted as an exact set rather than a list of absences, and kept exact on purpose: this is
115
163
  // the assertion that stops authorization data creeping back into the token later. There is no
@@ -121,36 +169,76 @@ async function main() {
121
169
  Object.keys(claimsA).sort().join(",") === "exp,iat,jti,sessionId,sub",
122
170
  `the access token carries exactly the expected claims — identity and session only (got ${Object.keys(claimsA).sort().join(",")})`,
123
171
  );
124
- assert(claimsA.sub === (await call("GET", "/auth/me", { token: tokensA.accessToken })).body.sub, "the token's subject is the user");
172
+ assert(
173
+ claimsA.sub ===
174
+ (await call("GET", "/auth/me", { token: tokensA.accessToken })).body
175
+ .sub,
176
+ "the token's subject is the user",
177
+ );
125
178
 
126
- const meSignup = await call("GET", "/auth/me", { token: tokensA.accessToken });
179
+ const meSignup = await call("GET", "/auth/me", {
180
+ token: tokensA.accessToken,
181
+ });
127
182
  assert(
128
- hooks.variant === "workspaces" ? meSignup.body.roles.length === 0 : meSignup.body.roles.length > 0,
183
+ hooks.variant === "workspaces"
184
+ ? meSignup.body.roles.length === 0
185
+ : meSignup.body.roles.length > 0,
129
186
  `a new signup's roles come from the database, not the token (got ${JSON.stringify(meSignup.body.roles)})`,
130
187
  );
131
188
 
132
189
  console.log("2. login (session B, a second device for the same user)");
133
- const loginB = await call("POST", "/auth/login", { body: { identifier: emailA, password: "correct-horse-battery" } });
190
+ const loginB = await call("POST", "/auth/login", {
191
+ body: { identifier: emailA, password: "correct-horse-battery" },
192
+ });
134
193
  const tokensB = loginB.body as AuthTokens;
135
- assert(loginB.status === 201 || loginB.status === 200, `login succeeds (got ${loginB.status})`);
136
- assert(tokensB.sessionId !== tokensA.sessionId, "login creates a distinct session from signup's");
194
+ assert(
195
+ loginB.status === 201 || loginB.status === 200,
196
+ `login succeeds (got ${loginB.status})`,
197
+ );
198
+ assert(
199
+ tokensB.sessionId !== tokensA.sessionId,
200
+ "login creates a distinct session from signup's",
201
+ );
137
202
 
138
203
  console.log("2b. login with wrong password is rejected");
139
- const badLogin = await call("POST", "/auth/login", { body: { identifier: emailA, password: "wrong" } });
140
- assert(badLogin.status === 401, `wrong password rejected (got ${badLogin.status})`);
204
+ const badLogin = await call("POST", "/auth/login", {
205
+ body: { identifier: emailA, password: "wrong" },
206
+ });
207
+ assert(
208
+ badLogin.status === 401,
209
+ `wrong password rejected (got ${badLogin.status})`,
210
+ );
141
211
 
142
- console.log("2c. a second signup with the same email is rejected — email is globally unique");
143
- const duplicate = await call("POST", "/auth/signup", { body: { email: emailA, password: "another-password-1" } });
144
- assert(duplicate.status === 409, `duplicate email rejected (got ${duplicate.status})`);
212
+ console.log(
213
+ "2c. a second signup with the same email is rejected — email is globally unique",
214
+ );
215
+ const duplicate = await call("POST", "/auth/signup", {
216
+ body: { email: emailA, password: "another-password-1" },
217
+ });
218
+ assert(
219
+ duplicate.status === 409,
220
+ `duplicate email rejected (got ${duplicate.status})`,
221
+ );
145
222
 
146
- console.log("3. protected route: GET /auth/me with session A's access token");
223
+ console.log(
224
+ "3. protected route: GET /auth/me with session A's access token",
225
+ );
147
226
  const meA = await call("GET", "/auth/me", { token: tokensA.accessToken });
148
- assert(meA.status === 200, `me succeeds with a valid token (got ${meA.status})`);
149
- assert(meA.body?.sessionId === tokensA.sessionId, "me reflects session A's identity");
227
+ assert(
228
+ meA.status === 200,
229
+ `me succeeds with a valid token (got ${meA.status})`,
230
+ );
231
+ assert(
232
+ meA.body?.sessionId === tokensA.sessionId,
233
+ "me reflects session A's identity",
234
+ );
150
235
 
151
236
  console.log("3b. protected route rejects a missing token");
152
237
  const meNoAuth = await call("GET", "/auth/me");
153
- assert(meNoAuth.status === 401, `me rejects no token (got ${meNoAuth.status})`);
238
+ assert(
239
+ meNoAuth.status === 401,
240
+ `me rejects no token (got ${meNoAuth.status})`,
241
+ );
154
242
 
155
243
  console.log("3c. the three route tiers, from the outside");
156
244
  // Tier 1: no token at all. Each of these is marked @Public() in the source; the assertion is
@@ -159,211 +247,487 @@ async function main() {
159
247
  // running, not the guard refusing.
160
248
  const MISSING_TOKEN = "missing bearer token";
161
249
  const publicProbes: Array<[string, string, unknown]> = [
162
- ["POST", "/auth/login", { identifier: emailA, password: "wrong-on-purpose" }],
250
+ [
251
+ "POST",
252
+ "/auth/login",
253
+ { identifier: emailA, password: "wrong-on-purpose" },
254
+ ],
163
255
  ["POST", "/auth/refresh", { refreshToken: "not-a-real-token" }],
164
256
  ["POST", "/auth/password/forgot", { email: uniqueEmail("nobody") }],
165
257
  ["POST", "/auth/login/2fa", { challengeToken: "nope", code: "000000" }],
166
- ["POST", "/auth/password/reset", { token: "nope", newPassword: "irrelevant-1" }],
258
+ [
259
+ "POST",
260
+ "/auth/password/reset",
261
+ { token: "nope", newPassword: "irrelevant-1" },
262
+ ],
167
263
  ["GET", "/auth/oauth/google/start", undefined],
168
264
  ];
169
265
  for (const [method, path, body] of publicProbes) {
170
266
  const res = await call(method, path, { body });
171
- assert(res.body?.message !== MISSING_TOKEN, `${method} ${path} is reachable with no token (got ${res.status} ${res.body?.message ?? ""})`);
267
+ assert(
268
+ res.body?.message !== MISSING_TOKEN,
269
+ `${method} ${path} is reachable with no token (got ${res.status} ${res.body?.message ?? ""})`,
270
+ );
172
271
  }
173
272
 
174
273
  // Tier 2: any authenticated caller, no permission. `tokensA` holds no administrative
175
274
  // permission whatsoever, and these must still answer.
176
275
  for (const path of ["/auth/me", "/auth/sessions"]) {
177
276
  const authed = await call("GET", path, { token: tokensA.accessToken });
178
- assert(authed.status === 200, `GET ${path} is open to any authenticated user (got ${authed.status})`);
277
+ assert(
278
+ authed.status === 200,
279
+ `GET ${path} is open to any authenticated user (got ${authed.status})`,
280
+ );
179
281
  const anon = await call("GET", path);
180
- assert(anon.status === 401, `…and refuses an anonymous caller (got ${anon.status})`);
282
+ assert(
283
+ anon.status === 401,
284
+ `…and refuses an anonymous caller (got ${anon.status})`,
285
+ );
181
286
  }
182
287
 
183
288
  console.log("4. refresh session A's token");
184
- const refreshed = await call("POST", "/auth/refresh", { body: { refreshToken: tokensA.refreshToken } });
185
- assert(refreshed.status === 200 || refreshed.status === 201, `refresh succeeds (got ${refreshed.status})`);
186
- const rotatedA = refreshed.body as { accessToken: string; refreshToken: string };
187
- assert(rotatedA.refreshToken !== tokensA.refreshToken, "refresh issues a new refresh token (rotation)");
289
+ const refreshed = await call("POST", "/auth/refresh", {
290
+ body: { refreshToken: tokensA.refreshToken },
291
+ });
292
+ assert(
293
+ refreshed.status === 200 || refreshed.status === 201,
294
+ `refresh succeeds (got ${refreshed.status})`,
295
+ );
296
+ const rotatedA = refreshed.body as {
297
+ accessToken: string;
298
+ refreshToken: string;
299
+ };
300
+ assert(
301
+ rotatedA.refreshToken !== tokensA.refreshToken,
302
+ "refresh issues a new refresh token (rotation)",
303
+ );
188
304
 
189
- console.log("4b. reuse of the now-dead original refresh token is detected and kills the whole session family");
190
- const reuse = await call("POST", "/auth/refresh", { body: { refreshToken: tokensA.refreshToken } });
191
- assert(reuse.status === 401, `stale/reused refresh token rejected (got ${reuse.status})`);
305
+ console.log(
306
+ "4b. reuse of the now-dead original refresh token is detected and kills the whole session family",
307
+ );
308
+ const reuse = await call("POST", "/auth/refresh", {
309
+ body: { refreshToken: tokensA.refreshToken },
310
+ });
311
+ assert(
312
+ reuse.status === 401,
313
+ `stale/reused refresh token rejected (got ${reuse.status})`,
314
+ );
192
315
 
193
- const reuseFalloutB = await call("POST", "/auth/refresh", { body: { refreshToken: tokensB.refreshToken } });
194
- assert(reuseFalloutB.status === 401, `reuse detection also killed session B's refresh (got ${reuseFalloutB.status})`);
316
+ const reuseFalloutB = await call("POST", "/auth/refresh", {
317
+ body: { refreshToken: tokensB.refreshToken },
318
+ });
319
+ assert(
320
+ reuseFalloutB.status === 401,
321
+ `reuse detection also killed session B's refresh (got ${reuseFalloutB.status})`,
322
+ );
195
323
 
196
324
  // Self-contained on purpose: the reuse detection just above kills every session belonging to
197
325
  // user A, so anything asserting on a *live* session has to bring its own.
198
- console.log("4c. a session's lifetime: an absolute expiry that refreshing does not extend");
199
- type LiveSession = { id: string; createdAt: string; expiresAt: string; provider?: string };
326
+ console.log(
327
+ "4c. a session's lifetime: an absolute expiry that refreshing does not extend",
328
+ );
329
+ type LiveSession = {
330
+ id: string;
331
+ createdAt: string;
332
+ expiresAt: string;
333
+ provider?: string;
334
+ };
200
335
  const lifetimeEmail = uniqueEmail("lifetime");
201
336
  const lifetimeFirst = await signup(lifetimeEmail, "lifetime-pw-123");
202
- const lifetimeSessionId = decodeJwtPayload(lifetimeFirst.accessToken).sessionId as string;
337
+ const lifetimeSessionId = decodeJwtPayload(lifetimeFirst.accessToken)
338
+ .sessionId as string;
203
339
 
204
- const listSessions = async (token: string) => (await call("GET", "/auth/sessions", { token })).body as LiveSession[];
205
- const live = (await listSessions(lifetimeFirst.accessToken)).find((s) => s.id === lifetimeSessionId);
340
+ const listSessions = async (token: string) =>
341
+ (await call("GET", "/auth/sessions", { token })).body as LiveSession[];
342
+ const live = (await listSessions(lifetimeFirst.accessToken)).find(
343
+ (s) => s.id === lifetimeSessionId,
344
+ );
206
345
  assert(!!live, "the caller's own session is listed");
207
346
  assert(
208
- !!live && new Date(live.expiresAt).getTime() > new Date(live.createdAt).getTime(),
347
+ !!live &&
348
+ new Date(live.expiresAt).getTime() > new Date(live.createdAt).getTime(),
209
349
  `a session expires strictly after it was created (got ${live?.createdAt} → ${live?.expiresAt})`,
210
350
  );
211
- assert(!live?.provider, "a password login records no OAuth provider on the session");
351
+ assert(
352
+ !live?.provider,
353
+ "a password login records no OAuth provider on the session",
354
+ );
212
355
 
213
356
  // The point of `expires_at` being written once and never rewritten: rotation issues a fresh
214
357
  // refresh token every time, so without an absolute cap a continuously active client would hold
215
358
  // one session open forever.
216
- const lifetimeRotated = (await call("POST", "/auth/refresh", { body: { refreshToken: lifetimeFirst.refreshToken } })).body as AuthTokens;
217
- const afterRefresh = (await listSessions(lifetimeRotated.accessToken)).find((s) => s.id === lifetimeSessionId);
218
- assert(afterRefresh?.expiresAt === live?.expiresAt, `refreshing does not push the session's expiry out (got ${afterRefresh?.expiresAt})`);
359
+ const lifetimeRotated = (
360
+ await call("POST", "/auth/refresh", {
361
+ body: { refreshToken: lifetimeFirst.refreshToken },
362
+ })
363
+ ).body as AuthTokens;
364
+ const afterRefresh = (await listSessions(lifetimeRotated.accessToken)).find(
365
+ (s) => s.id === lifetimeSessionId,
366
+ );
367
+ assert(
368
+ afterRefresh?.expiresAt === live?.expiresAt,
369
+ `refreshing does not push the session's expiry out (got ${afterRefresh?.expiresAt})`,
370
+ );
219
371
 
220
372
  // A second device for the same user, so the list can be read after the first one is revoked.
221
- const lifetimeSecond = (await call("POST", "/auth/login", { body: { identifier: lifetimeEmail, password: "lifetime-pw-123" } })).body as AuthTokens;
222
- assert((await listSessions(lifetimeSecond.accessToken)).some((s) => s.id === lifetimeSessionId), "a second device sees the first device's session");
373
+ const lifetimeSecond = (
374
+ await call("POST", "/auth/login", {
375
+ body: { identifier: lifetimeEmail, password: "lifetime-pw-123" },
376
+ })
377
+ ).body as AuthTokens;
378
+ assert(
379
+ (await listSessions(lifetimeSecond.accessToken)).some(
380
+ (s) => s.id === lifetimeSessionId,
381
+ ),
382
+ "a second device sees the first device's session",
383
+ );
223
384
 
224
385
  await call("POST", "/auth/logout", { token: lifetimeRotated.accessToken });
225
386
  // Revocation is never a delete: the row survives with `is_revoked` set, and what changes is
226
387
  // that it stops counting as active.
227
388
  assert(
228
- !(await listSessions(lifetimeSecond.accessToken)).some((s) => s.id === lifetimeSessionId),
389
+ !(await listSessions(lifetimeSecond.accessToken)).some(
390
+ (s) => s.id === lifetimeSessionId,
391
+ ),
229
392
  "a revoked session stops being listed as active",
230
393
  );
231
394
 
232
- console.log("5. logout-one-device: revoke session A specifically using its (rotated) access token");
233
- const logoutA = await call("POST", "/auth/logout", { token: rotatedA.accessToken });
234
- assert(logoutA.status === 200 || logoutA.status === 201, `logout succeeds (got ${logoutA.status})`);
395
+ console.log(
396
+ "5. logout-one-device: revoke session A specifically using its (rotated) access token",
397
+ );
398
+ const logoutA = await call("POST", "/auth/logout", {
399
+ token: rotatedA.accessToken,
400
+ });
401
+ assert(
402
+ logoutA.status === 200 || logoutA.status === 201,
403
+ `logout succeeds (got ${logoutA.status})`,
404
+ );
235
405
 
236
- const meAAfterLogout = await call("GET", "/auth/me", { token: rotatedA.accessToken });
237
- assert(meAAfterLogout.status === 401, `session A's access token is denylisted after logout (got ${meAAfterLogout.status})`);
406
+ const meAAfterLogout = await call("GET", "/auth/me", {
407
+ token: rotatedA.accessToken,
408
+ });
409
+ assert(
410
+ meAAfterLogout.status === 401,
411
+ `session A's access token is denylisted after logout (got ${meAAfterLogout.status})`,
412
+ );
238
413
 
239
414
  console.log("6. block/unblock (admin-scoped)");
240
415
  const adminEmail = uniqueEmail("admin");
241
416
  const adminSignup = await signup(adminEmail, "admin-pw-123");
242
- const adminUserId = (await call("GET", "/auth/me", { token: adminSignup.accessToken })).body.sub as string;
243
- const admin = await hooks.makeAdmin(ctx, { userId: adminUserId, email: adminEmail, password: "admin-pw-123", tokens: adminSignup });
417
+ const adminUserId = (
418
+ await call("GET", "/auth/me", { token: adminSignup.accessToken })
419
+ ).body.sub as string;
420
+ const admin = await hooks.makeAdmin(ctx, {
421
+ userId: adminUserId,
422
+ email: adminEmail,
423
+ password: "admin-pw-123",
424
+ tokens: adminSignup,
425
+ });
244
426
 
245
427
  const victimEmail = uniqueEmail("victim");
246
428
  const victimTokens = await signup(victimEmail, "victim-pw-123");
247
- const victimId = (await call("GET", "/auth/me", { token: victimTokens.accessToken })).body.sub as string;
429
+ const victimId = (
430
+ await call("GET", "/auth/me", { token: victimTokens.accessToken })
431
+ ).body.sub as string;
248
432
  await hooks.admitUser(ctx, admin, { userId: victimId, email: victimEmail });
249
433
 
250
434
  console.log("6a. a non-admin cannot block anyone");
251
- const forbiddenBlock = await call("POST", `/auth/admin/users/${victimId}/block`, { token: victimTokens.accessToken, workspaceId: admin.workspaceId });
252
- assert(forbiddenBlock.status === 403, `non-admin block attempt rejected (got ${forbiddenBlock.status})`);
435
+ const forbiddenBlock = await call(
436
+ "POST",
437
+ `/auth/admin/users/${victimId}/block`,
438
+ { token: victimTokens.accessToken, workspaceId: admin.workspaceId },
439
+ );
440
+ assert(
441
+ forbiddenBlock.status === 403,
442
+ `non-admin block attempt rejected (got ${forbiddenBlock.status})`,
443
+ );
253
444
 
254
445
  console.log("6b. an admin cannot block themselves");
255
- const selfBlock = await call("POST", `/auth/admin/users/${adminUserId}/block`, { token: await admin.freshToken(), workspaceId: admin.workspaceId });
256
- assert(selfBlock.status === 403, `admin self-block rejected (got ${selfBlock.status})`);
446
+ const selfBlock = await call(
447
+ "POST",
448
+ `/auth/admin/users/${adminUserId}/block`,
449
+ { token: await admin.freshToken(), workspaceId: admin.workspaceId },
450
+ );
451
+ assert(
452
+ selfBlock.status === 403,
453
+ `admin self-block rejected (got ${selfBlock.status})`,
454
+ );
257
455
 
258
456
  console.log("6b-ii. an admin cannot revoke their own role");
259
457
  // Self-revoking `admin` strips the permission that authorised the call. In a workspace
260
458
  // there is no way back in afterwards, so the route refuses it the same way self-block does.
261
- const selfRevoke = await call("POST", `/auth/admin/users/${adminUserId}/roles/admin/revoke`, { token: await admin.freshToken(), workspaceId: admin.workspaceId });
262
- assert(selfRevoke.status === 403, `admin self-role-revoke rejected (got ${selfRevoke.status})`);
263
- const stillAdmin = await call("GET", "/auth/admin/users", { token: await admin.freshToken(), workspaceId: admin.workspaceId });
264
- assert(stillAdmin.status === 200, `admin still authorised after the refused self-revoke (got ${stillAdmin.status})`);
459
+ const selfRevoke = await call(
460
+ "POST",
461
+ `/auth/admin/users/${adminUserId}/roles/admin/revoke`,
462
+ { token: await admin.freshToken(), workspaceId: admin.workspaceId },
463
+ );
464
+ assert(
465
+ selfRevoke.status === 403,
466
+ `admin self-role-revoke rejected (got ${selfRevoke.status})`,
467
+ );
468
+ const stillAdmin = await call("GET", "/auth/admin/users", {
469
+ token: await admin.freshToken(),
470
+ workspaceId: admin.workspaceId,
471
+ });
472
+ assert(
473
+ stillAdmin.status === 200,
474
+ `admin still authorised after the refused self-revoke (got ${stillAdmin.status})`,
475
+ );
265
476
 
266
477
  console.log("6c. admin blocks the victim");
267
- const block = await call("POST", `/auth/admin/users/${victimId}/block`, { token: await admin.freshToken(), workspaceId: admin.workspaceId });
268
- assert(block.status === 200 || block.status === 201, `block succeeds (got ${block.status})`);
478
+ const block = await call("POST", `/auth/admin/users/${victimId}/block`, {
479
+ token: await admin.freshToken(),
480
+ workspaceId: admin.workspaceId,
481
+ });
482
+ assert(
483
+ block.status === 200 || block.status === 201,
484
+ `block succeeds (got ${block.status})`,
485
+ );
269
486
 
270
- console.log("7. get-rejected-after-block: victim cannot log in or refresh anymore");
271
- const victimLoginAfterBlock = await call("POST", "/auth/login", { body: { identifier: victimEmail, password: "victim-pw-123" } });
272
- assert(victimLoginAfterBlock.status === 401, `blocked user cannot log in (got ${victimLoginAfterBlock.status})`);
487
+ console.log(
488
+ "7. get-rejected-after-block: victim cannot log in or refresh anymore",
489
+ );
490
+ const victimLoginAfterBlock = await call("POST", "/auth/login", {
491
+ body: { identifier: victimEmail, password: "victim-pw-123" },
492
+ });
493
+ assert(
494
+ victimLoginAfterBlock.status === 401,
495
+ `blocked user cannot log in (got ${victimLoginAfterBlock.status})`,
496
+ );
273
497
 
274
- const victimRefreshAfterBlock = await call("POST", "/auth/refresh", { body: { refreshToken: victimTokens.refreshToken } });
275
- assert(victimRefreshAfterBlock.status === 401, `blocked user's refresh token rejected (got ${victimRefreshAfterBlock.status})`);
498
+ const victimRefreshAfterBlock = await call("POST", "/auth/refresh", {
499
+ body: { refreshToken: victimTokens.refreshToken },
500
+ });
501
+ assert(
502
+ victimRefreshAfterBlock.status === 401,
503
+ `blocked user's refresh token rejected (got ${victimRefreshAfterBlock.status})`,
504
+ );
276
505
 
277
506
  console.log("7b. unblock restores access");
278
- const unblock = await call("POST", `/auth/admin/users/${victimId}/unblock`, { token: await admin.freshToken(), workspaceId: admin.workspaceId });
279
- assert(unblock.status === 200 || unblock.status === 201, `unblock succeeds (got ${unblock.status})`);
280
- const victimLoginAfterUnblock = await call("POST", "/auth/login", { body: { identifier: victimEmail, password: "victim-pw-123" } });
507
+ const unblock = await call(
508
+ "POST",
509
+ `/auth/admin/users/${victimId}/unblock`,
510
+ { token: await admin.freshToken(), workspaceId: admin.workspaceId },
511
+ );
281
512
  assert(
282
- victimLoginAfterUnblock.status === 200 || victimLoginAfterUnblock.status === 201,
513
+ unblock.status === 200 || unblock.status === 201,
514
+ `unblock succeeds (got ${unblock.status})`,
515
+ );
516
+ const victimLoginAfterUnblock = await call("POST", "/auth/login", {
517
+ body: { identifier: victimEmail, password: "victim-pw-123" },
518
+ });
519
+ assert(
520
+ victimLoginAfterUnblock.status === 200 ||
521
+ victimLoginAfterUnblock.status === 201,
283
522
  `unblocked user can log in again (got ${victimLoginAfterUnblock.status})`,
284
523
  );
285
524
 
286
- console.log("7c. deactivate is a distinct toggle from block — both independently deny login, neither implies the other");
287
- const deactivate = await call("POST", `/auth/admin/users/${victimId}/deactivate`, { token: await admin.freshToken(), workspaceId: admin.workspaceId });
288
- assert(deactivate.status === 200 || deactivate.status === 201, `deactivate succeeds (got ${deactivate.status})`);
525
+ console.log(
526
+ "7c. deactivate is a distinct toggle from block both independently deny login, neither implies the other",
527
+ );
528
+ const deactivate = await call(
529
+ "POST",
530
+ `/auth/admin/users/${victimId}/deactivate`,
531
+ { token: await admin.freshToken(), workspaceId: admin.workspaceId },
532
+ );
533
+ assert(
534
+ deactivate.status === 200 || deactivate.status === 201,
535
+ `deactivate succeeds (got ${deactivate.status})`,
536
+ );
289
537
 
290
- const victimLoginAfterDeactivate = await call("POST", "/auth/login", { body: { identifier: victimEmail, password: "victim-pw-123" } });
291
- assert(victimLoginAfterDeactivate.status === 401, `deactivated (but not blocked) user cannot log in either (got ${victimLoginAfterDeactivate.status})`);
538
+ const victimLoginAfterDeactivate = await call("POST", "/auth/login", {
539
+ body: { identifier: victimEmail, password: "victim-pw-123" },
540
+ });
541
+ assert(
542
+ victimLoginAfterDeactivate.status === 401,
543
+ `deactivated (but not blocked) user cannot log in either (got ${victimLoginAfterDeactivate.status})`,
544
+ );
292
545
 
293
- const usersAfterDeactivate = await call("GET", "/auth/admin/users", { token: await admin.freshToken(), workspaceId: admin.workspaceId });
294
- const victimAfterDeactivate = (usersAfterDeactivate.body as { items: Array<{ id?: string; userId?: string; blocked: boolean; isActive: boolean }> }).items.find(
295
- (u) => (u.id ?? u.userId) === victimId,
546
+ const usersAfterDeactivate = await call("GET", "/auth/admin/users", {
547
+ token: await admin.freshToken(),
548
+ workspaceId: admin.workspaceId,
549
+ });
550
+ const victimAfterDeactivate = (
551
+ usersAfterDeactivate.body as {
552
+ items: Array<{
553
+ id?: string;
554
+ userId?: string;
555
+ blocked: boolean;
556
+ isActive: boolean;
557
+ }>;
558
+ }
559
+ ).items.find((u) => (u.id ?? u.userId) === victimId);
560
+ assert(
561
+ victimAfterDeactivate?.isActive === false,
562
+ "…and the user list reflects isActive=false",
563
+ );
564
+ assert(
565
+ victimAfterDeactivate?.blocked === false,
566
+ "…while blocked stays false — deactivating never sets it, they are independent flags",
296
567
  );
297
- assert(victimAfterDeactivate?.isActive === false, "…and the user list reflects isActive=false");
298
- assert(victimAfterDeactivate?.blocked === false, "…while blocked stays false — deactivating never sets it, they are independent flags");
299
568
 
300
- const reactivate = await call("POST", `/auth/admin/users/${victimId}/activate`, { token: await admin.freshToken(), workspaceId: admin.workspaceId });
301
- assert(reactivate.status === 200 || reactivate.status === 201, `activate succeeds (got ${reactivate.status})`);
302
- const victimLoginAfterReactivate = await call("POST", "/auth/login", { body: { identifier: victimEmail, password: "victim-pw-123" } });
569
+ const reactivate = await call(
570
+ "POST",
571
+ `/auth/admin/users/${victimId}/activate`,
572
+ { token: await admin.freshToken(), workspaceId: admin.workspaceId },
573
+ );
574
+ assert(
575
+ reactivate.status === 200 || reactivate.status === 201,
576
+ `activate succeeds (got ${reactivate.status})`,
577
+ );
578
+ const victimLoginAfterReactivate = await call("POST", "/auth/login", {
579
+ body: { identifier: victimEmail, password: "victim-pw-123" },
580
+ });
303
581
  assert(
304
- victimLoginAfterReactivate.status === 200 || victimLoginAfterReactivate.status === 201,
582
+ victimLoginAfterReactivate.status === 200 ||
583
+ victimLoginAfterReactivate.status === 201,
305
584
  `reactivated user can log in again (got ${victimLoginAfterReactivate.status})`,
306
585
  );
307
586
 
308
- console.log("8. two-factor: enroll -> confirm -> login now requires 2FA -> complete challenge -> logout");
587
+ console.log(
588
+ "8. two-factor: enroll -> confirm -> login now requires 2FA -> complete challenge -> logout",
589
+ );
309
590
  const tfaEmail = uniqueEmail("tfa");
310
591
  const tfaTokens = await signup(tfaEmail, "tfa-pw-12345");
311
592
 
312
- const enroll = await call("POST", "/auth/2fa/enroll", { token: tfaTokens.accessToken });
313
- assert(enroll.status === 200 || enroll.status === 201, `2fa enroll succeeds (got ${enroll.status})`);
314
- const { secret } = enroll.body as { secret: string; provisioningUri: string };
315
-
316
- const confirm = await call("POST", "/auth/2fa/confirm", { token: tfaTokens.accessToken, body: { code: generateTotpCode(secret) } });
317
- assert(confirm.status === 200 || confirm.status === 201, `2fa confirm succeeds (got ${confirm.status})`);
593
+ const enroll = await call("POST", "/auth/2fa/enroll", {
594
+ token: tfaTokens.accessToken,
595
+ });
596
+ assert(
597
+ enroll.status === 200 || enroll.status === 201,
598
+ `2fa enroll succeeds (got ${enroll.status})`,
599
+ );
600
+ const { secret } = enroll.body as {
601
+ secret: string;
602
+ provisioningUri: string;
603
+ };
604
+
605
+ const confirm = await call("POST", "/auth/2fa/confirm", {
606
+ token: tfaTokens.accessToken,
607
+ body: { code: generateTotpCode(secret) },
608
+ });
609
+ assert(
610
+ confirm.status === 200 || confirm.status === 201,
611
+ `2fa confirm succeeds (got ${confirm.status})`,
612
+ );
318
613
  const { backupCodes } = confirm.body as { backupCodes: string[] };
319
- assert(backupCodes.length === 10, `2fa confirm returns backup codes (got ${backupCodes.length})`);
614
+ assert(
615
+ backupCodes.length === 10,
616
+ `2fa confirm returns backup codes (got ${backupCodes.length})`,
617
+ );
320
618
 
321
- const tfaLogin = await call("POST", "/auth/login", { body: { identifier: tfaEmail, password: "tfa-pw-12345" } });
322
- const tfaChallenge = tfaLogin.body as { twoFactorRequired: true; challengeToken: string };
323
- assert(tfaChallenge.twoFactorRequired === true, `login now returns a 2fa challenge instead of tokens (got status ${tfaLogin.status})`);
619
+ const tfaLogin = await call("POST", "/auth/login", {
620
+ body: { identifier: tfaEmail, password: "tfa-pw-12345" },
621
+ });
622
+ const tfaChallenge = tfaLogin.body as {
623
+ twoFactorRequired: true;
624
+ challengeToken: string;
625
+ };
626
+ assert(
627
+ tfaChallenge.twoFactorRequired === true,
628
+ `login now returns a 2fa challenge instead of tokens (got status ${tfaLogin.status})`,
629
+ );
324
630
 
325
- const tfaComplete = await call("POST", "/auth/login/2fa", { body: { challengeToken: tfaChallenge.challengeToken, code: generateTotpCode(secret) } });
326
- assert(tfaComplete.status === 200 || tfaComplete.status === 201, `2fa challenge completes login (got ${tfaComplete.status})`);
631
+ const tfaComplete = await call("POST", "/auth/login/2fa", {
632
+ body: {
633
+ challengeToken: tfaChallenge.challengeToken,
634
+ code: generateTotpCode(secret),
635
+ },
636
+ });
637
+ assert(
638
+ tfaComplete.status === 200 || tfaComplete.status === 201,
639
+ `2fa challenge completes login (got ${tfaComplete.status})`,
640
+ );
327
641
  const tfaSessionTokens = tfaComplete.body as { accessToken: string };
328
642
 
329
- const tfaBadChallenge = await call("POST", "/auth/login/2fa", { body: { challengeToken: tfaChallenge.challengeToken, code: "000000" } });
330
- assert(tfaBadChallenge.status === 401, `wrong 2fa code rejected (got ${tfaBadChallenge.status})`);
643
+ const tfaBadChallenge = await call("POST", "/auth/login/2fa", {
644
+ body: { challengeToken: tfaChallenge.challengeToken, code: "000000" },
645
+ });
646
+ assert(
647
+ tfaBadChallenge.status === 401,
648
+ `wrong 2fa code rejected (got ${tfaBadChallenge.status})`,
649
+ );
331
650
 
332
- const tfaLogout = await call("POST", "/auth/logout", { token: tfaSessionTokens.accessToken });
333
- assert(tfaLogout.status === 200 || tfaLogout.status === 201, `logout after 2fa login succeeds (got ${tfaLogout.status})`);
651
+ const tfaLogout = await call("POST", "/auth/logout", {
652
+ token: tfaSessionTokens.accessToken,
653
+ });
654
+ assert(
655
+ tfaLogout.status === 200 || tfaLogout.status === 201,
656
+ `logout after 2fa login succeeds (got ${tfaLogout.status})`,
657
+ );
334
658
 
335
- console.log("9. forgot/reset password -> old password stops working -> old sessions revoked");
659
+ console.log(
660
+ "9. forgot/reset password -> old password stops working -> old sessions revoked",
661
+ );
336
662
  const resetEmail = uniqueEmail("reset");
337
663
  const resetTokens = await signup(resetEmail, "original-password-1");
338
664
 
339
- const forgot = await call("POST", "/auth/password/forgot", { body: { email: resetEmail } });
340
- assert(forgot.status === 200 || forgot.status === 201, `forgot-password always reports success (got ${forgot.status})`);
665
+ const forgot = await call("POST", "/auth/password/forgot", {
666
+ body: { email: resetEmail },
667
+ });
668
+ assert(
669
+ forgot.status === 200 || forgot.status === 201,
670
+ `forgot-password always reports success (got ${forgot.status})`,
671
+ );
341
672
  const rawResetToken = capturedResetTokens.get(resetEmail);
342
- assert(!!rawResetToken, "the reset token reached the sendPasswordResetEmail hook");
673
+ assert(
674
+ !!rawResetToken,
675
+ "the reset token reached the sendPasswordResetEmail hook",
676
+ );
343
677
 
344
- const badReset = await call("POST", "/auth/password/reset", { body: { token: "not-a-real-token", newPassword: "irrelevant-1" } });
345
- assert(badReset.status === 401, `reset with an invalid token is rejected (got ${badReset.status})`);
678
+ const badReset = await call("POST", "/auth/password/reset", {
679
+ body: { token: "not-a-real-token", newPassword: "irrelevant-1" },
680
+ });
681
+ assert(
682
+ badReset.status === 401,
683
+ `reset with an invalid token is rejected (got ${badReset.status})`,
684
+ );
346
685
 
347
- const reset = await call("POST", "/auth/password/reset", { body: { token: rawResetToken, newPassword: "new-password-2" } });
348
- assert(reset.status === 200 || reset.status === 201, `reset with a valid token succeeds (got ${reset.status})`);
686
+ const reset = await call("POST", "/auth/password/reset", {
687
+ body: { token: rawResetToken, newPassword: "new-password-2" },
688
+ });
689
+ assert(
690
+ reset.status === 200 || reset.status === 201,
691
+ `reset with a valid token succeeds (got ${reset.status})`,
692
+ );
349
693
 
350
694
  // Session revocation bumps sessionVersion/revokedAt, which invalidates future *refreshes*
351
695
  // immediately; it does not retroactively invalidate an already-issued, still-unexpired
352
696
  // access token (same "denylist is for instant per-token kill, sessionVersion is for
353
697
  // refresh-time revocation" split that /auth/logout relies on elsewhere in this file).
354
- const refreshAfterReset = await call("POST", "/auth/refresh", { body: { refreshToken: resetTokens.refreshToken } });
355
- assert(refreshAfterReset.status === 401, `pre-reset session's refresh token is invalid after password reset (got ${refreshAfterReset.status})`);
698
+ const refreshAfterReset = await call("POST", "/auth/refresh", {
699
+ body: { refreshToken: resetTokens.refreshToken },
700
+ });
701
+ assert(
702
+ refreshAfterReset.status === 401,
703
+ `pre-reset session's refresh token is invalid after password reset (got ${refreshAfterReset.status})`,
704
+ );
356
705
 
357
- const loginWithOldPassword = await call("POST", "/auth/login", { body: { identifier: resetEmail, password: "original-password-1" } });
358
- assert(loginWithOldPassword.status === 401, `old password no longer works (got ${loginWithOldPassword.status})`);
706
+ const loginWithOldPassword = await call("POST", "/auth/login", {
707
+ body: { identifier: resetEmail, password: "original-password-1" },
708
+ });
709
+ assert(
710
+ loginWithOldPassword.status === 401,
711
+ `old password no longer works (got ${loginWithOldPassword.status})`,
712
+ );
359
713
 
360
- const loginWithNewPassword = await call("POST", "/auth/login", { body: { identifier: resetEmail, password: "new-password-2" } });
361
- assert(loginWithNewPassword.status === 200 || loginWithNewPassword.status === 201, `new password works (got ${loginWithNewPassword.status})`);
714
+ const loginWithNewPassword = await call("POST", "/auth/login", {
715
+ body: { identifier: resetEmail, password: "new-password-2" },
716
+ });
717
+ assert(
718
+ loginWithNewPassword.status === 200 ||
719
+ loginWithNewPassword.status === 201,
720
+ `new password works (got ${loginWithNewPassword.status})`,
721
+ );
362
722
 
363
- console.log("10. RBAC: create role, attach permission, assign to user, permission takes effect");
723
+ console.log(
724
+ "10. RBAC: create role, attach permission, assign to user, permission takes effect",
725
+ );
364
726
  const rbacEmail = uniqueEmail("rbac");
365
727
  const rbacTokens = await signup(rbacEmail, "rbac-pw-12345");
366
- const rbacUserId = (await call("GET", "/auth/me", { token: rbacTokens.accessToken })).body.sub as string;
728
+ const rbacUserId = (
729
+ await call("GET", "/auth/me", { token: rbacTokens.accessToken })
730
+ ).body.sub as string;
367
731
  await hooks.admitUser(ctx, admin, { userId: rbacUserId, email: rbacEmail });
368
732
 
369
733
  const createRole = await call("POST", "/auth/admin/roles", {
@@ -371,75 +735,172 @@ async function main() {
371
735
  workspaceId: admin.workspaceId,
372
736
  body: { slug: ROLE_SLUG, displayName: "Billing manager" },
373
737
  });
374
- assert(createRole.status === 200 || createRole.status === 201, `admin can create a role (got ${createRole.status})`);
375
- const role = createRole.body as { id: string; slug: string; displayName: string };
376
- assert(role.slug === ROLE_SLUG && role.displayName === "Billing manager", "a role carries a stable slug and a separate human label");
738
+ assert(
739
+ createRole.status === 200 || createRole.status === 201,
740
+ `admin can create a role (got ${createRole.status})`,
741
+ );
742
+ const role = createRole.body as {
743
+ id: string;
744
+ slug: string;
745
+ displayName: string;
746
+ };
747
+ assert(
748
+ role.slug === ROLE_SLUG && role.displayName === "Billing manager",
749
+ "a role carries a stable slug and a separate human label",
750
+ );
377
751
 
378
- const attachPermission = await call("POST", `/auth/admin/roles/${role.id}/permissions`, {
379
- token: await admin.freshToken(),
380
- workspaceId: admin.workspaceId,
381
- body: { permission: "billing:manage" },
382
- });
383
- assert(attachPermission.status === 200 || attachPermission.status === 201, `admin can attach a permission to a role (got ${attachPermission.status})`);
752
+ const attachPermission = await call(
753
+ "POST",
754
+ `/auth/admin/roles/${role.id}/permissions`,
755
+ {
756
+ token: await admin.freshToken(),
757
+ workspaceId: admin.workspaceId,
758
+ body: { permission: "billing:manage" },
759
+ },
760
+ );
761
+ assert(
762
+ attachPermission.status === 200 || attachPermission.status === 201,
763
+ `admin can attach a permission to a role (got ${attachPermission.status})`,
764
+ );
384
765
 
385
- const assignRole = await call("POST", `/auth/admin/users/${rbacUserId}/roles`, {
386
- token: await admin.freshToken(),
387
- workspaceId: admin.workspaceId,
388
- body: { role: ROLE_SLUG },
389
- });
390
- assert(assignRole.status === 200 || assignRole.status === 201, `admin can assign a role to a user (got ${assignRole.status})`);
766
+ const assignRole = await call(
767
+ "POST",
768
+ `/auth/admin/users/${rbacUserId}/roles`,
769
+ {
770
+ token: await admin.freshToken(),
771
+ workspaceId: admin.workspaceId,
772
+ body: { role: ROLE_SLUG },
773
+ },
774
+ );
775
+ assert(
776
+ assignRole.status === 200 || assignRole.status === 201,
777
+ `admin can assign a role to a user (got ${assignRole.status})`,
778
+ );
391
779
 
392
- const rbacDirect = await call("POST", `/auth/admin/users/${rbacUserId}/permissions`, {
393
- token: await admin.freshToken(),
394
- workspaceId: admin.workspaceId,
395
- body: { permission: "reports:export" },
396
- });
397
- assert(rbacDirect.status === 200 || rbacDirect.status === 201, `admin can grant a permission directly (got ${rbacDirect.status})`);
780
+ const rbacDirect = await call(
781
+ "POST",
782
+ `/auth/admin/users/${rbacUserId}/permissions`,
783
+ {
784
+ token: await admin.freshToken(),
785
+ workspaceId: admin.workspaceId,
786
+ body: { permission: "reports:export" },
787
+ },
788
+ );
789
+ assert(
790
+ rbacDirect.status === 200 || rbacDirect.status === 201,
791
+ `admin can grant a permission directly (got ${rbacDirect.status})`,
792
+ );
398
793
 
399
794
  // No re-login. Nothing about authorization lives in the token in either variant, so both
400
795
  // grants are in effect on the caller's very next request, on the token they already hold.
401
- const rbacMe = await call("GET", "/auth/me", { token: rbacTokens.accessToken, workspaceId: admin.workspaceId });
402
- assert((rbacMe.body?.permissions ?? []).includes("billing:manage"), "the assigned role's permission is in effect on the user's existing token");
403
- assert((rbacMe.body?.permissions ?? []).includes("reports:export"), "a direct grant is unioned with the role-derived permissions");
404
- assert((rbacMe.body?.roles ?? []).includes(ROLE_SLUG), "…and /auth/me reports the role by slug");
405
-
406
- console.log("11. admin audit log: the role_assigned event from step 10 is readable back");
407
- const auditList = await call("GET", `/auth/admin/audit-log?userId=${rbacUserId}&action=role_assigned`, {
408
- token: await admin.freshToken(),
796
+ const rbacMe = await call("GET", "/auth/me", {
797
+ token: rbacTokens.accessToken,
409
798
  workspaceId: admin.workspaceId,
410
799
  });
411
- assert(auditList.status === 200, `admin can list the audit log (got ${auditList.status})`);
412
- const auditEntries = (auditList.body as { items: Array<{ action: string; name: string; userId: string | null }> }).items;
413
800
  assert(
414
- auditEntries.some((e) => e.action === "role_assigned" && e.userId === rbacUserId),
801
+ (rbacMe.body?.permissions ?? []).includes("billing:manage"),
802
+ "the assigned role's permission is in effect on the user's existing token",
803
+ );
804
+ assert(
805
+ (rbacMe.body?.permissions ?? []).includes("reports:export"),
806
+ "a direct grant is unioned with the role-derived permissions",
807
+ );
808
+ assert(
809
+ (rbacMe.body?.roles ?? []).includes(ROLE_SLUG),
810
+ "…and /auth/me reports the role by slug",
811
+ );
812
+
813
+ console.log(
814
+ "11. admin audit log: the role_assigned event from step 10 is readable back",
815
+ );
816
+ const auditList = await call(
817
+ "GET",
818
+ `/auth/admin/audit-log?userId=${rbacUserId}&action=role_assigned`,
819
+ {
820
+ token: await admin.freshToken(),
821
+ workspaceId: admin.workspaceId,
822
+ },
823
+ );
824
+ assert(
825
+ auditList.status === 200,
826
+ `admin can list the audit log (got ${auditList.status})`,
827
+ );
828
+ const auditEntries = (
829
+ auditList.body as {
830
+ items: Array<{ action: string; name: string; userId: string | null }>;
831
+ }
832
+ ).items;
833
+ assert(
834
+ auditEntries.some(
835
+ (e) => e.action === "role_assigned" && e.userId === rbacUserId,
836
+ ),
415
837
  "the role_assigned event for this user shows up in the audit log",
416
838
  );
417
- assert(auditEntries.every((e) => e.name === "Role assigned"), "audit entries carry a human-readable name alongside the action");
839
+ assert(
840
+ auditEntries.every((e) => e.name === "Role assigned"),
841
+ "audit entries carry a human-readable name alongside the action",
842
+ );
418
843
 
419
- const auditForbidden = await call("GET", "/auth/admin/audit-log", { token: rbacTokens.accessToken, workspaceId: admin.workspaceId });
420
- assert(auditForbidden.status === 403, `non-admin cannot list the audit log (got ${auditForbidden.status})`);
844
+ const auditForbidden = await call("GET", "/auth/admin/audit-log", {
845
+ token: rbacTokens.accessToken,
846
+ workspaceId: admin.workspaceId,
847
+ });
848
+ assert(
849
+ auditForbidden.status === 403,
850
+ `non-admin cannot list the audit log (got ${auditForbidden.status})`,
851
+ );
421
852
 
422
- console.log("11b. user/role CRUD: fetch, edit, and soft-delete both — on disposable rows, so the shared probe target/role are untouched");
853
+ console.log(
854
+ "11b. user/role CRUD: fetch, edit, and soft-delete both — on disposable rows, so the shared probe target/role are untouched",
855
+ );
423
856
  const crudUserEmail = uniqueEmail("crud-user");
424
857
  const crudUserTokens = await signup(crudUserEmail, "crud-user-pw-12345");
425
- const crudUserId = (await call("GET", "/auth/me", { token: crudUserTokens.accessToken })).body.sub as string;
426
- await hooks.admitUser(ctx, admin, { userId: crudUserId, email: crudUserEmail });
858
+ const crudUserId = (
859
+ await call("GET", "/auth/me", { token: crudUserTokens.accessToken })
860
+ ).body.sub as string;
861
+ await hooks.admitUser(ctx, admin, {
862
+ userId: crudUserId,
863
+ email: crudUserEmail,
864
+ });
427
865
 
428
- const getUser = await call("GET", `/auth/admin/users/${crudUserId}`, { token: await admin.freshToken(), workspaceId: admin.workspaceId });
429
- assert(getUser.status === 200, `admin can fetch a single user (got ${getUser.status})`);
430
- assert((getUser.body as { email: string }).email === crudUserEmail, "…and it's the right one");
866
+ const getUser = await call("GET", `/auth/admin/users/${crudUserId}`, {
867
+ token: await admin.freshToken(),
868
+ workspaceId: admin.workspaceId,
869
+ });
870
+ assert(
871
+ getUser.status === 200,
872
+ `admin can fetch a single user (got ${getUser.status})`,
873
+ );
874
+ assert(
875
+ (getUser.body as { email: string }).email === crudUserEmail,
876
+ "…and it's the right one",
877
+ );
431
878
 
432
879
  const updateUser = await call("PATCH", `/auth/admin/users/${crudUserId}`, {
433
880
  token: await admin.freshToken(),
434
881
  workspaceId: admin.workspaceId,
435
882
  body: { displayName: "CRUD Test User" },
436
883
  });
437
- assert(updateUser.status === 200, `admin can edit a user's profile (got ${updateUser.status})`);
438
- assert((updateUser.body as { displayName: string | null }).displayName === "CRUD Test User", "…and the edit is reflected in the response");
884
+ assert(
885
+ updateUser.status === 200,
886
+ `admin can edit a user's profile (got ${updateUser.status})`,
887
+ );
888
+ assert(
889
+ (updateUser.body as { displayName: string | null }).displayName ===
890
+ "CRUD Test User",
891
+ "…and the edit is reflected in the response",
892
+ );
439
893
 
440
894
  const crudRoleSlug = `crud-role-${RUN_ID}`;
441
- const createCrudRole = await call("POST", "/auth/admin/roles", { token: await admin.freshToken(), workspaceId: admin.workspaceId, body: { slug: crudRoleSlug } });
442
- assert(createCrudRole.status === 201, `admin can create a disposable role for the delete test (got ${createCrudRole.status})`);
895
+ const createCrudRole = await call("POST", "/auth/admin/roles", {
896
+ token: await admin.freshToken(),
897
+ workspaceId: admin.workspaceId,
898
+ body: { slug: crudRoleSlug },
899
+ });
900
+ assert(
901
+ createCrudRole.status === 201,
902
+ `admin can create a disposable role for the delete test (got ${createCrudRole.status})`,
903
+ );
443
904
  const crudRoleId = (createCrudRole.body as { id: string }).id;
444
905
 
445
906
  const updateRole = await call("PATCH", `/auth/admin/roles/${crudRoleId}`, {
@@ -447,150 +908,334 @@ async function main() {
447
908
  workspaceId: admin.workspaceId,
448
909
  body: { displayName: "CRUD Test Role" },
449
910
  });
450
- assert(updateRole.status === 200, `admin can edit a role (got ${updateRole.status})`);
451
- assert((updateRole.body as { displayName: string }).displayName === "CRUD Test Role", "…and the edit is reflected in the response");
911
+ assert(
912
+ updateRole.status === 200,
913
+ `admin can edit a role (got ${updateRole.status})`,
914
+ );
915
+ assert(
916
+ (updateRole.body as { displayName: string }).displayName ===
917
+ "CRUD Test Role",
918
+ "…and the edit is reflected in the response",
919
+ );
452
920
 
453
- const assignCrudRole = await call("POST", `/auth/admin/users/${crudUserId}/roles`, {
454
- token: await admin.freshToken(),
455
- workspaceId: admin.workspaceId,
456
- body: { role: crudRoleSlug },
457
- });
458
- assert(assignCrudRole.status === 201, `the disposable role can be assigned before it's deleted (got ${assignCrudRole.status})`);
921
+ const assignCrudRole = await call(
922
+ "POST",
923
+ `/auth/admin/users/${crudUserId}/roles`,
924
+ {
925
+ token: await admin.freshToken(),
926
+ workspaceId: admin.workspaceId,
927
+ body: { role: crudRoleSlug },
928
+ },
929
+ );
930
+ assert(
931
+ assignCrudRole.status === 201,
932
+ `the disposable role can be assigned before it's deleted (got ${assignCrudRole.status})`,
933
+ );
459
934
 
460
935
  const deleteRole = await call("DELETE", `/auth/admin/roles/${crudRoleId}`, {
461
936
  token: await admin.freshToken(),
462
937
  workspaceId: admin.workspaceId,
463
938
  body: { reason: "prove-cycle cleanup" },
464
939
  });
465
- assert(deleteRole.status === 200, `admin can delete a role (got ${deleteRole.status})`);
940
+ assert(
941
+ deleteRole.status === 200,
942
+ `admin can delete a role (got ${deleteRole.status})`,
943
+ );
466
944
 
467
- const rolesAfterDelete = await call("GET", "/auth/admin/roles", { token: await admin.freshToken(), workspaceId: admin.workspaceId });
945
+ const rolesAfterDelete = await call("GET", "/auth/admin/roles", {
946
+ token: await admin.freshToken(),
947
+ workspaceId: admin.workspaceId,
948
+ });
468
949
  assert(
469
- !(rolesAfterDelete.body as { roles: Array<{ id: string }> }).roles.some((r) => r.id === crudRoleId),
950
+ !(rolesAfterDelete.body as { roles: Array<{ id: string }> }).roles.some(
951
+ (r) => r.id === crudRoleId,
952
+ ),
470
953
  "a deleted role no longer appears in the role list",
471
954
  );
472
955
 
473
- const meAfterRoleDelete = await call("GET", "/auth/me", { token: crudUserTokens.accessToken, workspaceId: admin.workspaceId });
474
- assert(!(meAfterRoleDelete.body?.roles ?? []).includes(crudRoleSlug), "…and a member who held it no longer resolves it, with no re-login");
956
+ const meAfterRoleDelete = await call("GET", "/auth/me", {
957
+ token: crudUserTokens.accessToken,
958
+ workspaceId: admin.workspaceId,
959
+ });
960
+ assert(
961
+ !(meAfterRoleDelete.body?.roles ?? []).includes(crudRoleSlug),
962
+ "…and a member who held it no longer resolves it, with no re-login",
963
+ );
475
964
 
476
- const deleteUser = await call("DELETE", `/auth/admin/users/${crudUserId}`, { token: await admin.freshToken(), workspaceId: admin.workspaceId });
477
- assert(deleteUser.status === 200, `admin can delete a user (got ${deleteUser.status})`);
965
+ const deleteUser = await call("DELETE", `/auth/admin/users/${crudUserId}`, {
966
+ token: await admin.freshToken(),
967
+ workspaceId: admin.workspaceId,
968
+ });
969
+ assert(
970
+ deleteUser.status === 200,
971
+ `admin can delete a user (got ${deleteUser.status})`,
972
+ );
478
973
 
479
- const usersAfterDelete = await call("GET", "/auth/admin/users", { token: await admin.freshToken(), workspaceId: admin.workspaceId });
974
+ const usersAfterDelete = await call("GET", "/auth/admin/users", {
975
+ token: await admin.freshToken(),
976
+ workspaceId: admin.workspaceId,
977
+ });
480
978
  assert(
481
- !(usersAfterDelete.body as { items: Array<{ id: string }> }).items.some((u) => u.id === crudUserId),
979
+ !(usersAfterDelete.body as { items: Array<{ id: string }> }).items.some(
980
+ (u) => u.id === crudUserId,
981
+ ),
482
982
  "a deleted user no longer appears in the user list",
483
983
  );
484
984
 
485
- const getDeletedUser = await call("GET", `/auth/admin/users/${crudUserId}`, { token: await admin.freshToken(), workspaceId: admin.workspaceId });
486
- assert(getDeletedUser.status === 404, `…and fetching them directly by id now 404s (got ${getDeletedUser.status})`);
487
-
488
- const loginAsDeletedUser = await call("POST", "/auth/login", { body: { identifier: crudUserEmail, password: "crud-user-pw-12345" } });
489
- assert(loginAsDeletedUser.status === 401, `…and they can no longer log in (got ${loginAsDeletedUser.status})`);
985
+ const getDeletedUser = await call(
986
+ "GET",
987
+ `/auth/admin/users/${crudUserId}`,
988
+ { token: await admin.freshToken(), workspaceId: admin.workspaceId },
989
+ );
990
+ assert(
991
+ getDeletedUser.status === 404,
992
+ `…and fetching them directly by id now 404s (got ${getDeletedUser.status})`,
993
+ );
490
994
 
491
- const selfDeleteAttempt = await call("DELETE", `/auth/admin/users/${(await call("GET", "/auth/me", { token: await admin.freshToken() })).body.sub}`, {
492
- token: await admin.freshToken(),
493
- workspaceId: admin.workspaceId,
995
+ const loginAsDeletedUser = await call("POST", "/auth/login", {
996
+ body: { identifier: crudUserEmail, password: "crud-user-pw-12345" },
494
997
  });
495
- assert(selfDeleteAttempt.status === 403, `an admin cannot delete their own account (got ${selfDeleteAttempt.status})`);
998
+ assert(
999
+ loginAsDeletedUser.status === 401,
1000
+ `…and they can no longer log in (got ${loginAsDeletedUser.status})`,
1001
+ );
496
1002
 
497
- console.log("11c. login accepts a username or a phone number in place of the email, same password");
1003
+ const selfDeleteAttempt = await call(
1004
+ "DELETE",
1005
+ `/auth/admin/users/${(await call("GET", "/auth/me", { token: await admin.freshToken() })).body.sub}`,
1006
+ {
1007
+ token: await admin.freshToken(),
1008
+ workspaceId: admin.workspaceId,
1009
+ },
1010
+ );
1011
+ assert(
1012
+ selfDeleteAttempt.status === 403,
1013
+ `an admin cannot delete their own account (got ${selfDeleteAttempt.status})`,
1014
+ );
1015
+
1016
+ console.log(
1017
+ "11c. login accepts a username or a phone number in place of the email, same password",
1018
+ );
498
1019
  const identEmail = uniqueEmail("ident");
499
1020
  const identTokens = await signup(identEmail, "ident-user-pw-12345");
500
- const identUserId = (await call("GET", "/auth/me", { token: identTokens.accessToken })).body.sub as string;
501
- await hooks.admitUser(ctx, admin, { userId: identUserId, email: identEmail });
1021
+ const identUserId = (
1022
+ await call("GET", "/auth/me", { token: identTokens.accessToken })
1023
+ ).body.sub as string;
1024
+ await hooks.admitUser(ctx, admin, {
1025
+ userId: identUserId,
1026
+ email: identEmail,
1027
+ });
502
1028
 
503
1029
  const identUsername = `ident-user-${RUN_ID}`;
504
1030
  const identPhone = `+1-555-${RUN_ID}`;
505
- const setIdentFields = await call("PATCH", `/auth/admin/users/${identUserId}`, {
506
- token: await admin.freshToken(),
507
- workspaceId: admin.workspaceId,
508
- body: { username: identUsername, phone: identPhone },
509
- });
510
- assert(setIdentFields.status === 200, `admin can set a user's username and phone (got ${setIdentFields.status})`);
1031
+ const setIdentFields = await call(
1032
+ "PATCH",
1033
+ `/auth/admin/users/${identUserId}`,
1034
+ {
1035
+ token: await admin.freshToken(),
1036
+ workspaceId: admin.workspaceId,
1037
+ body: { username: identUsername, phone: identPhone },
1038
+ },
1039
+ );
1040
+ assert(
1041
+ setIdentFields.status === 200,
1042
+ `admin can set a user's username and phone (got ${setIdentFields.status})`,
1043
+ );
511
1044
 
512
- const loginByUsername = await call("POST", "/auth/login", { body: { identifier: identUsername, password: "ident-user-pw-12345" } });
513
- assert(loginByUsername.status === 201 || loginByUsername.status === 200, `login by username succeeds (got ${loginByUsername.status})`);
514
- assert((loginByUsername.body as AuthTokens).accessToken !== undefined, "…and returns a real access token");
1045
+ const loginByUsername = await call("POST", "/auth/login", {
1046
+ body: { identifier: identUsername, password: "ident-user-pw-12345" },
1047
+ });
1048
+ assert(
1049
+ loginByUsername.status === 201 || loginByUsername.status === 200,
1050
+ `login by username succeeds (got ${loginByUsername.status})`,
1051
+ );
1052
+ assert(
1053
+ (loginByUsername.body as AuthTokens).accessToken !== undefined,
1054
+ "…and returns a real access token",
1055
+ );
515
1056
 
516
- const loginByPhone = await call("POST", "/auth/login", { body: { identifier: identPhone, password: "ident-user-pw-12345" } });
517
- assert(loginByPhone.status === 201 || loginByPhone.status === 200, `login by phone succeeds (got ${loginByPhone.status})`);
1057
+ const loginByPhone = await call("POST", "/auth/login", {
1058
+ body: { identifier: identPhone, password: "ident-user-pw-12345" },
1059
+ });
1060
+ assert(
1061
+ loginByPhone.status === 201 || loginByPhone.status === 200,
1062
+ `login by phone succeeds (got ${loginByPhone.status})`,
1063
+ );
518
1064
 
519
- const loginByEmailStill = await call("POST", "/auth/login", { body: { identifier: identEmail, password: "ident-user-pw-12345" } });
520
- assert(loginByEmailStill.status === 201 || loginByEmailStill.status === 200, `login by email still works once username/phone are also set (got ${loginByEmailStill.status})`);
1065
+ const loginByEmailStill = await call("POST", "/auth/login", {
1066
+ body: { identifier: identEmail, password: "ident-user-pw-12345" },
1067
+ });
1068
+ assert(
1069
+ loginByEmailStill.status === 201 || loginByEmailStill.status === 200,
1070
+ `login by email still works once username/phone are also set (got ${loginByEmailStill.status})`,
1071
+ );
521
1072
 
522
- const loginByUsernameWrongPassword = await call("POST", "/auth/login", { body: { identifier: identUsername, password: "wrong" } });
523
- assert(loginByUsernameWrongPassword.status === 401, `login by username with the wrong password is still rejected (got ${loginByUsernameWrongPassword.status})`);
1073
+ const loginByUsernameWrongPassword = await call("POST", "/auth/login", {
1074
+ body: { identifier: identUsername, password: "wrong" },
1075
+ });
1076
+ assert(
1077
+ loginByUsernameWrongPassword.status === 401,
1078
+ `login by username with the wrong password is still rejected (got ${loginByUsernameWrongPassword.status})`,
1079
+ );
524
1080
 
525
- const loginByUnknownIdentifier = await call("POST", "/auth/login", { body: { identifier: `nobody-${RUN_ID}`, password: "whatever" } });
526
- assert(loginByUnknownIdentifier.status === 401, `an identifier matching no email/username/phone is rejected the same way (got ${loginByUnknownIdentifier.status})`);
1081
+ const loginByUnknownIdentifier = await call("POST", "/auth/login", {
1082
+ body: { identifier: `nobody-${RUN_ID}`, password: "whatever" },
1083
+ });
1084
+ assert(
1085
+ loginByUnknownIdentifier.status === 401,
1086
+ `an identifier matching no email/username/phone is rejected the same way (got ${loginByUnknownIdentifier.status})`,
1087
+ );
527
1088
 
528
- console.log("11d. change password: wrong current password rejected, right one rotates it and revokes every other session but this one");
529
- const otherDeviceLogin = await call("POST", "/auth/login", { body: { identifier: identEmail, password: "ident-user-pw-12345" } });
1089
+ console.log(
1090
+ "11d. change password: wrong current password rejected, right one rotates it and revokes every other session but this one",
1091
+ );
1092
+ const otherDeviceLogin = await call("POST", "/auth/login", {
1093
+ body: { identifier: identEmail, password: "ident-user-pw-12345" },
1094
+ });
530
1095
  const otherDeviceTokens = otherDeviceLogin.body as AuthTokens;
531
- assert(otherDeviceTokens.sessionId !== identTokens.sessionId, "a second session for the identifier-test user exists, distinct from the signup session");
1096
+ assert(
1097
+ otherDeviceTokens.sessionId !== identTokens.sessionId,
1098
+ "a second session for the identifier-test user exists, distinct from the signup session",
1099
+ );
532
1100
 
533
1101
  const wrongCurrentPassword = await call("POST", "/auth/password/change", {
534
1102
  token: identTokens.accessToken,
535
- body: { currentPassword: "not-the-real-password", newPassword: "ident-new-pw-67890" },
1103
+ body: {
1104
+ currentPassword: "not-the-real-password",
1105
+ newPassword: "ident-new-pw-67890",
1106
+ },
536
1107
  });
537
- assert(wrongCurrentPassword.status === 401, `changing the password with the wrong current password is rejected (got ${wrongCurrentPassword.status})`);
1108
+ assert(
1109
+ wrongCurrentPassword.status === 401,
1110
+ `changing the password with the wrong current password is rejected (got ${wrongCurrentPassword.status})`,
1111
+ );
538
1112
 
539
1113
  const changePassword = await call("POST", "/auth/password/change", {
540
1114
  token: identTokens.accessToken,
541
- body: { currentPassword: "ident-user-pw-12345", newPassword: "ident-new-pw-67890" },
1115
+ body: {
1116
+ currentPassword: "ident-user-pw-12345",
1117
+ newPassword: "ident-new-pw-67890",
1118
+ },
542
1119
  });
543
- assert(changePassword.status === 201 || changePassword.status === 200, `change-password succeeds with the right current password (got ${changePassword.status})`);
1120
+ assert(
1121
+ changePassword.status === 201 || changePassword.status === 200,
1122
+ `change-password succeeds with the right current password (got ${changePassword.status})`,
1123
+ );
544
1124
 
545
- const oldPasswordNowFails = await call("POST", "/auth/login", { body: { identifier: identEmail, password: "ident-user-pw-12345" } });
546
- assert(oldPasswordNowFails.status === 401, `the old password no longer works (got ${oldPasswordNowFails.status})`);
1125
+ const oldPasswordNowFails = await call("POST", "/auth/login", {
1126
+ body: { identifier: identEmail, password: "ident-user-pw-12345" },
1127
+ });
1128
+ assert(
1129
+ oldPasswordNowFails.status === 401,
1130
+ `the old password no longer works (got ${oldPasswordNowFails.status})`,
1131
+ );
547
1132
 
548
- const newPasswordWorks = await call("POST", "/auth/login", { body: { identifier: identEmail, password: "ident-new-pw-67890" } });
549
- assert(newPasswordWorks.status === 201 || newPasswordWorks.status === 200, `the new password works (got ${newPasswordWorks.status})`);
1133
+ const newPasswordWorks = await call("POST", "/auth/login", {
1134
+ body: { identifier: identEmail, password: "ident-new-pw-67890" },
1135
+ });
1136
+ assert(
1137
+ newPasswordWorks.status === 201 || newPasswordWorks.status === 200,
1138
+ `the new password works (got ${newPasswordWorks.status})`,
1139
+ );
550
1140
 
551
- const callingSessionSurvives = await call("GET", "/auth/me", { token: identTokens.accessToken });
552
- assert(callingSessionSurvives.status === 200, `the session that called change-password is left alone, not logged out by its own request (got ${callingSessionSurvives.status})`);
1141
+ const callingSessionSurvives = await call("GET", "/auth/me", {
1142
+ token: identTokens.accessToken,
1143
+ });
1144
+ assert(
1145
+ callingSessionSurvives.status === 200,
1146
+ `the session that called change-password is left alone, not logged out by its own request (got ${callingSessionSurvives.status})`,
1147
+ );
553
1148
 
554
1149
  // Revocation lands on the session row (checked at refresh time), not an instant denylist of
555
1150
  // the still-live access token — same split `logout()` vs. `logoutOthers()` relies on elsewhere.
556
- const otherSessionRefreshRevoked = await call("POST", "/auth/refresh", { body: { refreshToken: otherDeviceTokens.refreshToken } });
1151
+ const otherSessionRefreshRevoked = await call("POST", "/auth/refresh", {
1152
+ body: { refreshToken: otherDeviceTokens.refreshToken },
1153
+ });
557
1154
  assert(
558
1155
  otherSessionRefreshRevoked.status === 401,
559
1156
  `…while the other, unrelated session for the same user can no longer refresh (got ${otherSessionRefreshRevoked.status})`,
560
1157
  );
561
1158
 
562
- console.log("11d-2. self-service profile update: no admin permission required, own row only");
1159
+ console.log(
1160
+ "11d-2. self-service profile update: no admin permission required, own row only",
1161
+ );
563
1162
  const selfProfileUpdate = await call("PATCH", "/auth/me", {
564
1163
  token: identTokens.accessToken,
565
- body: { displayName: "Self Updated", photo: "data:image/png;base64,AAAA" },
1164
+ body: {
1165
+ displayName: "Self Updated",
1166
+ photo: "data:image/png;base64,AAAA",
1167
+ },
566
1168
  });
567
- assert(selfProfileUpdate.status === 200, `a user can update their own profile with no admin permission (got ${selfProfileUpdate.status})`);
568
- const selfProfile = selfProfileUpdate.body as { id: string; displayName: string | null; photo: string | null };
569
- assert(selfProfile.displayName === "Self Updated", "…and the response reflects the new value");
570
- assert(selfProfile.photo === "data:image/png;base64,AAAA", "…including the photo field");
1169
+ assert(
1170
+ selfProfileUpdate.status === 200,
1171
+ `a user can update their own profile with no admin permission (got ${selfProfileUpdate.status})`,
1172
+ );
1173
+ const selfProfile = selfProfileUpdate.body as {
1174
+ id: string;
1175
+ displayName: string | null;
1176
+ photo: string | null;
1177
+ };
1178
+ assert(
1179
+ selfProfile.displayName === "Self Updated",
1180
+ "…and the response reflects the new value",
1181
+ );
1182
+ assert(
1183
+ selfProfile.photo === "data:image/png;base64,AAAA",
1184
+ "…including the photo field",
1185
+ );
571
1186
 
572
- const selfProfileRejectsNoToken = await call("PATCH", "/auth/me", { body: { displayName: "No Auth" } });
573
- assert(selfProfileRejectsNoToken.status === 401, `updating a profile without a token is rejected (got ${selfProfileRejectsNoToken.status})`);
1187
+ const selfProfileRejectsNoToken = await call("PATCH", "/auth/me", {
1188
+ body: { displayName: "No Auth" },
1189
+ });
1190
+ assert(
1191
+ selfProfileRejectsNoToken.status === 401,
1192
+ `updating a profile without a token is rejected (got ${selfProfileRejectsNoToken.status})`,
1193
+ );
574
1194
 
575
- const meAfterProfileUpdate = await call("GET", "/auth/me", { token: identTokens.accessToken });
1195
+ const meAfterProfileUpdate = await call("GET", "/auth/me", {
1196
+ token: identTokens.accessToken,
1197
+ });
576
1198
  assert(
577
- (meAfterProfileUpdate.body as { displayName: string | null }).displayName === "Self Updated",
1199
+ (meAfterProfileUpdate.body as { displayName: string | null })
1200
+ .displayName === "Self Updated",
578
1201
  `…and GET /auth/me reflects the same update, no separate refetch path (got ${JSON.stringify(meAfterProfileUpdate.body)})`,
579
1202
  );
580
1203
 
581
- console.log("11e. admin creates a user directly — no signup, usable immediately");
1204
+ console.log(
1205
+ "11e. admin creates a user directly — no signup, usable immediately",
1206
+ );
582
1207
  const createdEmail = uniqueEmail("admin-created");
583
1208
  const createUser = await call("POST", "/auth/admin/users", {
584
1209
  token: await admin.freshToken(),
585
1210
  workspaceId: admin.workspaceId,
586
- body: { email: createdEmail, password: "admin-created-pw-12345", displayName: "Admin Created" },
1211
+ body: {
1212
+ email: createdEmail,
1213
+ password: "admin-created-pw-12345",
1214
+ displayName: "Admin Created",
1215
+ },
587
1216
  });
588
- assert(createUser.status === 201 || createUser.status === 200, `admin can create a user directly (got ${createUser.status})`);
589
- const createdUser = createUser.body as { id: string; email: string; displayName: string | null; roles: string[] };
590
- assert(createdUser.email === createdEmail, "…and the response is the new user, not something stale");
591
- assert(createdUser.displayName === "Admin Created", "…with the profile fields given at creation time");
1217
+ assert(
1218
+ createUser.status === 201 || createUser.status === 200,
1219
+ `admin can create a user directly (got ${createUser.status})`,
1220
+ );
1221
+ const createdUser = createUser.body as {
1222
+ id: string;
1223
+ email: string;
1224
+ displayName: string | null;
1225
+ roles: string[];
1226
+ };
1227
+ assert(
1228
+ createdUser.email === createdEmail,
1229
+ "…and the response is the new user, not something stale",
1230
+ );
1231
+ assert(
1232
+ createdUser.displayName === "Admin Created",
1233
+ "…with the profile fields given at creation time",
1234
+ );
592
1235
 
593
- const createdUserCanLogin = await call("POST", "/auth/login", { body: { identifier: createdEmail, password: "admin-created-pw-12345" } });
1236
+ const createdUserCanLogin = await call("POST", "/auth/login", {
1237
+ body: { identifier: createdEmail, password: "admin-created-pw-12345" },
1238
+ });
594
1239
  assert(
595
1240
  createdUserCanLogin.status === 201 || createdUserCanLogin.status === 200,
596
1241
  `the account is usable immediately, no separate activation step (got ${createdUserCanLogin.status})`,
@@ -601,20 +1246,32 @@ async function main() {
601
1246
  workspaceId: admin.workspaceId,
602
1247
  body: { email: createdEmail, password: "another-pw-12345" },
603
1248
  });
604
- assert(duplicateCreate.status === 409, `creating a second account with the same email is rejected (got ${duplicateCreate.status})`);
1249
+ assert(
1250
+ duplicateCreate.status === 409,
1251
+ `creating a second account with the same email is rejected (got ${duplicateCreate.status})`,
1252
+ );
605
1253
 
606
1254
  const createWithRole = await call("POST", "/auth/admin/users", {
607
1255
  token: await admin.freshToken(),
608
1256
  workspaceId: admin.workspaceId,
609
- body: { email: uniqueEmail("admin-created-role"), password: "admin-created-pw-67890", roles: [ROLE_SLUG] },
1257
+ body: {
1258
+ email: uniqueEmail("admin-created-role"),
1259
+ password: "admin-created-pw-67890",
1260
+ roles: [ROLE_SLUG],
1261
+ },
610
1262
  });
611
- assert(createWithRole.status === 201 || createWithRole.status === 200, `admin can name specific roles at creation time (got ${createWithRole.status})`);
1263
+ assert(
1264
+ createWithRole.status === 201 || createWithRole.status === 200,
1265
+ `admin can name specific roles at creation time (got ${createWithRole.status})`,
1266
+ );
612
1267
  assert(
613
1268
  (createWithRole.body as { roles: string[] }).roles.includes(ROLE_SLUG),
614
1269
  `…and the named role — not the default — is what the new account actually holds (got ${JSON.stringify((createWithRole.body as { roles: string[] }).roles)})`,
615
1270
  );
616
1271
 
617
- console.log("12. permission enforcement: each admin route is opened by its own permission and by nothing else");
1272
+ console.log(
1273
+ "12. permission enforcement: each admin route is opened by its own permission and by nothing else",
1274
+ );
618
1275
  // The point of this section is that permissions — not roles — are the boundary. Every probe
619
1276
  // user below holds a real role that carries nothing, so "has a role" can never be mistaken
620
1277
  // for "has authority", and the only thing that ever changes between them is which single
@@ -622,18 +1279,33 @@ async function main() {
622
1279
  const NO_PERMS_ROLE = `probe-no-perms-${RUN_ID}`;
623
1280
  const ASSIGNABLE_ROLE = `probe-assignable-${RUN_ID}`;
624
1281
 
625
- const noPermsRole = await call("POST", "/auth/admin/roles", { token: await admin.freshToken(), workspaceId: admin.workspaceId, body: { slug: NO_PERMS_ROLE } });
1282
+ const noPermsRole = await call("POST", "/auth/admin/roles", {
1283
+ token: await admin.freshToken(),
1284
+ workspaceId: admin.workspaceId,
1285
+ body: { slug: NO_PERMS_ROLE },
1286
+ });
626
1287
  const assignableRole = await call("POST", "/auth/admin/roles", {
627
1288
  token: await admin.freshToken(),
628
1289
  workspaceId: admin.workspaceId,
629
1290
  body: { slug: ASSIGNABLE_ROLE },
630
1291
  });
631
- assert(noPermsRole.status === 201 && assignableRole.status === 201, "the probe roles are created (neither carries any permission)");
1292
+ assert(
1293
+ noPermsRole.status === 201 && assignableRole.status === 201,
1294
+ "the probe roles are created (neither carries any permission)",
1295
+ );
632
1296
 
633
1297
  const probeTargetEmail = uniqueEmail("probe-target");
634
- const probeTargetTokens = await signup(probeTargetEmail, "probe-target-pw-1");
635
- const probeTargetId = (await call("GET", "/auth/me", { token: probeTargetTokens.accessToken })).body.sub as string;
636
- await hooks.admitUser(ctx, admin, { userId: probeTargetId, email: probeTargetEmail });
1298
+ const probeTargetTokens = await signup(
1299
+ probeTargetEmail,
1300
+ "probe-target-pw-1",
1301
+ );
1302
+ const probeTargetId = (
1303
+ await call("GET", "/auth/me", { token: probeTargetTokens.accessToken })
1304
+ ).body.sub as string;
1305
+ await hooks.admitUser(ctx, admin, {
1306
+ userId: probeTargetId,
1307
+ email: probeTargetEmail,
1308
+ });
637
1309
 
638
1310
  const probesFor = (seq: number) =>
639
1311
  adminRouteProbes({
@@ -648,7 +1320,11 @@ async function main() {
648
1320
  async function runProbes(token: string, seq: number) {
649
1321
  const results: Array<{ probe: RouteProbe; status: number }> = [];
650
1322
  for (const probe of probesFor(seq)) {
651
- const res = await call(probe.method, probe.path, { token, workspaceId: admin.workspaceId, body: probe.body });
1323
+ const res = await call(probe.method, probe.path, {
1324
+ token,
1325
+ workspaceId: admin.workspaceId,
1326
+ body: probe.body,
1327
+ });
652
1328
  results.push({ probe, status: res.status });
653
1329
  }
654
1330
  return results;
@@ -666,84 +1342,168 @@ async function main() {
666
1342
  const email = uniqueEmail(label);
667
1343
  const password = `${label}-pw-12345`;
668
1344
  const tokens = await signup(email, password);
669
- const userId = (await call("GET", "/auth/me", { token: tokens.accessToken })).body.sub as string;
1345
+ const userId = (
1346
+ await call("GET", "/auth/me", { token: tokens.accessToken })
1347
+ ).body.sub as string;
670
1348
  await hooks.admitUser(ctx, admin, { userId, email });
671
- await call("POST", `/auth/admin/users/${userId}/roles`, { token: await admin.freshToken(), workspaceId: admin.workspaceId, body: { role: NO_PERMS_ROLE } });
1349
+ await call("POST", `/auth/admin/users/${userId}/roles`, {
1350
+ token: await admin.freshToken(),
1351
+ workspaceId: admin.workspaceId,
1352
+ body: { role: NO_PERMS_ROLE },
1353
+ });
672
1354
  for (const permission of grants) {
673
- await call("POST", `/auth/admin/users/${userId}/permissions`, { token: await admin.freshToken(), workspaceId: admin.workspaceId, body: { permission } });
1355
+ await call("POST", `/auth/admin/users/${userId}/permissions`, {
1356
+ token: await admin.freshToken(),
1357
+ workspaceId: admin.workspaceId,
1358
+ body: { permission },
1359
+ });
674
1360
  }
675
1361
  // No re-login: authorization is read from the database on every request in both variants,
676
1362
  // so the token this user already holds sees the grants immediately.
677
1363
  return { email, password, userId, token: tokens.accessToken };
678
1364
  }
679
1365
 
680
- console.log("12a. holding a role that carries no permissions opens nothing");
1366
+ console.log(
1367
+ "12a. holding a role that carries no permissions opens nothing",
1368
+ );
681
1369
  const bare = await probeUser("probe-bare", []);
682
- const bareMe = await call("GET", "/auth/me", { token: bare.token, workspaceId: admin.workspaceId });
683
- assert(bareMe.body?.roles?.includes(NO_PERMS_ROLE), "the probe user really does hold a role — it just carries no permissions");
1370
+ const bareMe = await call("GET", "/auth/me", {
1371
+ token: bare.token,
1372
+ workspaceId: admin.workspaceId,
1373
+ });
1374
+ assert(
1375
+ bareMe.body?.roles?.includes(NO_PERMS_ROLE),
1376
+ "the probe user really does hold a role — it just carries no permissions",
1377
+ );
684
1378
  const bareResults = await runProbes(bare.token, 0);
685
- assert(bareResults.every((r) => r.status === 403), `all ${bareResults.length} admin routes refuse them (offenders: ${refused(bareResults) || "none"})`);
1379
+ assert(
1380
+ bareResults.every((r) => r.status === 403),
1381
+ `all ${bareResults.length} admin routes refuse them (offenders: ${refused(bareResults) || "none"})`,
1382
+ );
686
1383
 
687
- console.log("12b. one directly-granted permission opens exactly the routes it names, and no others");
1384
+ console.log(
1385
+ "12b. one directly-granted permission opens exactly the routes it names, and no others",
1386
+ );
688
1387
  const permissions = [...new Set(probesFor(0).map((p) => p.permission))];
689
1388
  let probeSeq = 1;
690
1389
  /** Kept for 12d: one caller holding exactly one permission is the sharpest case for the equivalence check. */
691
1390
  let singleGrantUser: { token: string; permission: string } | undefined;
692
1391
  for (const permission of permissions) {
693
- const holder = await probeUser(`probe-${permission.replace(/[^a-z]/g, "")}`, [permission]);
694
- if (permission === "users:read") singleGrantUser = { token: holder.token, permission };
1392
+ const holder = await probeUser(
1393
+ `probe-${permission.replace(/[^a-z]/g, "")}`,
1394
+ [permission],
1395
+ );
1396
+ if (permission === "users:read")
1397
+ singleGrantUser = { token: holder.token, permission };
695
1398
  const results = await runProbes(holder.token, probeSeq++);
696
- for (const r of results.filter((r) => r.probe.permission === permission)) {
697
- assert(succeeded(r.status), `"${permission}" opens ${r.probe.label} (got ${r.status})`);
1399
+ for (const r of results.filter(
1400
+ (r) => r.probe.permission === permission,
1401
+ )) {
1402
+ assert(
1403
+ succeeded(r.status),
1404
+ `"${permission}" opens ${r.probe.label} (got ${r.status})`,
1405
+ );
698
1406
  }
699
1407
  const others = results.filter((r) => r.probe.permission !== permission);
700
- assert(others.every((r) => r.status === 403), `…and "${permission}" opens nothing else (offenders: ${refused(others) || "none"})`);
1408
+ assert(
1409
+ others.every((r) => r.status === 403),
1410
+ `…and "${permission}" opens nothing else (offenders: ${refused(others) || "none"})`,
1411
+ );
701
1412
  }
702
1413
 
703
- console.log("12c. revoking a permission takes it away again — on the same token, with no re-login");
1414
+ console.log(
1415
+ "12c. revoking a permission takes it away again — on the same token, with no re-login",
1416
+ );
704
1417
  const revokee = await probeUser("probe-revokee", ["users:read"]);
705
- const beforeRevoke = await call("GET", "/auth/admin/users", { token: revokee.token, workspaceId: admin.workspaceId });
706
- assert(beforeRevoke.status === 200, `the grant is in effect to begin with (got ${beforeRevoke.status})`);
707
-
708
- const revoked = await call("POST", `/auth/admin/users/${revokee.userId}/permissions/${encodeURIComponent("users:read")}/revoke`, {
709
- token: await admin.freshToken(),
1418
+ const beforeRevoke = await call("GET", "/auth/admin/users", {
1419
+ token: revokee.token,
710
1420
  workspaceId: admin.workspaceId,
711
1421
  });
712
- assert(succeeded(revoked.status), `admin can revoke a direct grant (got ${revoked.status})`);
1422
+ assert(
1423
+ beforeRevoke.status === 200,
1424
+ `the grant is in effect to begin with (got ${beforeRevoke.status})`,
1425
+ );
713
1426
 
714
- const afterRevokeCall = await call("GET", "/auth/admin/users", { token: revokee.token, workspaceId: admin.workspaceId });
715
- assert(afterRevokeCall.status === 403, `and the route closes again on the very next request (got ${afterRevokeCall.status})`);
1427
+ const revoked = await call(
1428
+ "POST",
1429
+ `/auth/admin/users/${revokee.userId}/permissions/${encodeURIComponent("users:read")}/revoke`,
1430
+ {
1431
+ token: await admin.freshToken(),
1432
+ workspaceId: admin.workspaceId,
1433
+ },
1434
+ );
1435
+ assert(
1436
+ succeeded(revoked.status),
1437
+ `admin can revoke a direct grant (got ${revoked.status})`,
1438
+ );
1439
+
1440
+ const afterRevokeCall = await call("GET", "/auth/admin/users", {
1441
+ token: revokee.token,
1442
+ workspaceId: admin.workspaceId,
1443
+ });
1444
+ assert(
1445
+ afterRevokeCall.status === 403,
1446
+ `and the route closes again on the very next request (got ${afterRevokeCall.status})`,
1447
+ );
716
1448
 
717
- console.log("12d. front/back equivalence: an ability rebuilt from /auth/me answers exactly as the server enforces");
1449
+ console.log(
1450
+ "12d. front/back equivalence: an ability rebuilt from /auth/me answers exactly as the server enforces",
1451
+ );
718
1452
  // This is the property the whole flat-slug model exists for. The client is given permission
719
1453
  // slugs and builds its ability with the *same* `defineAbilitiesFor` the server's guard used.
720
1454
  // Comparing predicted allow/deny against the real HTTP status for every gated route is what
721
1455
  // makes "the console cannot offer a button the API refuses" a fact rather than a hope.
722
1456
  async function proveEquivalence(label: string, token: string) {
723
- const me = await call("GET", "/auth/me", { token, workspaceId: admin.workspaceId });
724
- const ability = defineAbilitiesFor((me.body?.permissions ?? []) as string[]);
1457
+ const me = await call("GET", "/auth/me", {
1458
+ token,
1459
+ workspaceId: admin.workspaceId,
1460
+ });
1461
+ const ability = defineAbilitiesFor(
1462
+ (me.body?.permissions ?? []) as string[],
1463
+ );
725
1464
  const results = await runProbes(token, probeSeq++);
726
1465
 
727
1466
  const disagreements = results
728
- .map((r) => ({ label: r.probe.label, predicted: ability.can(r.probe.permission, ABILITY_SUBJECT), served: r.status !== 403 }))
1467
+ .map((r) => ({
1468
+ label: r.probe.label,
1469
+ predicted: ability.can(r.probe.permission, ABILITY_SUBJECT),
1470
+ served: r.status !== 403,
1471
+ }))
729
1472
  .filter((r) => r.predicted !== r.served);
730
1473
  assert(
731
1474
  disagreements.length === 0,
732
1475
  `${label}: all ${results.length} gated routes answer exactly as the client-side ability predicts` +
733
- (disagreements.length ? ` (disagreements: ${disagreements.map((d) => `${d.label} predicted=${d.predicted}`).join(", ")})` : ""),
1476
+ (disagreements.length
1477
+ ? ` (disagreements: ${disagreements.map((d) => `${d.label} predicted=${d.predicted}`).join(", ")})`
1478
+ : ""),
1479
+ );
1480
+ return new Set(
1481
+ results.filter((r) => r.status !== 403).map((r) => r.probe.label),
734
1482
  );
735
- return new Set(results.filter((r) => r.status !== 403).map((r) => r.probe.label));
736
1483
  }
737
1484
 
738
- const adminAllows = await proveEquivalence("admin", await admin.freshToken());
739
- const bareAllows = await proveEquivalence("a member holding a role with no permissions", bare.token);
740
- const grantAllows = await proveEquivalence(`a user holding only "${singleGrantUser!.permission}"`, singleGrantUser!.token);
1485
+ const adminAllows = await proveEquivalence(
1486
+ "admin",
1487
+ await admin.freshToken(),
1488
+ );
1489
+ const bareAllows = await proveEquivalence(
1490
+ "a member holding a role with no permissions",
1491
+ bare.token,
1492
+ );
1493
+ const grantAllows = await proveEquivalence(
1494
+ `a user holding only "${singleGrantUser!.permission}"`,
1495
+ singleGrantUser!.token,
1496
+ );
741
1497
  assert(
742
- adminAllows.size > grantAllows.size && grantAllows.size > bareAllows.size && bareAllows.size === 0,
1498
+ adminAllows.size > grantAllows.size &&
1499
+ grantAllows.size > bareAllows.size &&
1500
+ bareAllows.size === 0,
743
1501
  `…and the three callers genuinely differ, so the comparison is not vacuous (admin=${adminAllows.size}, single-grant=${grantAllows.size}, bare=${bareAllows.size})`,
744
1502
  );
745
1503
 
746
- console.log("12e. a permission edited in the database changes enforcement, with no code change and no redeploy");
1504
+ console.log(
1505
+ "12e. a permission edited in the database changes enforcement, with no code change and no redeploy",
1506
+ );
747
1507
  // `isActive: false` is the deny: the row stops appearing in any ability that would have
748
1508
  // carried it, without unpicking a single grant. Written here through the admin API — each
749
1509
  // variant's hooks prove the same thing again with a raw database write, to rule out the API
@@ -753,28 +1513,58 @@ async function main() {
753
1513
  workspaceId: admin.workspaceId,
754
1514
  body: { slug: "users:read", isActive: false },
755
1515
  });
756
- assert(succeeded(deactivated.status), `an admin can deactivate a permission (got ${deactivated.status})`);
1516
+ assert(
1517
+ succeeded(deactivated.status),
1518
+ `an admin can deactivate a permission (got ${deactivated.status})`,
1519
+ );
757
1520
 
758
- const listAfterDeactivate = await call("GET", "/auth/admin/permissions", { token: await admin.freshToken(), workspaceId: admin.workspaceId });
1521
+ const listAfterDeactivate = await call("GET", "/auth/admin/permissions", {
1522
+ token: await admin.freshToken(),
1523
+ workspaceId: admin.workspaceId,
1524
+ });
759
1525
  assert(
760
- (listAfterDeactivate.body?.permissions ?? []).some((p: { slug: string; isActive: boolean }) => p.slug === "users:read" && p.isActive === false),
1526
+ (listAfterDeactivate.body?.permissions ?? []).some(
1527
+ (p: { slug: string; isActive: boolean }) =>
1528
+ p.slug === "users:read" && p.isActive === false,
1529
+ ),
761
1530
  "the catalog reports it as inactive",
762
1531
  );
763
1532
 
764
- const deniedByEdit = await call("GET", "/auth/admin/users", { token: singleGrantUser!.token, workspaceId: admin.workspaceId });
765
- assert(deniedByEdit.status === 403, `the route it opened is now refused for a user whose grant is untouched (got ${deniedByEdit.status})`);
1533
+ const deniedByEdit = await call("GET", "/auth/admin/users", {
1534
+ token: singleGrantUser!.token,
1535
+ workspaceId: admin.workspaceId,
1536
+ });
1537
+ assert(
1538
+ deniedByEdit.status === 403,
1539
+ `the route it opened is now refused for a user whose grant is untouched (got ${deniedByEdit.status})`,
1540
+ );
766
1541
 
767
- const adminDeniedToo = await call("GET", "/auth/admin/users", { token: await admin.freshToken(), workspaceId: admin.workspaceId });
768
- assert(adminDeniedToo.status === 403, `…including for the administrator, whose role still carries it (got ${adminDeniedToo.status})`);
1542
+ const adminDeniedToo = await call("GET", "/auth/admin/users", {
1543
+ token: await admin.freshToken(),
1544
+ workspaceId: admin.workspaceId,
1545
+ });
1546
+ assert(
1547
+ adminDeniedToo.status === 403,
1548
+ `…including for the administrator, whose role still carries it (got ${adminDeniedToo.status})`,
1549
+ );
769
1550
 
770
1551
  const reactivated = await call("POST", "/auth/admin/permissions", {
771
1552
  token: await admin.freshToken(),
772
1553
  workspaceId: admin.workspaceId,
773
1554
  body: { slug: "users:read", isActive: true },
774
1555
  });
775
- assert(succeeded(reactivated.status), `an admin can reactivate it (got ${reactivated.status})`);
776
- const restored = await call("GET", "/auth/admin/users", { token: singleGrantUser!.token, workspaceId: admin.workspaceId });
777
- assert(restored.status === 200, `and every grant that pointed at it works again immediately (got ${restored.status})`);
1556
+ assert(
1557
+ succeeded(reactivated.status),
1558
+ `an admin can reactivate it (got ${reactivated.status})`,
1559
+ );
1560
+ const restored = await call("GET", "/auth/admin/users", {
1561
+ token: singleGrantUser!.token,
1562
+ workspaceId: admin.workspaceId,
1563
+ });
1564
+ assert(
1565
+ restored.status === 200,
1566
+ `and every grant that pointed at it works again immediately (got ${restored.status})`,
1567
+ );
778
1568
 
779
1569
  console.log(`13. ${hooks.variant}-specific properties`);
780
1570
  await hooks.proveVariantProperties(ctx, admin);