History

Fix SSR crash from the admin nav and add org API key revoke

The admin nav hook read orgMembersAtom, which is wrapped in refreshOnWindowFocus — a browser-only atom whose signal touches window at read time. Evaluating it during SSR threw ReferenceError synchronously in render, above the routed outlet, so the whole authenticated document fell back to client rendering and the landing page was blank on first paint. Give the atom an explicit server value so its read never runs on the server; the role reads as unknown during SSR and resolves client-side, and the nav still fails closed. Org API keys could be minted and listed but never revoked. Add DELETE /account/org-api-keys/:apiKeyId with the same admin gate as the mint, resolving ownership against the org's own keys so a member's personal key or a sibling org's key cannot be destroyed through it. Self-host and cloudflare refuse it as they already refuse the mint.

Rhys Sullivan committed 3 months ago

Commit 445a4cd1 parent 8bca26215 files · +454 −8Browse files

Unified · Split

15 files
  1. apps/cloud/src/account/org-api-key-revoke.node.test.ts +192 −0
  2. apps/cloud/src/account/workos-account-service.ts +20 −0
  3. apps/cloud/src/api/protected-api-key-auth.node.test.ts +1 −0
  4. apps/cloud/src/api/protected-jwt-auth.node.test.ts +1 −0
  5. apps/cloud/src/auth/api-keys.ts +39 −0
  6. apps/cloud/src/auth/org-api-key-auth.node.test.ts +1 −0
  7. apps/cloud/src/auth/org-api-key-mint.node.test.ts +139 −3
  8. apps/cloud/src/auth/org-selector-auth.node.test.ts +1 −0
  9. apps/host-cloudflare/src/account/account-provider.ts +1 −0
  10. apps/host-selfhost/src/account/better-auth-account-provider.ts +11 −0
  11. packages/core/api/src/account/api.ts +12 −0
  12. packages/core/api/src/account/handlers.ts +6 −0
  13. packages/core/api/src/account/service.ts +4 −0
  14. packages/react/src/api/account-atoms.tsx +19 −5
  15. packages/react/src/multiplayer/use-admin-nav.tsx +7 −0
apps/cloud/src/account/org-api-key-revoke.node.test.ts+192−0
@@ -0,0 +1,192 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { AccountProvider } from "@executor-js/api/server";
5import { AccountError, AccountForbidden } from "@executor-js/api";
6
7import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
8import { UserStoreService } from "../auth/context";
9import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
10import { AutumnService } from "../extensions/billing/service";
11import { AccountCaller, workosAccountProvider } from "./workos-account-service";
12
13// ---------------------------------------------------------------------------
14// `DELETE /account/org-api-keys/:apiKeyId` at the PROVIDER boundary.
15//
16// An org key reads every user and connection in the tenant through `/admin/*`,
17// so it is the most privileged credential the product issues. Two properties
18// have to hold together, and only this seam sees both:
19//
20// 1. Revoke is gated on org ADMIN membership — the same gate as the mint. A
21// plain member calling it gets 403 and no delete goes out.
22// 2. Revoke resolves ownership against the ORG's keys. `revokeApiKey` (the
23// personal path) checks the caller's OWN key list, so it can never revoke
24// an org key; conversely this path must never destroy a member's personal
25// key. The scope-blind `deleteApiKey` would do exactly that if handed a
26// user key id, so a wrong id has to fail cleanly instead.
27//
28// Ownership filtering itself is pinned per-shape in
29// `auth/org-api-key-mint.node.test.ts`; here it is asserted end-to-end through
30// the gate, on the provider the HTTP handler actually calls.
31// ---------------------------------------------------------------------------
32
33const ORG = "org_123";
34const ADMIN = "user_admin";
35const MEMBER = "user_member";
36const ORG_KEY = "key_org_1";
37const USER_KEY = "key_user_1";
38const createdAt = new Date("2026-01-01T00:00:00.000Z");
39
40const session = (accountId: string) => ({
41 accountId,
42 email: `${accountId}@example.test`,
43 name: null,
44 avatarUrl: null,
45 organizationId: ORG,
46 sealedSession: "sealed",
47 refreshedSession: null,
48});
49
50/** Membership roles: only ADMIN carries the `admin` role slug. */
51const stubWorkOS = Layer.succeed(
52 WorkOSClient,
53 new Proxy({} as WorkOSClientService, {
54 get: (_target, prop) => {
55 if (prop === "listUserMemberships") {
56 return (userId: string) =>
57 Effect.succeed({
58 data: [{ userId, organizationId: ORG, status: "active" }],
59 });
60 }
61 if (prop === "getUserOrgMembership") {
62 return (organizationId: string, userId: string) =>
63 Effect.succeed(
64 organizationId === ORG
65 ? {
66 id: `om_${userId}`,
67 userId,
68 organizationId,
69 role: { slug: userId === ADMIN ? "admin" : "member" },
70 }
71 : null,
72 );
73 }
74 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
75 },
76 }),
77);
78
79const stubUsers = Layer.succeed(UserStoreService)({
80 use: (fn) =>
81 Effect.promise(() =>
82 fn({
83 ensureAccount: async (id: string) => ({ id, createdAt }),
84 getAccount: async (id: string) => ({ id, createdAt }),
85 upsertOrganization: async (org: { id: string; name: string }) => ({
86 ...org,
87 slug: org.id,
88 createdAt,
89 }),
90 getOrganization: async (id: string) => ({
91 id,
92 name: `Org ${id}`,
93 slug: id,
94 createdAt,
95 }),
96 getOrganizationBySlug: async (slug: string) => ({
97 id: slug,
98 name: `Org ${slug}`,
99 slug,
100 createdAt,
101 }),
102 deleteOrganizationCascade: async () => {},
103 }),
104 ),
105});
106
107const stubAutumn = Layer.succeed(AutumnService)({
108 use: () => Effect.die("revoke does not touch billing"),
109 checkExecutionBalance: () => Effect.die("revoke does not touch billing"),
110 trackExecution: () => Effect.void,
111});
112
113/**
114 * The provider under test, over an `ApiKeyService` whose org listing holds a
115 * single org-owned key. Revoked ids land in `revoked` so "no delete happened"
116 * is assertable, not merely inferred from the error.
117 */
118const providerWith = (accountId: string) => {
119 const revoked: string[] = [];
120 const stubApiKeys = Layer.succeed(ApiKeyService)({
121 validate: () => Effect.die("revoke test does not validate keys"),
122 listUserKeys: () => Effect.succeed([]),
123 createUserKey: () => Effect.die("revoke test does not create user keys"),
124 revokeUserKey: () => Effect.die("the ORG path must not call the personal revoke"),
125 listOrgKeys: () => Effect.die("revoke test does not list org keys"),
126 createOrgKey: () => Effect.die("revoke test does not create org keys"),
127 revokeOrgKey: ({ keyId }) =>
128 keyId === ORG_KEY
129 ? Effect.sync(() => {
130 revoked.push(keyId);
131 })
132 : Effect.fail(new OrgApiKeyNotFound({ keyId })),
133 });
134
135 const provider = AccountProvider.asEffect().pipe(
136 Effect.provide(
137 workosAccountProvider.pipe(
138 Layer.provide(
139 Layer.mergeAll(
140 stubWorkOS,
141 stubUsers,
142 stubApiKeys,
143 stubAutumn,
144 Layer.succeed(AccountCaller)({ session: session(accountId) }),
145 ),
146 ),
147 ),
148 ),
149 );
150
151 return { provider, revoked };
152};
153
154describe("revokeOrgApiKey · provider boundary", () => {
155 it.effect("an org admin revokes an org-owned key", () =>
156 Effect.gen(function* () {
157 const { provider, revoked } = providerWith(ADMIN);
158 const account = yield* provider;
159
160 const result = yield* account.revokeOrgApiKey({}, ORG_KEY);
161
162 expect(result).toEqual({ success: true });
163 expect(revoked, "the revoke reached the key service").toEqual([ORG_KEY]);
164 }),
165 );
166
167 it.effect("a plain member is forbidden, and nothing is revoked", () =>
168 Effect.gen(function* () {
169 const { provider, revoked } = providerWith(MEMBER);
170 const account = yield* provider;
171
172 const error = yield* Effect.flip(account.revokeOrgApiKey({}, ORG_KEY));
173
174 expect(error, "same admin gate as the mint").toBeInstanceOf(AccountForbidden);
175 expect(revoked, "the gate runs BEFORE the key service is touched").toEqual([]);
176 }),
177 );
178
179 it.effect("a user-scoped key id fails cleanly through this path", () =>
180 Effect.gen(function* () {
181 // Not found — not a 500, and emphatically not a silent success that would
182 // report a member's live personal credential as destroyed.
183 const { provider, revoked } = providerWith(ADMIN);
184 const account = yield* provider;
185
186 const error = yield* Effect.flip(account.revokeOrgApiKey({}, USER_KEY));
187
188 expect(error).toBeInstanceOf(AccountError);
189 expect(revoked).toEqual([]);
190 }),
191 );
192});
apps/cloud/src/account/workos-account-service.ts+20−0
@@ -272,6 +272,26 @@ export const workosAccountProvider: Layer.Layer< · 271 unmodified lines
272 .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));272 .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
273 }),273 }),
274 274
275 // Same admin gate as the mint. Note this deliberately does NOT reuse
276 // `revokeApiKey`: that path resolves ownership against the caller's
277 // personal keys, so an org key id is never in its owned set. Ownership
278 // here is resolved against the ORG's keys inside `revokeOrgKey`, which is
279 // what keeps a user-scoped (or foreign-org) key id from being destroyed
280 // through the org route — it surfaces as a 404-shaped AccountError, not a
281 // silent success and not a 500.
282 revokeOrgApiKey: (headers, apiKeyId) =>
283 Effect.gen(function* () {
284 const { session, org } = yield* requireOrganization(headers);
285 yield* requireAdmin(session.accountId, org.id);
286 yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe(
287 Effect.catchTag("ApiKeyManagementError", toAccountError),
288 Effect.catchTag("OrgApiKeyNotFound", () =>
289 Effect.fail(new AccountError({ message: "Organization API key not found" })),
290 ),
291 );
292 return { success: true };
293 }),
294
275 listMembers: (headers) =>295 listMembers: (headers) =>
276 Effect.gen(function* () {296 Effect.gen(function* () {
277 const { session, org } = yield* requireOrganization(headers);297 const { session, org } = yield* requireOrganization(headers);
apps/cloud/src/api/protected-api-key-auth.node.test.ts+1−0
@@ -25,6 +25,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 24 unmodified lines
25 revokeUserKey: () => Effect.void,25 revokeUserKey: () => Effect.void,
26 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),26 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
27 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),27 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
28 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
28});29});
29 30
30const stubWorkOS = Layer.succeed(31const stubWorkOS = Layer.succeed(
apps/cloud/src/api/protected-jwt-auth.node.test.ts+1−0
@@ -42,6 +42,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 41 unmodified lines
42 revokeUserKey: () => Effect.void,42 revokeUserKey: () => Effect.void,
43 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),43 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
44 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),44 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
45 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
45});46});
46 47
47const stubWorkOS = Layer.succeed(48const stubWorkOS = Layer.succeed(
apps/cloud/src/auth/api-keys.ts+39−0
@@ -42,6 +42,16 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 41 unmodified lines
42 readonly cause: unknown;42 readonly cause: unknown;
43}> {}43}> {}
44 44
45/**
46 * The requested key id is not an org-owned key of this organization — it does
47 * not exist, belongs to a member rather than the org, or belongs to a different
48 * org in the same WorkOS workspace. Distinct from `ApiKeyManagementError` (an
49 * upstream failure) so callers can answer "not found" without a 500.
50 */
51export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{
52 readonly keyId: string;
53}> {}
54
45// WorkOS keys are owned by either a user or an organization. The user shape55// WorkOS keys are owned by either a user or an organization. The user shape
46// carries the member id in `id` and the org in `organization_id`; the org shape56// carries the member id in `id` and the org in `organization_id`; the org shape
47// carries the ORG in `id` and no member at all. Both decode here — the57// carries the ORG in `id` and no member at all. Both decode here — the
@@ -295,6 +305,16 @@ export class ApiKeyService extends Context.Service< · 247 unmodified lines
295 readonly organizationId: string;305 readonly organizationId: string;
296 readonly name: string;306 readonly name: string;
297 }) => Effect.Effect<CreatedApiKey, ApiKeyManagementError>;307 }) => Effect.Effect<CreatedApiKey, ApiKeyManagementError>;
308 /**
309 * Revoke an org-owned key. Ownership is resolved against the ORG's own key
310 * listing — never the caller's personal keys — so a member's user key id
311 * (or another org's key id, since the WorkOS workspace key is
312 * workspace-wide) cannot be destroyed through this path.
313 */
314 readonly revokeOrgKey: (input: {
315 readonly organizationId: string;
316 readonly keyId: string;
317 }) => Effect.Effect<void, ApiKeyManagementError | OrgApiKeyNotFound>;
298 }318 }
299>()("@executor-js/cloud/ApiKeyService") {319>()("@executor-js/cloud/ApiKeyService") {
300 static WorkOS = Layer.effect(this)(320 static WorkOS = Layer.effect(this)(
@@ -340,6 +360,25 @@ export class ApiKeyService extends Context.Service< · 39 unmodified lines
340 : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" }));360 : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" }));
341 }),361 }),
342 ),362 ),
363 // Ownership is checked against the ORG's decoded key listing before the
364 // delete goes out. `deleteApiKey` is scope-blind — handed any key id it
365 // would happily destroy a member's personal credential, or a key
366 // belonging to a sibling org in the same workspace. The listing already
367 // drops both of those (`orgListFromResponse` filters on owner type AND
368 // organization), so filtering through it is the whole authorization.
369 revokeOrgKey: ({ organizationId, keyId }) =>
370 Effect.gen(function* () {
371 const response = yield* workos
372 .listOrgApiKeys(organizationId)
373 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
374 const owned = orgListFromResponse(response, organizationId);
375 if (!owned.some((key) => key.id === keyId)) {
376 return yield* new OrgApiKeyNotFound({ keyId });
377 }
378 yield* workos
379 .deleteApiKey(keyId)
380 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
381 }),
343 };382 };
344 }),383 }),
345 );384 );
apps/cloud/src/auth/org-api-key-auth.node.test.ts+1−0
@@ -38,6 +38,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 37 unmodified lines
38 revokeUserKey: () => Effect.void,38 revokeUserKey: () => Effect.void,
39 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),39 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
40 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),40 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
41 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
41});42});
42 43
43const stubWorkOS = Layer.succeed(44const stubWorkOS = Layer.succeed(
apps/cloud/src/auth/org-api-key-mint.node.test.ts+139−3
@@ -1,13 +1,13 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";2import { Effect, Layer } from "effect";
3 3
4import { ApiKeyService } from "./api-keys";4import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys";
5import { ApiKeyManagementError } from "./errors";5import { ApiKeyManagementError } from "./errors";
6import { WorkOSClient, type WorkOSClientService } from "./workos";6import { WorkOSClient, type WorkOSClientService } from "./workos";
7 7
8// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
9// Minting and listing ORGANIZATION-owned api keys — the privileged credential9// Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged
10// that resolves to the read-only platform view (`/admin/*`).10// credential that resolves to the read-only platform view (`/admin/*`).
11//11//
12// The decode boundary is what these pin. WorkOS returns an org-owned key with12// The decode boundary is what these pin. WorkOS returns an org-owned key with
13// the ORG in `owner.id` and no member at all, which is a different shape from13// the ORG in `owner.id` and no member at all, which is a different shape from
@@ -178,3 +178,139 @@ describe("organization API keys", () => { · 164 unmodified lines
178 }),178 }),
179 );179 );
180});180});
181
182// ---------------------------------------------------------------------------
183// Revoking an org key. The mint is only half a credential lifecycle: an org key
184// reads every user and connection in the tenant, so it has to be destroyable.
185//
186// Revoke cannot go through `revokeUserKey`/`revokeApiKey` — that path resolves
187// ownership against the CALLER's personal keys, so an org key id is never in
188// its owned set. It also cannot just call `deleteApiKey` directly: that route is
189// scope-blind and would happily destroy a member's personal key, or a key
190// belonging to a sibling org in the same WorkOS workspace. So ownership is
191// resolved against the ORG's decoded listing first, and that filter IS the
192// authorization. These pin that filter.
193// ---------------------------------------------------------------------------
194
195describe("revoking an organization API key", () => {
196 const listing = (...keys: ReadonlyArray<unknown>) => ({
197 object: "list",
198 data: keys,
199 listMetadata: { before: null, after: null },
200 });
201
202 it.effect("revokes a key the organization owns", () =>
203 Effect.gen(function* () {
204 const deleted: string[] = [];
205 const apiKeys = yield* serviceWith({
206 listOrgApiKeys: (organizationId: string) =>
207 Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))),
208 deleteApiKey: (id: string) =>
209 Effect.sync(() => {
210 deleted.push(id);
211 }),
212 });
213
214 yield* apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" });
215
216 expect(deleted, "the delete reaches WorkOS exactly once, for that key").toEqual([
217 "key_org_1",
218 ]);
219 }),
220 );
221
222 it.effect("refuses a USER-owned key id, without deleting anything", () =>
223 Effect.gen(function* () {
224 // The regression this guards: a member's personal credential must not be
225 // destroyable through the org route just because an admin can call it.
226 const deleted: string[] = [];
227 const apiKeys = yield* serviceWith({
228 listOrgApiKeys: () =>
229 Effect.succeed(
230 listing(orgKeyResponse(ORG, "key_org_1", "acme-backend"), {
231 object: "api_key",
232 id: "key_user",
233 owner: { type: "user", id: "user_1", organization_id: ORG },
234 name: "personal",
235 obfuscatedValue: "sk_user…c3d4",
236 lastUsedAt: null,
237 createdAt: "2026-01-01T00:00:00.000Z",
238 updatedAt: "2026-01-01T00:00:00.000Z",
239 }),
240 ),
241 deleteApiKey: (id: string) =>
242 Effect.sync(() => {
243 deleted.push(id);
244 }),
245 });
246
247 const error = yield* Effect.flip(
248 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_user" }),
249 );
250
251 expect(error).toBeInstanceOf(OrgApiKeyNotFound);
252 expect(deleted, "no delete is issued for a key the org does not own").toEqual([]);
253 }),
254 );
255
256 it.effect("refuses a key belonging to ANOTHER organization", () =>
257 Effect.gen(function* () {
258 // The WorkOS workspace key is workspace-wide, so an admin of one org must
259 // not be able to revoke a sibling org's credential by id.
260 const deleted: string[] = [];
261 const apiKeys = yield* serviceWith({
262 listOrgApiKeys: () =>
263 Effect.succeed(
264 listing(
265 orgKeyResponse(ORG, "key_mine", "mine"),
266 orgKeyResponse("org_other", "key_theirs", "theirs"),
267 ),
268 ),
269 deleteApiKey: (id: string) =>
270 Effect.sync(() => {
271 deleted.push(id);
272 }),
273 });
274
275 const error = yield* Effect.flip(
276 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_theirs" }),
277 );
278
279 expect(error).toBeInstanceOf(OrgApiKeyNotFound);
280 expect(deleted).toEqual([]);
281 }),
282 );
283
284 it.effect("refuses an unknown key id", () =>
285 Effect.gen(function* () {
286 const apiKeys = yield* serviceWith({
287 listOrgApiKeys: () => Effect.succeed(listing()),
288 deleteApiKey: () => Effect.die("nothing should be deleted"),
289 });
290
291 const error = yield* Effect.flip(
292 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_nope" }),
293 );
294
295 expect(error).toBeInstanceOf(OrgApiKeyNotFound);
296 }),
297 );
298
299 it.effect("surfaces an upstream delete failure as a management error", () =>
300 Effect.gen(function* () {
301 // A failed delete must not read as a successful revoke — the console
302 // would tell an admin a live credential is gone.
303 const apiKeys = yield* serviceWith({
304 listOrgApiKeys: (organizationId: string) =>
305 Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))),
306 deleteApiKey: () => Effect.fail("workos_unavailable"),
307 });
308
309 const error = yield* Effect.flip(
310 apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }),
311 );
312
313 expect(error).toBeInstanceOf(ApiKeyManagementError);
314 }),
315 );
316});
apps/cloud/src/auth/org-selector-auth.node.test.ts+1−0
@@ -31,6 +31,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 30 unmodified lines
31 revokeUserKey: () => Effect.void,31 revokeUserKey: () => Effect.void,
32 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),32 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
33 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),33 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
34 revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
34});35});
35 36
36const stubWorkOS = Layer.succeed(37const stubWorkOS = Layer.succeed(
apps/host-cloudflare/src/account/account-provider.ts+1−0
@@ -65,6 +65,7 @@ export const cloudflareAccountProvider = ( · 64 unmodified lines
65 // credential store of their own to mint one from.65 // credential store of their own to mint one from.
66 listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }),66 listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }),
67 createOrgApiKey: () => forbiddenWrite,67 createOrgApiKey: () => forbiddenWrite,
68 revokeOrgApiKey: () => forbiddenWrite,
68 listMembers: () => Effect.succeed({ members: [] }),69 listMembers: () => Effect.succeed({ members: [] }),
69 listRoles: () => Effect.succeed({ roles: [] }),70 listRoles: () => Effect.succeed({ roles: [] }),
70 inviteMember: () => forbiddenWrite,71 inviteMember: () => forbiddenWrite,
apps/host-selfhost/src/account/better-auth-account-provider.ts+11−0
@@ -122,6 +122,17 @@ export const betterAuthAccountProvider: Layer.Layer<AccountProvider, never, Bett · 121 unmodified lines
122 }),122 }),
123 ),123 ),
124 124
125 // Nothing to revoke: `listOrgApiKeys` is empty and `createOrgApiKey`
126 // refuses, so any id reaching here names a key this instance never
127 // issued. Refusing (rather than succeeding vacuously) keeps the console
128 // from reporting a revoke that did not happen.
129 revokeOrgApiKey: () =>
130 Effect.fail(
131 new AccountError({
132 message: "Organization API keys are not available on self-hosted instances",
133 }),
134 ),
135
125 listMembers: (headers) =>136 listMembers: (headers) =>
126 Effect.gen(function* () {137 Effect.gen(function* () {
127 const resolved = yield* getSession(headers);138 const resolved = yield* getSession(headers);
packages/core/api/src/account/api.ts+12−0
@@ -219,6 +219,18 @@ export const AccountApi = HttpApiGroup.make("account") · 218 unmodified lines
219 error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization],219 error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization],
220 }),220 }),
221 )221 )
222 // Revoke is its own route rather than a reuse of `revokeApiKey`: that one
223 // resolves ownership against the caller's PERSONAL keys, so an org key id is
224 // never in its owned set and revoking one there always fails. An org key is
225 // the most privileged credential in the product (it reads the tenant-wide
226 // `/admin/*` plane), so it must be revocable — admin-gated, like the mint.
227 .add(
228 HttpApiEndpoint.delete("revokeOrgApiKey", "/account/org-api-keys/:apiKeyId", {
229 params: ApiKeyParams,
230 success: SuccessResponse,
231 error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization],
232 }),
233 )
222 .add(234 .add(
223 HttpApiEndpoint.get("listMembers", "/account/members", {235 HttpApiEndpoint.get("listMembers", "/account/members", {
224 success: OrgMembersResponse,236 success: OrgMembersResponse,
packages/core/api/src/account/handlers.ts+6−0
@@ -56,6 +56,12 @@ export const AccountHandlers = HttpApiBuilder.group(AccountHttpApi, "account", ( · 55 unmodified lines
56 return yield* (yield* AccountProvider).createOrgApiKey(headers, payload.name);56 return yield* (yield* AccountProvider).createOrgApiKey(headers, payload.name);
57 }),57 }),
58 )58 )
59 .handle("revokeOrgApiKey", ({ params }) =>
60 Effect.gen(function* () {
61 const headers = yield* requestHeaders;
62 return yield* (yield* AccountProvider).revokeOrgApiKey(headers, params.apiKeyId);
63 }),
64 )
59 .handle("listMembers", () =>65 .handle("listMembers", () =>
60 Effect.gen(function* () {66 Effect.gen(function* () {
61 const headers = yield* requestHeaders;67 const headers = yield* requestHeaders;
packages/core/api/src/account/service.ts+4−0
@@ -57,6 +57,10 @@ export interface AccountProviderShape { · 56 unmodified lines
57 headers: AccountHeaders,57 headers: AccountHeaders,
58 name: string,58 name: string,
59 ) => OrgScoped<CreatedApiKey, AccountForbidden>;59 ) => OrgScoped<CreatedApiKey, AccountForbidden>;
60 readonly revokeOrgApiKey: (
61 headers: AccountHeaders,
62 apiKeyId: string,
63 ) => OrgScoped<Success, AccountForbidden>;
60 readonly listMembers: (headers: AccountHeaders) => OrgScoped<Members>;64 readonly listMembers: (headers: AccountHeaders) => OrgScoped<Members>;
61 readonly listRoles: (headers: AccountHeaders) => OrgScoped<Roles>;65 readonly listRoles: (headers: AccountHeaders) => OrgScoped<Roles>;
62 readonly inviteMember: (66 readonly inviteMember: (
packages/react/src/api/account-atoms.tsx+19−5
@@ -28,11 +28,25 @@ export const revokeApiKey = AccountApiClient.mutation("account", "revokeApiKey") · 27 unmodified lines
28 28
29// ── Organization members ─────────────────────────────────────────────────────29// ── Organization members ─────────────────────────────────────────────────────
30 30
31export const orgMembersAtom = Atom.refreshOnWindowFocus(31// `refreshOnWindowFocus` is BROWSER-ONLY: its signal atom subscribes to
32 AccountApiClient.query("account", "listMembers", {32// `window`/`document.visibilityState` the first time the atom is read, so
33 timeToLive: "30 seconds",33// evaluating it on a server throws `ReferenceError: window is not defined` —
34 reactivityKeys: [ReactivityKey.orgMembers],34// synchronously, in render, where no Suspense boundary can absorb it.
35 }),35//
36// `withServerValueInitial` gives the atom an explicit server snapshot
37// (`AsyncResult.initial(true)`) that `useAtomValue`'s `getServerSnapshot` reads
38// INSTEAD of evaluating the atom, so the read function never runs server-side.
39// Every reader already handles the initial state (it is the ordinary loading
40// state), and the client refetches on mount. Without this, any SSR'd tree that
41// reads the member list — cloud's shell does, to decide the admin nav — fails
42// the whole document render.
43export const orgMembersAtom = Atom.withServerValueInitial(
44 Atom.refreshOnWindowFocus(
45 AccountApiClient.query("account", "listMembers", {
46 timeToLive: "30 seconds",
47 reactivityKeys: [ReactivityKey.orgMembers],
48 }),
49 ),
36);50);
37 51
38export const orgRolesAtom = AccountApiClient.query("account", "listRoles", {52export const orgRolesAtom = AccountApiClient.query("account", "listRoles", {
packages/react/src/multiplayer/use-admin-nav.tsx+7−0
@@ -19,6 +19,13 @@ import type { ShellNavItem } from "./shell"; · 18 unmodified lines
19// matters. Failing closed here is deliberate: while the member list is loading19// matters. Failing closed here is deliberate: while the member list is loading
20// or unavailable, showing nothing is better than flashing a section that will20// or unavailable, showing nothing is better than flashing a section that will
21// refuse.21// refuse.
22//
23// That fail-closed default is also what makes this hook SSR-safe. `orgMembersAtom`
24// carries an explicit server value (see `api/account-atoms`), so on the server it
25// reads as initial without evaluating — the role is simply UNKNOWN during SSR,
26// which lands on `false` here and resolves client-side once the list arrives.
27// The hook must never suspend or throw: it runs above the routed outlet, so
28// anything it raises takes down the whole server-rendered document.
22// ---------------------------------------------------------------------------29// ---------------------------------------------------------------------------
23 30
24/** Whether the current member reads as an admin/owner of this tenant. */31/** Whether the current member reads as an admin/owner of this tenant. */