@simple-auth-kit/cli 1.5.1 → 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 (182) hide show
  1. package/package.json +1 -1
  2. package/registry/combos/express-drizzle/shared/src/{ability.ts → ability/ability.ts} +8 -2
  3. package/registry/combos/express-drizzle/shared/src/{permission-cache.ts → cache/permission-cache.ts} +25 -9
  4. package/registry/combos/express-drizzle/shared/src/{rate-limit.store.ts → cache/rate-limit.store.ts} +4 -1
  5. package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/config}/auth.config.ts +1 -1
  6. package/registry/combos/express-drizzle/shared/src/{db.ts → config/db.ts} +1 -1
  7. package/registry/combos/express-drizzle/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
  8. package/registry/combos/express-drizzle/shared/src/{id.helper.ts → helpers/id.helper.ts} +6 -2
  9. package/registry/combos/{nestjs-drizzle/shared/src → express-drizzle/shared/src/helpers}/pagination.ts +17 -3
  10. package/registry/combos/express-drizzle/shared/src/middleware/auth-core-error.middleware.ts +6 -8
  11. package/registry/combos/express-drizzle/shared/src/middleware/auth.middleware.ts +6 -8
  12. package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/openapi}/openapi-fragment.ts +13 -4
  13. package/registry/combos/express-drizzle/shared/src/{openapi-spec.ts → openapi/openapi-spec.ts} +251 -52
  14. package/registry/combos/express-drizzle/shared/src/repositories/oauth.repository.ts +2 -2
  15. package/registry/combos/express-drizzle/shared/src/repositories/password-reset.repository.ts +2 -4
  16. package/registry/combos/express-drizzle/shared/src/repositories/session.repository.ts +3 -3
  17. package/registry/combos/express-drizzle/shared/src/request-context.ts +1 -1
  18. package/registry/combos/express-drizzle/shared/src/route-tiers.ts +52 -11
  19. package/registry/combos/express-drizzle/shared/src/routers/auth.router.ts +2 -2
  20. package/registry/combos/express-drizzle/test/bootstrap.ts +4 -2
  21. package/registry/combos/express-drizzle/test/prove-cycle.ts +1095 -308
  22. package/registry/combos/express-drizzle/variants/base/src/create-auth-app.ts +5 -5
  23. package/registry/combos/express-drizzle/variants/base/src/index.ts +8 -0
  24. package/registry/combos/express-drizzle/variants/base/src/middleware/authz.middleware.ts +2 -2
  25. package/registry/combos/express-drizzle/variants/base/src/openapi/openapi-admin.ts +1082 -0
  26. package/registry/combos/express-drizzle/variants/base/src/rbac.defaults.ts +60 -17
  27. package/registry/combos/express-drizzle/variants/base/src/repositories/audit-log.repository.ts +2 -2
  28. package/registry/combos/express-drizzle/variants/base/src/repositories/rbac.repository.ts +5 -5
  29. package/registry/combos/express-drizzle/variants/base/src/routers/admin.router.ts +18 -22
  30. package/registry/combos/express-drizzle/variants/base/src/seed.ts +76 -17
  31. package/registry/combos/express-drizzle/variants/base/src/services/auth.service.ts +7 -9
  32. package/registry/combos/express-drizzle/variants/base/test/variant-hooks.ts +167 -45
  33. package/registry/combos/express-drizzle/variants/workspaces/src/create-auth-app.ts +5 -5
  34. package/registry/combos/express-drizzle/variants/workspaces/src/index.ts +8 -0
  35. package/registry/combos/express-drizzle/variants/workspaces/src/middleware/authz.middleware.ts +3 -3
  36. package/registry/combos/express-drizzle/variants/workspaces/src/openapi/openapi-admin.ts +1176 -0
  37. package/registry/combos/express-drizzle/variants/workspaces/src/openapi-workspace.ts +1 -1
  38. package/registry/combos/express-drizzle/variants/workspaces/src/rbac.defaults.ts +66 -19
  39. package/registry/combos/express-drizzle/variants/workspaces/src/repositories/audit-log.repository.ts +2 -2
  40. package/registry/combos/express-drizzle/variants/workspaces/src/repositories/rbac.repository.ts +5 -5
  41. package/registry/combos/express-drizzle/variants/workspaces/src/repositories/workspace.repository.ts +9 -11
  42. package/registry/combos/express-drizzle/variants/workspaces/src/routers/admin.router.ts +18 -22
  43. package/registry/combos/express-drizzle/variants/workspaces/src/routers/workspace.router.ts +1 -1
  44. package/registry/combos/express-drizzle/variants/workspaces/src/seed.ts +168 -35
  45. package/registry/combos/express-drizzle/variants/workspaces/src/services/auth.service.ts +7 -9
  46. package/registry/combos/express-drizzle/variants/workspaces/test/variant-hooks.ts +539 -133
  47. package/registry/combos/express-prisma/shared/src/{ability.ts → ability/ability.ts} +8 -2
  48. package/registry/combos/express-prisma/shared/src/{permission-cache.ts → cache/permission-cache.ts} +25 -9
  49. package/registry/combos/express-prisma/shared/src/{rate-limit.store.ts → cache/rate-limit.store.ts} +4 -1
  50. package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/config}/auth.config.ts +1 -1
  51. package/registry/combos/express-prisma/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
  52. package/registry/combos/{nestjs-prisma/shared/src → express-prisma/shared/src/helpers}/id.helper.ts +6 -2
  53. package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/helpers}/pagination.ts +17 -3
  54. package/registry/combos/express-prisma/shared/src/middleware/auth-core-error.middleware.ts +6 -8
  55. package/registry/combos/express-prisma/shared/src/middleware/auth.middleware.ts +6 -8
  56. package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/openapi}/openapi-fragment.ts +13 -4
  57. package/registry/combos/express-prisma/shared/src/{openapi-spec.ts → openapi/openapi-spec.ts} +313 -55
  58. package/registry/combos/express-prisma/shared/src/repositories/oauth.repository.ts +2 -2
  59. package/registry/combos/express-prisma/shared/src/repositories/password-reset.repository.ts +2 -4
  60. package/registry/combos/express-prisma/shared/src/repositories/session.repository.ts +2 -2
  61. package/registry/combos/express-prisma/shared/src/request-context.ts +1 -1
  62. package/registry/combos/express-prisma/shared/src/route-tiers.ts +52 -11
  63. package/registry/combos/express-prisma/shared/src/routers/auth.router.ts +2 -2
  64. package/registry/combos/express-prisma/test/bootstrap.ts +7 -3
  65. package/registry/combos/express-prisma/test/prove-cycle.ts +1095 -308
  66. package/registry/combos/express-prisma/variants/base/src/create-auth-app.ts +6 -6
  67. package/registry/combos/express-prisma/variants/base/src/index.ts +8 -0
  68. package/registry/combos/express-prisma/variants/base/src/middleware/authz.middleware.ts +2 -2
  69. package/registry/combos/express-prisma/variants/base/src/openapi/openapi-admin.ts +1102 -0
  70. package/registry/combos/express-prisma/variants/base/src/rbac.defaults.ts +1 -1
  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/seed.ts +1 -1
  75. package/registry/combos/express-prisma/variants/base/src/services/auth.service.ts +6 -8
  76. package/registry/combos/express-prisma/variants/base/test/variant-hooks.ts +176 -47
  77. package/registry/combos/express-prisma/variants/workspaces/src/create-auth-app.ts +6 -6
  78. package/registry/combos/express-prisma/variants/workspaces/src/index.ts +8 -0
  79. package/registry/combos/express-prisma/variants/workspaces/src/middleware/authz.middleware.ts +3 -3
  80. package/registry/combos/express-prisma/variants/workspaces/src/openapi/openapi-admin.ts +1218 -0
  81. package/registry/combos/express-prisma/variants/workspaces/src/openapi-workspace.ts +1 -1
  82. package/registry/combos/express-prisma/variants/workspaces/src/rbac.defaults.ts +1 -1
  83. package/registry/combos/express-prisma/variants/workspaces/src/repositories/audit-log.repository.ts +2 -2
  84. package/registry/combos/express-prisma/variants/workspaces/src/repositories/rbac.repository.ts +4 -4
  85. package/registry/combos/express-prisma/variants/workspaces/src/repositories/workspace.repository.ts +2 -2
  86. package/registry/combos/express-prisma/variants/workspaces/src/routers/admin.router.ts +18 -22
  87. package/registry/combos/express-prisma/variants/workspaces/src/routers/workspace.router.ts +1 -1
  88. package/registry/combos/express-prisma/variants/workspaces/src/seed.ts +1 -1
  89. package/registry/combos/express-prisma/variants/workspaces/src/services/auth.service.ts +6 -8
  90. package/registry/combos/express-prisma/variants/workspaces/test/variant-hooks.ts +538 -134
  91. package/registry/combos/nestjs-drizzle/shared/src/{guards → ability}/ability.guard.ts +1 -1
  92. package/registry/combos/nestjs-drizzle/shared/src/{ability.ts → ability/ability.ts} +8 -2
  93. package/registry/combos/nestjs-drizzle/shared/src/{permission-cache.ts → cache/permission-cache.ts} +27 -10
  94. package/registry/combos/{nestjs-prisma/shared/src → nestjs-drizzle/shared/src/cache}/rate-limit.store.ts +4 -1
  95. package/registry/combos/nestjs-drizzle/shared/src/{auth.config.ts → config/auth.config.ts} +1 -1
  96. package/registry/combos/nestjs-drizzle/shared/src/{db.ts → config/db.ts} +1 -1
  97. package/registry/combos/nestjs-drizzle/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
  98. package/registry/combos/nestjs-drizzle/shared/src/controllers/auth.controller.ts +1 -1
  99. package/registry/combos/nestjs-drizzle/shared/src/guards/auth.guard.ts +1 -1
  100. package/registry/combos/nestjs-drizzle/shared/src/{id.helper.ts → helpers/id.helper.ts} +6 -2
  101. package/registry/combos/{express-prisma/shared/src → nestjs-drizzle/shared/src/helpers}/pagination.ts +17 -3
  102. package/registry/combos/nestjs-drizzle/shared/src/repositories/oauth.repository.ts +2 -2
  103. package/registry/combos/nestjs-drizzle/shared/src/repositories/password-reset.repository.ts +8 -12
  104. package/registry/combos/nestjs-drizzle/shared/src/repositories/session.repository.ts +3 -3
  105. package/registry/combos/nestjs-drizzle/shared/src/repositories/two-factor.repository.ts +1 -1
  106. package/registry/combos/nestjs-drizzle/shared/src/request-context.ts +1 -1
  107. package/registry/combos/nestjs-drizzle/test/bootstrap.ts +1 -1
  108. package/registry/combos/nestjs-drizzle/test/prove-cycle.ts +1 -1
  109. package/registry/combos/nestjs-drizzle/variants/base/src/auth.module.ts +10 -6
  110. package/registry/combos/nestjs-drizzle/variants/base/src/controllers/admin.controller.ts +1 -1
  111. package/registry/combos/nestjs-drizzle/variants/base/src/guards/authz.guard.ts +2 -2
  112. package/registry/combos/nestjs-drizzle/variants/base/src/index.ts +9 -0
  113. package/registry/combos/nestjs-drizzle/variants/base/src/rbac.defaults.ts +58 -16
  114. package/registry/combos/nestjs-drizzle/variants/base/src/repositories/audit-log.repository.ts +3 -3
  115. package/registry/combos/nestjs-drizzle/variants/base/src/repositories/rbac.repository.ts +4 -4
  116. package/registry/combos/nestjs-drizzle/variants/base/src/seed.ts +76 -17
  117. package/registry/combos/nestjs-drizzle/variants/base/src/services/auth.service.ts +7 -9
  118. package/registry/combos/nestjs-drizzle/variants/base/test/variant-hooks.ts +167 -45
  119. package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.module.ts +10 -6
  120. package/registry/combos/nestjs-drizzle/variants/workspaces/src/controllers/admin.controller.ts +1 -1
  121. package/registry/combos/nestjs-drizzle/variants/workspaces/src/controllers/workspace.controller.ts +1 -1
  122. package/registry/combos/nestjs-drizzle/variants/workspaces/src/guards/authz.guard.ts +2 -2
  123. package/registry/combos/nestjs-drizzle/variants/workspaces/src/index.ts +9 -0
  124. package/registry/combos/nestjs-drizzle/variants/workspaces/src/rbac.defaults.ts +64 -18
  125. package/registry/combos/nestjs-drizzle/variants/workspaces/src/repositories/audit-log.repository.ts +3 -3
  126. package/registry/combos/nestjs-drizzle/variants/workspaces/src/repositories/rbac.repository.ts +4 -4
  127. package/registry/combos/nestjs-drizzle/variants/workspaces/src/repositories/workspace.repository.ts +8 -10
  128. package/registry/combos/nestjs-drizzle/variants/workspaces/src/seed.ts +171 -36
  129. package/registry/combos/nestjs-drizzle/variants/workspaces/src/services/auth.service.ts +7 -9
  130. package/registry/combos/nestjs-drizzle/variants/workspaces/test/variant-hooks.ts +539 -133
  131. package/registry/combos/nestjs-prisma/shared/src/{guards → ability}/ability.guard.ts +1 -1
  132. package/registry/combos/nestjs-prisma/shared/src/{ability.ts → ability/ability.ts} +8 -2
  133. package/registry/combos/nestjs-prisma/shared/src/{permission-cache.ts → cache/permission-cache.ts} +27 -10
  134. package/registry/combos/{nestjs-drizzle/shared/src → nestjs-prisma/shared/src/cache}/rate-limit.store.ts +4 -1
  135. package/registry/combos/nestjs-prisma/shared/src/{auth.config.ts → config/auth.config.ts} +1 -1
  136. package/registry/combos/nestjs-prisma/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
  137. package/registry/combos/nestjs-prisma/shared/src/controllers/auth.controller.ts +1 -1
  138. package/registry/combos/nestjs-prisma/shared/src/guards/auth.guard.ts +1 -1
  139. package/registry/combos/{express-prisma/shared/src → nestjs-prisma/shared/src/helpers}/id.helper.ts +6 -2
  140. package/registry/combos/nestjs-prisma/shared/src/{pagination.ts → helpers/pagination.ts} +17 -3
  141. package/registry/combos/nestjs-prisma/shared/src/repositories/oauth.repository.ts +1 -1
  142. package/registry/combos/nestjs-prisma/shared/src/repositories/password-reset.repository.ts +2 -4
  143. package/registry/combos/nestjs-prisma/shared/src/repositories/session.repository.ts +2 -2
  144. package/registry/combos/nestjs-prisma/shared/src/request-context.ts +1 -1
  145. package/registry/combos/nestjs-prisma/test/bootstrap.ts +1 -1
  146. package/registry/combos/nestjs-prisma/test/prove-cycle.ts +1 -1
  147. package/registry/combos/nestjs-prisma/variants/base/src/auth.module.ts +10 -6
  148. package/registry/combos/nestjs-prisma/variants/base/src/controllers/admin.controller.ts +1 -1
  149. package/registry/combos/nestjs-prisma/variants/base/src/gateways/audit-log.gateway.ts +1 -1
  150. package/registry/combos/nestjs-prisma/variants/base/src/guards/authz.guard.ts +2 -2
  151. package/registry/combos/nestjs-prisma/variants/base/src/index.ts +9 -0
  152. package/registry/combos/nestjs-prisma/variants/base/src/{docs.ts → openapi/docs.ts} +23 -6
  153. package/registry/combos/nestjs-prisma/variants/base/src/rbac.defaults.ts +1 -1
  154. package/registry/combos/nestjs-prisma/variants/base/src/repositories/audit-log.repository.ts +2 -2
  155. package/registry/combos/nestjs-prisma/variants/base/src/repositories/country.repository.ts +2 -2
  156. package/registry/combos/nestjs-prisma/variants/base/src/repositories/customer.repository.ts +6 -5
  157. package/registry/combos/nestjs-prisma/variants/base/src/repositories/language.repository.ts +2 -2
  158. package/registry/combos/nestjs-prisma/variants/base/src/repositories/rbac.repository.ts +3 -3
  159. package/registry/combos/nestjs-prisma/variants/base/src/seed.ts +1 -1
  160. package/registry/combos/nestjs-prisma/variants/base/src/services/auth.service.ts +6 -8
  161. package/registry/combos/nestjs-prisma/variants/base/test/variant-hooks.ts +176 -47
  162. package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.module.ts +10 -6
  163. package/registry/combos/nestjs-prisma/variants/workspaces/src/controllers/admin.controller.ts +1 -1
  164. package/registry/combos/nestjs-prisma/variants/workspaces/src/controllers/workspace.controller.ts +1 -1
  165. package/registry/combos/nestjs-prisma/variants/workspaces/src/guards/authz.guard.ts +2 -2
  166. package/registry/combos/nestjs-prisma/variants/workspaces/src/index.ts +9 -0
  167. package/registry/combos/nestjs-prisma/variants/workspaces/src/rbac.defaults.ts +1 -1
  168. package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/audit-log.repository.ts +2 -2
  169. package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/country.repository.ts +2 -2
  170. package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/customer.repository.ts +6 -5
  171. package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/language.repository.ts +2 -2
  172. package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/rbac.repository.ts +3 -3
  173. package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/workspace.repository.ts +1 -1
  174. package/registry/combos/nestjs-prisma/variants/workspaces/src/seed.ts +1 -1
  175. package/registry/combos/nestjs-prisma/variants/workspaces/src/services/auth.service.ts +6 -8
  176. package/registry/combos/nestjs-prisma/variants/workspaces/test/variant-hooks.ts +538 -134
  177. package/registry/combos/express-drizzle/variants/base/src/openapi-admin.ts +0 -565
  178. package/registry/combos/express-drizzle/variants/workspaces/src/openapi-admin.ts +0 -596
  179. package/registry/combos/express-prisma/variants/base/src/openapi-admin.ts +0 -569
  180. package/registry/combos/express-prisma/variants/workspaces/src/openapi-admin.ts +0 -601
  181. /package/registry/combos/express-drizzle/shared/src/{http-error.ts → errors/http-error.ts} +0 -0
  182. /package/registry/combos/express-prisma/shared/src/{http-error.ts → errors/http-error.ts} +0 -0
@@ -7,11 +7,19 @@
7
7
  import "dotenv/config";
8
8
  import { PrismaPg } from "@prisma/adapter-pg";
9
9
  import { PrismaClient } from "../generated/prisma/client.js";
10
- import { POLICY_VERSION_KEY } from "../src/permission-cache.js";
10
+ import { POLICY_VERSION_KEY } from "../src/cache/permission-cache.js";
11
11
  import { permissionCacheStore } from "./bootstrap.js";
12
- import { renewingToken, type AdminSession, type Principal, type ProofContext, type VariantHooks } from "./harness.js";
13
-
14
- const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env["DATABASE_URL"] }) });
12
+ import {
13
+ renewingToken,
14
+ type AdminSession,
15
+ type Principal,
16
+ type ProofContext,
17
+ type VariantHooks,
18
+ } from "./harness.js";
19
+
20
+ const prisma = new PrismaClient({
21
+ adapter: new PrismaPg({ connectionString: process.env["DATABASE_URL"] }),
22
+ });
15
23
 
16
24
  interface Workspace {
17
25
  id: string;
@@ -24,19 +32,42 @@ async function newAdminWithWorkspace(ctx: ProofContext, label: string) {
24
32
  const email = ctx.uniqueEmail(label);
25
33
  const password = `${label}-pw-12345`;
26
34
  const tokens = await ctx.signup(email, password);
27
- const userId = (await ctx.call("GET", "/auth/me", { token: tokens.accessToken })).body.sub as string;
28
- const created = await ctx.call("POST", "/workspaces", { token: tokens.accessToken, body: { name: `${label}-workspace` } });
29
- return { email, password, userId, token: tokens.accessToken, workspace: created.body as Workspace };
35
+ const userId = (
36
+ await ctx.call("GET", "/auth/me", { token: tokens.accessToken })
37
+ ).body.sub as string;
38
+ const created = await ctx.call("POST", "/workspaces", {
39
+ token: tokens.accessToken,
40
+ body: { name: `${label}-workspace` },
41
+ });
42
+ return {
43
+ email,
44
+ password,
45
+ userId,
46
+ token: tokens.accessToken,
47
+ workspace: created.body as Workspace,
48
+ };
30
49
  }
31
50
 
32
51
  export const hooks: VariantHooks = {
33
52
  variant: "workspaces",
34
53
 
35
- async makeAdmin(ctx: ProofContext, principal: Principal): Promise<AdminSession> {
36
- const created = await ctx.call("POST", "/workspaces", { token: principal.tokens.accessToken, body: { name: "proof-workspace" } });
37
- ctx.assert(created.status === 201 || created.status === 200, `creating a workspace succeeds (got ${created.status})`);
54
+ async makeAdmin(
55
+ ctx: ProofContext,
56
+ principal: Principal,
57
+ ): Promise<AdminSession> {
58
+ const created = await ctx.call("POST", "/workspaces", {
59
+ token: principal.tokens.accessToken,
60
+ body: { name: "proof-workspace" },
61
+ });
62
+ ctx.assert(
63
+ created.status === 201 || created.status === 200,
64
+ `creating a workspace succeeds (got ${created.status})`,
65
+ );
38
66
  const workspace = created.body as Workspace;
39
- ctx.assert(workspace.roles.includes("admin"), "the creator of a workspace is an admin member of it");
67
+ ctx.assert(
68
+ workspace.roles.includes("admin"),
69
+ "the creator of a workspace is an admin member of it",
70
+ );
40
71
  // No re-login, and no fresh token needed: the access token is workspace-agnostic and carries
41
72
  // no authorization, so the new membership is in effect immediately and the token never goes
42
73
  // stale in any way that matters to authorization. `freshToken` re-logs in anyway, because the
@@ -45,15 +76,28 @@ export const hooks: VariantHooks = {
45
76
  token: principal.tokens.accessToken,
46
77
  workspaceId: workspace.id,
47
78
  freshToken: renewingToken(async () => {
48
- const res = await ctx.call("POST", "/auth/login", { body: { identifier: principal.email, password: principal.password } });
79
+ const res = await ctx.call("POST", "/auth/login", {
80
+ body: { identifier: principal.email, password: principal.password },
81
+ });
49
82
  return (res.body as { accessToken: string }).accessToken;
50
83
  }),
51
84
  };
52
85
  },
53
86
 
54
- async admitUser(ctx: ProofContext, admin: AdminSession, user: { userId: string; email: string }): Promise<void> {
55
- const added = await ctx.call("POST", "/workspaces/members", { token: await admin.freshToken(), workspaceId: admin.workspaceId, body: { email: user.email } });
56
- ctx.assert(added.status === 201 || added.status === 200, `adding a user to the workspace succeeds (got ${added.status})`);
87
+ async admitUser(
88
+ ctx: ProofContext,
89
+ admin: AdminSession,
90
+ user: { userId: string; email: string },
91
+ ): Promise<void> {
92
+ const added = await ctx.call("POST", "/workspaces/members", {
93
+ token: await admin.freshToken(),
94
+ workspaceId: admin.workspaceId,
95
+ body: { email: user.email },
96
+ });
97
+ ctx.assert(
98
+ added.status === 201 || added.status === 200,
99
+ `adding a user to the workspace succeeds (got ${added.status})`,
100
+ );
57
101
  },
58
102
 
59
103
  async proveVariantProperties(ctx: ProofContext): Promise<void> {
@@ -61,115 +105,294 @@ export const hooks: VariantHooks = {
61
105
  const alpha = await newAdminWithWorkspace(ctx, "alpha");
62
106
  const beta = await newAdminWithWorkspace(ctx, "beta");
63
107
 
64
- ctx.assert(alpha.workspace.id !== beta.workspace.id, "two workspaces are distinct");
108
+ ctx.assert(
109
+ alpha.workspace.id !== beta.workspace.id,
110
+ "two workspaces are distinct",
111
+ );
65
112
 
66
113
  // --- a non-member cannot even see a workspace exists ---
67
- const outsider = await ctx.call("GET", "/auth/admin/users", { token: alpha.token, workspaceId: beta.workspace.id });
68
- ctx.assert(outsider.status === 403, `an admin of one workspace is refused in a workspace they are not a member of (got ${outsider.status})`);
114
+ const outsider = await ctx.call("GET", "/auth/admin/users", {
115
+ token: alpha.token,
116
+ workspaceId: beta.workspace.id,
117
+ });
118
+ ctx.assert(
119
+ outsider.status === 403,
120
+ `an admin of one workspace is refused in a workspace they are not a member of (got ${outsider.status})`,
121
+ );
69
122
 
70
- const bogus = await ctx.call("GET", "/auth/admin/users", { token: alpha.token, workspaceId: "00000000-0000-0000-0000-000000000000" });
71
- ctx.assert(bogus.status === 403, `an unknown workspace id is refused the same way as someone else's (got ${bogus.status})`);
123
+ const bogus = await ctx.call("GET", "/auth/admin/users", {
124
+ token: alpha.token,
125
+ workspaceId: "00000000-0000-0000-0000-000000000000",
126
+ });
127
+ ctx.assert(
128
+ bogus.status === 403,
129
+ `an unknown workspace id is refused the same way as someone else's (got ${bogus.status})`,
130
+ );
72
131
 
73
- const noHeader = await ctx.call("GET", "/auth/admin/users", { token: alpha.token });
74
- ctx.assert(noHeader.status === 403, `a workspace-scoped route refuses a request that names no workspace (got ${noHeader.status})`);
132
+ const noHeader = await ctx.call("GET", "/auth/admin/users", {
133
+ token: alpha.token,
134
+ });
135
+ ctx.assert(
136
+ noHeader.status === 403,
137
+ `a workspace-scoped route refuses a request that names no workspace (got ${noHeader.status})`,
138
+ );
75
139
 
76
140
  // --- alpha joins beta as a plain member: their admin role in alpha must not follow them ---
77
- const added = await ctx.call("POST", "/workspaces/members", { token: beta.token, workspaceId: beta.workspace.id, body: { email: alpha.email } });
78
- ctx.assert(added.status === 201 || added.status === 200, `an admin can add an existing user to their workspace (got ${added.status})`);
141
+ const added = await ctx.call("POST", "/workspaces/members", {
142
+ token: beta.token,
143
+ workspaceId: beta.workspace.id,
144
+ body: { email: alpha.email },
145
+ });
146
+ ctx.assert(
147
+ added.status === 201 || added.status === 200,
148
+ `an admin can add an existing user to their workspace (got ${added.status})`,
149
+ );
79
150
  const alphaInBeta = added.body as { memberId: string; roles: string[] };
80
- ctx.assert(alphaInBeta.roles.join() === "member", `a new member defaults to the member role (got ${alphaInBeta.roles.join()})`);
151
+ ctx.assert(
152
+ alphaInBeta.roles.join() === "member",
153
+ `a new member defaults to the member role (got ${alphaInBeta.roles.join()})`,
154
+ );
81
155
 
82
- const meInAlpha = await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: alpha.workspace.id });
83
- ctx.assert(meInAlpha.body.roles.includes("admin"), "the same token is admin in its own workspace");
156
+ const meInAlpha = await ctx.call("GET", "/auth/me", {
157
+ token: alpha.token,
158
+ workspaceId: alpha.workspace.id,
159
+ });
160
+ ctx.assert(
161
+ meInAlpha.body.roles.includes("admin"),
162
+ "the same token is admin in its own workspace",
163
+ );
84
164
 
85
- const meInBeta = await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: beta.workspace.id });
86
- ctx.assert(!meInBeta.body.roles.includes("admin"), "THE core property: the admin role held in workspace A is absent in workspace B");
87
- ctx.assert(meInBeta.body.roles.includes("member"), "…and only the roles granted by workspace B's membership apply there");
165
+ const meInBeta = await ctx.call("GET", "/auth/me", {
166
+ token: alpha.token,
167
+ workspaceId: beta.workspace.id,
168
+ });
169
+ ctx.assert(
170
+ !meInBeta.body.roles.includes("admin"),
171
+ "THE core property: the admin role held in workspace A is absent in workspace B",
172
+ );
173
+ ctx.assert(
174
+ meInBeta.body.roles.includes("member"),
175
+ "…and only the roles granted by workspace B's membership apply there",
176
+ );
88
177
 
89
- const adminAttemptInBeta = await ctx.call("GET", "/auth/admin/users", { token: alpha.token, workspaceId: beta.workspace.id });
90
- ctx.assert(adminAttemptInBeta.status === 403, `being an admin elsewhere does not unlock workspace B's admin endpoints (got ${adminAttemptInBeta.status})`);
178
+ const adminAttemptInBeta = await ctx.call("GET", "/auth/admin/users", {
179
+ token: alpha.token,
180
+ workspaceId: beta.workspace.id,
181
+ });
182
+ ctx.assert(
183
+ adminAttemptInBeta.status === 403,
184
+ `being an admin elsewhere does not unlock workspace B's admin endpoints (got ${adminAttemptInBeta.status})`,
185
+ );
91
186
 
92
187
  // --- roles are per workspace: the same role name can mean different things ---
93
- const roleInBeta = await ctx.call("POST", "/auth/admin/roles", { token: beta.token, workspaceId: beta.workspace.id, body: { slug: "shared-name" } });
94
- const roleInAlpha = await ctx.call("POST", "/auth/admin/roles", { token: alpha.token, workspaceId: alpha.workspace.id, body: { slug: "shared-name" } });
95
- ctx.assert(roleInBeta.status === 201 && roleInAlpha.status === 201, "the same role name can exist independently in two workspaces");
188
+ const roleInBeta = await ctx.call("POST", "/auth/admin/roles", {
189
+ token: beta.token,
190
+ workspaceId: beta.workspace.id,
191
+ body: { slug: "shared-name" },
192
+ });
193
+ const roleInAlpha = await ctx.call("POST", "/auth/admin/roles", {
194
+ token: alpha.token,
195
+ workspaceId: alpha.workspace.id,
196
+ body: { slug: "shared-name" },
197
+ });
198
+ ctx.assert(
199
+ roleInBeta.status === 201 && roleInAlpha.status === 201,
200
+ "the same role name can exist independently in two workspaces",
201
+ );
96
202
 
97
- await ctx.call("POST", `/auth/admin/roles/${(roleInBeta.body as { id: string }).id}/permissions`, {
203
+ await ctx.call(
204
+ "POST",
205
+ `/auth/admin/roles/${(roleInBeta.body as { id: string }).id}/permissions`,
206
+ {
207
+ token: beta.token,
208
+ workspaceId: beta.workspace.id,
209
+ body: { permission: "beta:only" },
210
+ },
211
+ );
212
+ await ctx.call("POST", `/auth/admin/users/${alpha.userId}/roles`, {
98
213
  token: beta.token,
99
214
  workspaceId: beta.workspace.id,
100
- body: { permission: "beta:only" },
215
+ body: { role: "shared-name" },
101
216
  });
102
- await ctx.call("POST", `/auth/admin/users/${alpha.userId}/roles`, { token: beta.token, workspaceId: beta.workspace.id, body: { role: "shared-name" } });
103
217
 
104
- const permsInBeta = await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: beta.workspace.id });
105
- ctx.assert(permsInBeta.body.permissions.includes("beta:only"), "a role assigned in workspace B grants its permissions in workspace B");
106
- const permsInAlpha = await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: alpha.workspace.id });
107
- ctx.assert(!permsInAlpha.body.permissions.includes("beta:only"), "…and the identically-named role in workspace A does not carry them");
218
+ const permsInBeta = await ctx.call("GET", "/auth/me", {
219
+ token: alpha.token,
220
+ workspaceId: beta.workspace.id,
221
+ });
222
+ ctx.assert(
223
+ permsInBeta.body.permissions.includes("beta:only"),
224
+ "a role assigned in workspace B grants its permissions in workspace B",
225
+ );
226
+ const permsInAlpha = await ctx.call("GET", "/auth/me", {
227
+ token: alpha.token,
228
+ workspaceId: alpha.workspace.id,
229
+ });
230
+ ctx.assert(
231
+ !permsInAlpha.body.permissions.includes("beta:only"),
232
+ "…and the identically-named role in workspace A does not carry them",
233
+ );
108
234
 
109
235
  // --- direct grants attach to the membership, not the user ---
110
- const directGrant = await ctx.call("POST", `/auth/admin/users/${alpha.userId}/permissions`, {
111
- token: beta.token,
236
+ const directGrant = await ctx.call(
237
+ "POST",
238
+ `/auth/admin/users/${alpha.userId}/permissions`,
239
+ {
240
+ token: beta.token,
241
+ workspaceId: beta.workspace.id,
242
+ body: { permission: "beta:direct" },
243
+ },
244
+ );
245
+ ctx.assert(
246
+ directGrant.status === 201 || directGrant.status === 200,
247
+ `a direct grant inside workspace B succeeds (got ${directGrant.status})`,
248
+ );
249
+ const afterGrantBeta = await ctx.call("GET", "/auth/me", {
250
+ token: alpha.token,
112
251
  workspaceId: beta.workspace.id,
113
- body: { permission: "beta:direct" },
114
252
  });
115
- ctx.assert(directGrant.status === 201 || directGrant.status === 200, `a direct grant inside workspace B succeeds (got ${directGrant.status})`);
116
- const afterGrantBeta = await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: beta.workspace.id });
117
- ctx.assert(afterGrantBeta.body.permissions.includes("beta:direct"), "the direct grant applies in workspace B");
118
- const afterGrantAlpha = await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: alpha.workspace.id });
119
- ctx.assert(!afterGrantAlpha.body.permissions.includes("beta:direct"), "the direct grant does not leak into workspace A");
253
+ ctx.assert(
254
+ afterGrantBeta.body.permissions.includes("beta:direct"),
255
+ "the direct grant applies in workspace B",
256
+ );
257
+ const afterGrantAlpha = await ctx.call("GET", "/auth/me", {
258
+ token: alpha.token,
259
+ workspaceId: alpha.workspace.id,
260
+ });
261
+ ctx.assert(
262
+ !afterGrantAlpha.body.permissions.includes("beta:direct"),
263
+ "the direct grant does not leak into workspace A",
264
+ );
120
265
 
121
266
  // --- an admin cannot act on a user who is not in their workspace ---
122
- const crossBlock = await ctx.call("POST", `/auth/admin/users/${beta.userId}/block`, { token: alpha.token, workspaceId: alpha.workspace.id });
123
- ctx.assert(crossBlock.status === 404, `an admin cannot block a user who is not a member of their workspace (got ${crossBlock.status})`);
267
+ const crossBlock = await ctx.call(
268
+ "POST",
269
+ `/auth/admin/users/${beta.userId}/block`,
270
+ { token: alpha.token, workspaceId: alpha.workspace.id },
271
+ );
272
+ ctx.assert(
273
+ crossBlock.status === 404,
274
+ `an admin cannot block a user who is not a member of their workspace (got ${crossBlock.status})`,
275
+ );
124
276
 
125
277
  // --- audit log is scoped too ---
126
- const betaAudit = await ctx.call("GET", "/auth/admin/audit-log", { token: beta.token, workspaceId: beta.workspace.id });
127
- ctx.assert(betaAudit.status === 200, `an admin can read their workspace's audit log (got ${betaAudit.status})`);
278
+ const betaAudit = await ctx.call("GET", "/auth/admin/audit-log", {
279
+ token: beta.token,
280
+ workspaceId: beta.workspace.id,
281
+ });
128
282
  ctx.assert(
129
- (betaAudit.body.items as Array<{ workspaceId: string | null }>).every((e) => e.workspaceId === beta.workspace.id),
283
+ betaAudit.status === 200,
284
+ `an admin can read their workspace's audit log (got ${betaAudit.status})`,
285
+ );
286
+ ctx.assert(
287
+ (betaAudit.body.items as Array<{ workspaceId: string | null }>).every(
288
+ (e) => e.workspaceId === beta.workspace.id,
289
+ ),
130
290
  "the audit log never returns another workspace's entries",
131
291
  );
132
292
 
133
293
  // --- membership management ---
134
- const myWorkspaces = await ctx.call("GET", "/workspaces", { token: alpha.token });
135
- ctx.assert(myWorkspaces.status === 200, `a user can list the workspaces they belong to (got ${myWorkspaces.status})`);
294
+ const myWorkspaces = await ctx.call("GET", "/workspaces", {
295
+ token: alpha.token,
296
+ });
297
+ ctx.assert(
298
+ myWorkspaces.status === 200,
299
+ `a user can list the workspaces they belong to (got ${myWorkspaces.status})`,
300
+ );
136
301
  const ids = (myWorkspaces.body as Workspace[]).map((w) => w.id);
137
- ctx.assert(ids.includes(alpha.workspace.id) && ids.includes(beta.workspace.id), "the list covers every workspace the user is a member of");
302
+ ctx.assert(
303
+ ids.includes(alpha.workspace.id) && ids.includes(beta.workspace.id),
304
+ "the list covers every workspace the user is a member of",
305
+ );
138
306
 
139
- const promote = await ctx.call("PUT", `/workspaces/members/${alphaInBeta.memberId}/roles`, {
140
- token: beta.token,
307
+ const promote = await ctx.call(
308
+ "PUT",
309
+ `/workspaces/members/${alphaInBeta.memberId}/roles`,
310
+ {
311
+ token: beta.token,
312
+ workspaceId: beta.workspace.id,
313
+ body: { roles: ["member", "shared-name"] },
314
+ },
315
+ );
316
+ ctx.assert(
317
+ promote.status === 200,
318
+ `an admin can set a member's roles (got ${promote.status})`,
319
+ );
320
+
321
+ const memberList = await ctx.call("GET", "/workspaces/members", {
322
+ token: alpha.token,
141
323
  workspaceId: beta.workspace.id,
142
- body: { roles: ["member", "shared-name"] },
143
324
  });
144
- ctx.assert(promote.status === 200, `an admin can set a member's roles (got ${promote.status})`);
145
-
146
- const memberList = await ctx.call("GET", "/workspaces/members", { token: alpha.token, workspaceId: beta.workspace.id });
147
- ctx.assert(memberList.status === 200 && memberList.body.length === 2, `any member can list the workspace's members (got ${memberList.status})`);
325
+ ctx.assert(
326
+ memberList.status === 200 && memberList.body.length === 2,
327
+ `any member can list the workspace's members (got ${memberList.status})`,
328
+ );
148
329
 
149
- const removed = await ctx.call("DELETE", `/workspaces/members/${alphaInBeta.memberId}`, { token: beta.token, workspaceId: beta.workspace.id });
150
- ctx.assert(removed.status === 200, `an admin can remove a member (got ${removed.status})`);
330
+ const removed = await ctx.call(
331
+ "DELETE",
332
+ `/workspaces/members/${alphaInBeta.memberId}`,
333
+ { token: beta.token, workspaceId: beta.workspace.id },
334
+ );
335
+ ctx.assert(
336
+ removed.status === 200,
337
+ `an admin can remove a member (got ${removed.status})`,
338
+ );
151
339
 
152
- const afterRemoval = await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: beta.workspace.id });
153
- ctx.assert(afterRemoval.status === 403, `a removed member loses access to the workspace immediately (got ${afterRemoval.status})`);
340
+ const afterRemoval = await ctx.call("GET", "/auth/me", {
341
+ token: alpha.token,
342
+ workspaceId: beta.workspace.id,
343
+ });
344
+ ctx.assert(
345
+ afterRemoval.status === 403,
346
+ `a removed member loses access to the workspace immediately (got ${afterRemoval.status})`,
347
+ );
154
348
 
155
- const stillInAlpha = await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: alpha.workspace.id });
156
- ctx.assert(stillInAlpha.status === 200 && stillInAlpha.body.roles.includes("admin"), "…while their own workspace is untouched — the same login still works");
349
+ const stillInAlpha = await ctx.call("GET", "/auth/me", {
350
+ token: alpha.token,
351
+ workspaceId: alpha.workspace.id,
352
+ });
353
+ ctx.assert(
354
+ stillInAlpha.status === 200 && stillInAlpha.body.roles.includes("admin"),
355
+ "…while their own workspace is untouched — the same login still works",
356
+ );
157
357
 
158
358
  // --- revocation is immediate here: the context is resolved per request, not per token ---
159
- const immediate = await ctx.call("POST", `/auth/admin/users/${beta.userId}/permissions`, {
359
+ const immediate = await ctx.call(
360
+ "POST",
361
+ `/auth/admin/users/${beta.userId}/permissions`,
362
+ {
363
+ token: beta.token,
364
+ workspaceId: beta.workspace.id,
365
+ body: { permission: "probe:immediate" },
366
+ },
367
+ );
368
+ ctx.assert(
369
+ immediate.status === 201 || immediate.status === 200,
370
+ `a direct grant to oneself succeeds (got ${immediate.status})`,
371
+ );
372
+ const seen = await ctx.call("GET", "/auth/me", {
160
373
  token: beta.token,
161
374
  workspaceId: beta.workspace.id,
162
- body: { permission: "probe:immediate" },
163
375
  });
164
- ctx.assert(immediate.status === 201 || immediate.status === 200, `a direct grant to oneself succeeds (got ${immediate.status})`);
165
- const seen = await ctx.call("GET", "/auth/me", { token: beta.token, workspaceId: beta.workspace.id });
166
- ctx.assert(seen.body.permissions.includes("probe:immediate"), "the grant is visible on the very next request, with no re-login");
167
- await ctx.call("POST", `/auth/admin/users/${beta.userId}/permissions/${encodeURIComponent("probe:immediate")}/revoke`, {
376
+ ctx.assert(
377
+ seen.body.permissions.includes("probe:immediate"),
378
+ "the grant is visible on the very next request, with no re-login",
379
+ );
380
+ await ctx.call(
381
+ "POST",
382
+ `/auth/admin/users/${beta.userId}/permissions/${encodeURIComponent("probe:immediate")}/revoke`,
383
+ {
384
+ token: beta.token,
385
+ workspaceId: beta.workspace.id,
386
+ },
387
+ );
388
+ const gone = await ctx.call("GET", "/auth/me", {
168
389
  token: beta.token,
169
390
  workspaceId: beta.workspace.id,
170
391
  });
171
- const gone = await ctx.call("GET", "/auth/me", { token: beta.token, workspaceId: beta.workspace.id });
172
- ctx.assert(!gone.body.permissions.includes("probe:immediate"), "…and the revocation lands on the very next request too, on the same token");
392
+ ctx.assert(
393
+ !gone.body.permissions.includes("probe:immediate"),
394
+ "…and the revocation lands on the very next request too, on the same token",
395
+ );
173
396
 
174
397
  await proveNewWorkspaceIsProvisioned(ctx);
175
398
  await proveWorkspaceHeaderCannotBeSkipped(ctx, alpha);
@@ -189,53 +412,137 @@ export const hooks: VariantHooks = {
189
412
  * So this walks the entire administrative surface of a brand-new workspace, in order, as the
190
413
  * person who created it thirty milliseconds earlier.
191
414
  */
192
- async function proveNewWorkspaceIsProvisioned(ctx: ProofContext): Promise<void> {
415
+ async function proveNewWorkspaceIsProvisioned(
416
+ ctx: ProofContext,
417
+ ): Promise<void> {
193
418
  const creator = await newAdminWithWorkspace(ctx, "fresh");
194
419
  const ws = creator.workspace.id;
195
420
  const as = { token: creator.token, workspaceId: ws };
196
- ctx.assert(creator.workspace.roles.includes("admin"), "the creator of a brand-new workspace holds the admin role in it");
421
+ ctx.assert(
422
+ creator.workspace.roles.includes("admin"),
423
+ "the creator of a brand-new workspace holds the admin role in it",
424
+ );
197
425
 
198
426
  const guestEmail = ctx.uniqueEmail("fresh-guest");
199
427
  const guestTokens = await ctx.signup(guestEmail, "fresh-guest-pw-1");
200
- const guestId = (await ctx.call("GET", "/auth/me", { token: guestTokens.accessToken })).body.sub as string;
428
+ const guestId = (
429
+ await ctx.call("GET", "/auth/me", { token: guestTokens.accessToken })
430
+ ).body.sub as string;
201
431
 
202
- const ok = (res: { status: number }) => res.status === 200 || res.status === 201;
432
+ const ok = (res: { status: number }) =>
433
+ res.status === 200 || res.status === 201;
203
434
 
204
- const added = await ctx.call("POST", "/workspaces/members", { ...as, body: { email: guestEmail } });
205
- ctx.assert(ok(added), `members:manage — the creator can add a member (got ${added.status})`);
435
+ const added = await ctx.call("POST", "/workspaces/members", {
436
+ ...as,
437
+ body: { email: guestEmail },
438
+ });
439
+ ctx.assert(
440
+ ok(added),
441
+ `members:manage — the creator can add a member (got ${added.status})`,
442
+ );
206
443
  const memberId = (added.body as { memberId: string }).memberId;
207
444
 
208
- ctx.assert(ok(await ctx.call("GET", "/auth/admin/users", as)), "users:read — the creator can list the members");
209
- ctx.assert(ok(await ctx.call("GET", "/auth/admin/audit-log", as)), "audit-log:read — the creator can read the audit log");
445
+ ctx.assert(
446
+ ok(await ctx.call("GET", "/auth/admin/users", as)),
447
+ "users:read — the creator can list the members",
448
+ );
449
+ ctx.assert(
450
+ ok(await ctx.call("GET", "/auth/admin/audit-log", as)),
451
+ "audit-log:read — the creator can read the audit log",
452
+ );
210
453
 
211
- const role = await ctx.call("POST", "/auth/admin/roles", { ...as, body: { slug: "fresh-role" } });
212
- ctx.assert(ok(role), `roles:manage — the creator can define a role (got ${role.status})`);
213
- const attach = await ctx.call("POST", `/auth/admin/roles/${(role.body as { id: string }).id}/permissions`, { ...as, body: { permission: "fresh:probe" } });
214
- ctx.assert(ok(attach), `roles:manage — the creator can say what that role carries (got ${attach.status})`);
454
+ const role = await ctx.call("POST", "/auth/admin/roles", {
455
+ ...as,
456
+ body: { slug: "fresh-role" },
457
+ });
458
+ ctx.assert(
459
+ ok(role),
460
+ `roles:manage — the creator can define a role (got ${role.status})`,
461
+ );
462
+ const attach = await ctx.call(
463
+ "POST",
464
+ `/auth/admin/roles/${(role.body as { id: string }).id}/permissions`,
465
+ { ...as, body: { permission: "fresh:probe" } },
466
+ );
467
+ ctx.assert(
468
+ ok(attach),
469
+ `roles:manage — the creator can say what that role carries (got ${attach.status})`,
470
+ );
215
471
 
216
- ctx.assert(ok(await ctx.call("POST", `/auth/admin/users/${guestId}/roles`, { ...as, body: { role: "fresh-role" } })), "roles:assign — assign a role");
217
- ctx.assert(ok(await ctx.call("POST", `/auth/admin/users/${guestId}/roles/fresh-role/revoke`, as)), "roles:assign — revoke a role");
472
+ ctx.assert(
473
+ ok(
474
+ await ctx.call("POST", `/auth/admin/users/${guestId}/roles`, {
475
+ ...as,
476
+ body: { role: "fresh-role" },
477
+ }),
478
+ ),
479
+ "roles:assign — assign a role",
480
+ );
481
+ ctx.assert(
482
+ ok(
483
+ await ctx.call(
484
+ "POST",
485
+ `/auth/admin/users/${guestId}/roles/fresh-role/revoke`,
486
+ as,
487
+ ),
488
+ ),
489
+ "roles:assign — revoke a role",
490
+ );
218
491
 
219
492
  ctx.assert(
220
- ok(await ctx.call("POST", `/auth/admin/users/${guestId}/permissions`, { ...as, body: { permission: "fresh:direct" } })),
493
+ ok(
494
+ await ctx.call("POST", `/auth/admin/users/${guestId}/permissions`, {
495
+ ...as,
496
+ body: { permission: "fresh:direct" },
497
+ }),
498
+ ),
221
499
  "permissions:grant — grant directly",
222
500
  );
223
501
  ctx.assert(
224
- ok(await ctx.call("POST", `/auth/admin/users/${guestId}/permissions/${encodeURIComponent("fresh:direct")}/revoke`, as)),
502
+ ok(
503
+ await ctx.call(
504
+ "POST",
505
+ `/auth/admin/users/${guestId}/permissions/${encodeURIComponent("fresh:direct")}/revoke`,
506
+ as,
507
+ ),
508
+ ),
225
509
  "permissions:grant — revoke a direct grant",
226
510
  );
227
511
 
228
- ctx.assert(ok(await ctx.call("POST", `/auth/admin/users/${guestId}/block`, as)), "users:block — block a member");
229
- ctx.assert(ok(await ctx.call("POST", `/auth/admin/users/${guestId}/unblock`, as)), "users:block — unblock a member");
512
+ ctx.assert(
513
+ ok(await ctx.call("POST", `/auth/admin/users/${guestId}/block`, as)),
514
+ "users:block — block a member",
515
+ );
516
+ ctx.assert(
517
+ ok(await ctx.call("POST", `/auth/admin/users/${guestId}/unblock`, as)),
518
+ "users:block — unblock a member",
519
+ );
230
520
 
231
- ctx.assert(ok(await ctx.call("PUT", `/workspaces/members/${memberId}/roles`, { ...as, body: { roles: ["member"] } })), "roles:assign — set a member's roles");
232
- ctx.assert(ok(await ctx.call("DELETE", `/workspaces/members/${memberId}`, as)), "members:manage — remove a member");
521
+ ctx.assert(
522
+ ok(
523
+ await ctx.call("PUT", `/workspaces/members/${memberId}/roles`, {
524
+ ...as,
525
+ body: { roles: ["member"] },
526
+ }),
527
+ ),
528
+ "roles:assign — set a member's roles",
529
+ );
530
+ ctx.assert(
531
+ ok(await ctx.call("DELETE", `/workspaces/members/${memberId}`, as)),
532
+ "members:manage — remove a member",
533
+ );
233
534
 
234
535
  // The other half of the same property: the roles are provisioned, not inherited. A second
235
536
  // workspace's "admin" is a different row that happens to share a name.
236
537
  const other = await newAdminWithWorkspace(ctx, "fresh-other");
237
- const trespass = await ctx.call("GET", "/auth/admin/users", { token: creator.token, workspaceId: other.workspace.id });
238
- ctx.assert(trespass.status === 403, `being provisioned as admin of one new workspace grants nothing in another (got ${trespass.status})`);
538
+ const trespass = await ctx.call("GET", "/auth/admin/users", {
539
+ token: creator.token,
540
+ workspaceId: other.workspace.id,
541
+ });
542
+ ctx.assert(
543
+ trespass.status === 403,
544
+ `being provisioned as admin of one new workspace grants nothing in another (got ${trespass.status})`,
545
+ );
239
546
  }
240
547
 
241
548
  /**
@@ -247,7 +554,10 @@ async function proveNewWorkspaceIsProvisioned(ctx: ProofContext): Promise<void>
247
554
  * All of them must deny, and deny the same way a workspace you are not a member of does — a
248
555
  * caller must not be able to tell "no such workspace" from "not yours" from "you sent nothing".
249
556
  */
250
- async function proveWorkspaceHeaderCannotBeSkipped(ctx: ProofContext, member: { token: string; workspace: Workspace }): Promise<void> {
557
+ async function proveWorkspaceHeaderCannotBeSkipped(
558
+ ctx: ProofContext,
559
+ member: { token: string; workspace: Workspace },
560
+ ): Promise<void> {
251
561
  const cases: Array<[string, Record<string, string>]> = [
252
562
  ["absent", {}],
253
563
  ["empty", { "x-workspace-id": "" }],
@@ -258,20 +568,39 @@ async function proveWorkspaceHeaderCannotBeSkipped(ctx: ProofContext, member: {
258
568
  ];
259
569
 
260
570
  for (const [label, headers] of cases) {
261
- const admin = await ctx.call("GET", "/auth/admin/users", { token: member.token, headers });
262
- ctx.assert(admin.status === 403, `a permission-gated admin route denies a request whose workspace id is ${label} (got ${admin.status})`);
571
+ const admin = await ctx.call("GET", "/auth/admin/users", {
572
+ token: member.token,
573
+ headers,
574
+ });
575
+ ctx.assert(
576
+ admin.status === 403,
577
+ `a permission-gated admin route denies a request whose workspace id is ${label} (got ${admin.status})`,
578
+ );
263
579
 
264
- const members = await ctx.call("GET", "/workspaces/members", { token: member.token, headers });
265
- ctx.assert(members.status === 403, `a member-gated workspace route denies a request whose workspace id is ${label} (got ${members.status})`);
580
+ const members = await ctx.call("GET", "/workspaces/members", {
581
+ token: member.token,
582
+ headers,
583
+ });
584
+ ctx.assert(
585
+ members.status === 403,
586
+ `a member-gated workspace route denies a request whose workspace id is ${label} (got ${members.status})`,
587
+ );
266
588
  }
267
589
 
268
590
  // Indistinguishability, spelled out: an id that exists but is not yours and an id that exists
269
591
  // nowhere must be the same answer, body included, or the 403 becomes a workspace oracle.
270
592
  const stranger = await newAdminWithWorkspace(ctx, "stranger");
271
- const notYours = await ctx.call("GET", "/auth/admin/users", { token: member.token, workspaceId: stranger.workspace.id });
272
- const notReal = await ctx.call("GET", "/auth/admin/users", { token: member.token, workspaceId: "00000000-0000-0000-0000-000000000000" });
593
+ const notYours = await ctx.call("GET", "/auth/admin/users", {
594
+ token: member.token,
595
+ workspaceId: stranger.workspace.id,
596
+ });
597
+ const notReal = await ctx.call("GET", "/auth/admin/users", {
598
+ token: member.token,
599
+ workspaceId: "00000000-0000-0000-0000-000000000000",
600
+ });
273
601
  ctx.assert(
274
- notYours.status === notReal.status && notYours.body?.message === notReal.body?.message,
602
+ notYours.status === notReal.status &&
603
+ notYours.body?.message === notReal.body?.message,
275
604
  `someone else's workspace and a nonexistent one answer identically (${notYours.status}/${notYours.body?.message} vs ${notReal.status}/${notReal.body?.message})`,
276
605
  );
277
606
 
@@ -279,21 +608,38 @@ async function proveWorkspaceHeaderCannotBeSkipped(ctx: ProofContext, member: {
279
608
  // which workspaces exist. It must answer with an empty authorization context, never with one
280
609
  // borrowed from an arbitrary workspace the user happens to belong to.
281
610
  const meNoHeader = await ctx.call("GET", "/auth/me", { token: member.token });
282
- ctx.assert(meNoHeader.status === 200, `GET /auth/me answers without a workspace header (got ${meNoHeader.status})`);
283
611
  ctx.assert(
284
- Array.isArray(meNoHeader.body?.roles) && meNoHeader.body.roles.length === 0 && meNoHeader.body.permissions.length === 0,
612
+ meNoHeader.status === 200,
613
+ `GET /auth/me answers without a workspace header (got ${meNoHeader.status})`,
614
+ );
615
+ ctx.assert(
616
+ Array.isArray(meNoHeader.body?.roles) &&
617
+ meNoHeader.body.roles.length === 0 &&
618
+ meNoHeader.body.permissions.length === 0,
285
619
  `…with empty roles and permissions rather than some workspace's (got roles=${JSON.stringify(meNoHeader.body?.roles)})`,
286
620
  );
287
- ctx.assert(meNoHeader.body?.sub && meNoHeader.body?.sessionId, "…while still identifying the user, which is what makes it callable before any workspace is chosen");
621
+ ctx.assert(
622
+ meNoHeader.body?.sub && meNoHeader.body?.sessionId,
623
+ "…while still identifying the user, which is what makes it callable before any workspace is chosen",
624
+ );
288
625
 
289
- const meEmptyHeader = await ctx.call("GET", "/auth/me", { token: member.token, headers: { "x-workspace-id": "" } });
626
+ const meEmptyHeader = await ctx.call("GET", "/auth/me", {
627
+ token: member.token,
628
+ headers: { "x-workspace-id": "" },
629
+ });
290
630
  ctx.assert(
291
631
  meEmptyHeader.status === 200 && meEmptyHeader.body.roles.length === 0,
292
632
  `an empty workspace header is treated as naming no workspace, not as naming one (got ${meEmptyHeader.status})`,
293
633
  );
294
634
 
295
- const meBogusHeader = await ctx.call("GET", "/auth/me", { token: member.token, workspaceId: "00000000-0000-0000-0000-000000000000" });
296
- ctx.assert(meBogusHeader.status === 403, `but a workspace id that is named and not yours is refused outright, even here (got ${meBogusHeader.status})`);
635
+ const meBogusHeader = await ctx.call("GET", "/auth/me", {
636
+ token: member.token,
637
+ workspaceId: "00000000-0000-0000-0000-000000000000",
638
+ });
639
+ ctx.assert(
640
+ meBogusHeader.status === 403,
641
+ `but a workspace id that is named and not yours is refused outright, even here (got ${meBogusHeader.status})`,
642
+ );
297
643
  }
298
644
 
299
645
  /**
@@ -308,9 +654,16 @@ async function proveTheCacheIsScopedAndReal(
308
654
  beta: { token: string; userId: string; workspace: Workspace },
309
655
  ): Promise<void> {
310
656
  const N = 8;
311
- await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: alpha.workspace.id }); // warm
657
+ await ctx.call("GET", "/auth/me", {
658
+ token: alpha.token,
659
+ workspaceId: alpha.workspace.id,
660
+ }); // warm
312
661
  const before = { ...permissionCacheStore.stats };
313
- for (let i = 0; i < N; i += 1) await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: alpha.workspace.id });
662
+ for (let i = 0; i < N; i += 1)
663
+ await ctx.call("GET", "/auth/me", {
664
+ token: alpha.token,
665
+ workspaceId: alpha.workspace.id,
666
+ });
314
667
  const after = permissionCacheStore.stats;
315
668
  ctx.assert(
316
669
  after.misses === before.misses && after.hits === before.hits + N,
@@ -319,21 +672,45 @@ async function proveTheCacheIsScopedAndReal(
319
672
 
320
673
  // A grant inside beta must invalidate beta's entry and leave alpha's alone: the cache subject is
321
674
  // the (user, workspace) pair, so one workspace's churn cannot cost another workspace its cache.
322
- await ctx.call("POST", `/auth/admin/users/${beta.userId}/permissions`, { token: beta.token, workspaceId: beta.workspace.id, body: { permission: "probe:cache" } });
675
+ await ctx.call("POST", `/auth/admin/users/${beta.userId}/permissions`, {
676
+ token: beta.token,
677
+ workspaceId: beta.workspace.id,
678
+ body: { permission: "probe:cache" },
679
+ });
323
680
  const beforeAlpha = { ...permissionCacheStore.stats };
324
- const alphaAfter = await ctx.call("GET", "/auth/me", { token: alpha.token, workspaceId: alpha.workspace.id });
681
+ const alphaAfter = await ctx.call("GET", "/auth/me", {
682
+ token: alpha.token,
683
+ workspaceId: alpha.workspace.id,
684
+ });
325
685
  ctx.assert(
326
- permissionCacheStore.stats.misses === beforeAlpha.misses && alphaAfter.status === 200,
686
+ permissionCacheStore.stats.misses === beforeAlpha.misses &&
687
+ alphaAfter.status === 200,
327
688
  "a grant made in workspace B does not invalidate the caller's cached membership of workspace A",
328
689
  );
329
- const betaAfter = await ctx.call("GET", "/auth/me", { token: beta.token, workspaceId: beta.workspace.id });
330
- ctx.assert((betaAfter.body?.permissions ?? []).includes("probe:cache"), "…while workspace B sees the grant on its very next request");
331
- await ctx.call("POST", `/auth/admin/users/${beta.userId}/permissions/${encodeURIComponent("probe:cache")}/revoke`, {
690
+ const betaAfter = await ctx.call("GET", "/auth/me", {
691
+ token: beta.token,
692
+ workspaceId: beta.workspace.id,
693
+ });
694
+ ctx.assert(
695
+ (betaAfter.body?.permissions ?? []).includes("probe:cache"),
696
+ "…while workspace B sees the grant on its very next request",
697
+ );
698
+ await ctx.call(
699
+ "POST",
700
+ `/auth/admin/users/${beta.userId}/permissions/${encodeURIComponent("probe:cache")}/revoke`,
701
+ {
702
+ token: beta.token,
703
+ workspaceId: beta.workspace.id,
704
+ },
705
+ );
706
+ const betaRevoked = await ctx.call("GET", "/auth/me", {
332
707
  token: beta.token,
333
708
  workspaceId: beta.workspace.id,
334
709
  });
335
- const betaRevoked = await ctx.call("GET", "/auth/me", { token: beta.token, workspaceId: beta.workspace.id });
336
- ctx.assert(!(betaRevoked.body?.permissions ?? []).includes("probe:cache"), "…and loses it again on the request after the revoke");
710
+ ctx.assert(
711
+ !(betaRevoked.body?.permissions ?? []).includes("probe:cache"),
712
+ "…and loses it again on the request after the revoke",
713
+ );
337
714
  }
338
715
 
339
716
  /**
@@ -342,17 +719,44 @@ async function proveTheCacheIsScopedAndReal(
342
719
  * A `psql` session would look exactly like this, bump included: an out-of-band writer is outside
343
720
  * the invalidation protocol and says so by bumping the policy counter (see permission-cache.ts).
344
721
  */
345
- async function proveARawDatabaseEditChangesEnforcement(ctx: ProofContext, beta: { token: string; workspace: Workspace }): Promise<void> {
346
- const opened = await ctx.call("GET", "/auth/admin/audit-log", { token: beta.token, workspaceId: beta.workspace.id });
347
- ctx.assert(opened.status === 200, `the workspace admin can read the audit log to begin with (got ${opened.status})`);
722
+ async function proveARawDatabaseEditChangesEnforcement(
723
+ ctx: ProofContext,
724
+ beta: { token: string; workspace: Workspace },
725
+ ): Promise<void> {
726
+ const opened = await ctx.call("GET", "/auth/admin/audit-log", {
727
+ token: beta.token,
728
+ workspaceId: beta.workspace.id,
729
+ });
730
+ ctx.assert(
731
+ opened.status === 200,
732
+ `the workspace admin can read the audit log to begin with (got ${opened.status})`,
733
+ );
348
734
 
349
- await prisma.permission.update({ where: { slug: "audit-log:read" }, data: { isActive: false } });
735
+ await prisma.permission.update({
736
+ where: { slug: "audit-log:read" },
737
+ data: { isActive: false },
738
+ });
350
739
  await permissionCacheStore.bump(POLICY_VERSION_KEY);
351
- const denied = await ctx.call("GET", "/auth/admin/audit-log", { token: beta.token, workspaceId: beta.workspace.id });
352
- ctx.assert(denied.status === 403, `a permission deactivated by a raw database write stops opening its route (got ${denied.status})`);
740
+ const denied = await ctx.call("GET", "/auth/admin/audit-log", {
741
+ token: beta.token,
742
+ workspaceId: beta.workspace.id,
743
+ });
744
+ ctx.assert(
745
+ denied.status === 403,
746
+ `a permission deactivated by a raw database write stops opening its route (got ${denied.status})`,
747
+ );
353
748
 
354
- await prisma.permission.update({ where: { slug: "audit-log:read" }, data: { isActive: true } });
749
+ await prisma.permission.update({
750
+ where: { slug: "audit-log:read" },
751
+ data: { isActive: true },
752
+ });
355
753
  await permissionCacheStore.bump(POLICY_VERSION_KEY);
356
- const restored = await ctx.call("GET", "/auth/admin/audit-log", { token: beta.token, workspaceId: beta.workspace.id });
357
- ctx.assert(restored.status === 200, `…and reactivating it in the database opens the route again (got ${restored.status})`);
754
+ const restored = await ctx.call("GET", "/auth/admin/audit-log", {
755
+ token: beta.token,
756
+ workspaceId: beta.workspace.id,
757
+ });
758
+ ctx.assert(
759
+ restored.status === 200,
760
+ `…and reactivating it in the database opens the route again (got ${restored.status})`,
761
+ );
358
762
  }