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