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