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

Showing the changes to packages/core/api/src (3 of 15 files). Show all

Unified · Split

3 files
  1. packages/core/api/src/account/api.ts +12 −0
  2. packages/core/api/src/account/handlers.ts +6 −0
  3. packages/core/api/src/account/service.ts +4 −0
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: (