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