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