Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/cloud/src/routes/app/api-keys.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { ApiKeysPage } from "@executor-js/react/pages/api-keys";
import { ApiKeysPage, OrgApiKeysSection } from "@executor-js/react/pages/api-keys";

// Cloud renders the SHARED API-keys page over the provider-neutral
// `/account/api-keys` surface — identical UI to self-host.
// `/account/api-keys` surface — identical UI to self-host, plus the
// cloud-only Organization keys section (self-host's provider refuses
// org keys; its admin plane is session-gated instead).
export const Route = createFileRoute("/{-$orgSlug}/api-keys")({
component: ApiKeysPage,
component: CloudApiKeysPage,
});

function CloudApiKeysPage() {
return <ApiKeysPage orgKeysSection={<OrgApiKeysSection />} />;
}
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

141 changes: 141 additions & 0 deletions e2e/cloud/org-api-keys-console.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Cloud-only: the Organization keys SECTION of the API keys page — the console
// surface over `/api/account/org-api-keys`, minting the machine credential for
// the tenant-wide admin plane.
//
// Two members are built through the real flows. The guarantees pinned here:
//
// 1. an ADMIN sees the section, mints an org key through the dialog, gets the
// one-time reveal, and the minted value ACTUALLY authenticates the admin
// API (the whole point of the credential);
// 2. the listing shows the key afterward and revoke asks for confirmation
// before killing it — after which the value stops authenticating;
// 3. a PLAIN MEMBER never sees the section at all.
//
// Runs against emulate >= 0.13.9, whose WorkOS emulator serves the org-key
// routes (list/mint via /organizations/:id/api_keys, org-owner validation) —
// the gap that previously kept this scenario impossible.
import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { scenario } from "../src/scenario";
import { Browser, Target } from "../src/services";
import { forBrowser, joinOrg } from "./support/session";

declare global {
interface Window {
__e2eCopied?: Array<string>;
}
}

scenario(
"Admin · organization keys are minted in the console and authenticate the admin API",
{ timeout: 180_000 },
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;

const admin = yield* target.newIdentity();
const invitee = yield* target.newIdentity({ org: false });
const member = yield* joinOrg(target, admin, invitee);

let mintedValue = "";

// ── The admin's view: mint, verify, revoke ──────────────────────────────
yield* browser.session(forBrowser(admin), async ({ page, step }) => {
let slug = "";

await step("Land in the workspace and open the API keys page", async () => {
await page.goto("/", { waitUntil: "networkidle" });
await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
timeout: 30_000,
});
slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";
await page.getByRole("link", { name: "API keys" }).click();
await page.waitForURL((url) => url.pathname === `/${slug}/api-keys`, { timeout: 30_000 });
});

await step("The admin is offered the Organization keys section", async () => {
await page
.getByRole("heading", { name: "Organization keys", exact: true })
.waitFor({ state: "visible", timeout: 30_000 });
});

await step("Mint an org key through the dialog and capture the reveal", async () => {
await page.getByRole("button", { name: "New org key" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel("Name").fill("e2e backend reader");
await dialog.getByRole("button", { name: "Create key" }).click();

// The one-time secret panel renders once the key exists; its first
// input holds the plaintext value (same shape the personal-key
// feedback scenario reads).
await dialog.getByText("It is only shown once").waitFor({ timeout: 30_000 });
mintedValue = await dialog.locator("input").first().inputValue();
expect(mintedValue, "a real key value is revealed once").toMatch(/^sk_/);
await dialog.getByRole("button", { name: "Close", exact: true }).first().click();
});

await step("The minted key appears in the org listing", async () => {
await page
.getByText("e2e backend reader")
.first()
.waitFor({ state: "visible", timeout: 30_000 });
});

await step("The minted value authenticates the tenant-wide admin API", async () => {
// The credential's purpose, proven from outside the browser: a backend
// holding ONLY this value can read the admin plane.
const response = await fetch(new URL("/api/admin/users", target.baseUrl), {
headers: { authorization: `Bearer ${mintedValue}` },
});
expect(response.status, "the org key reads the admin plane").toBe(200);
const body = (await response.json()) as {
users: ReadonlyArray<{ email: string | null }>;
};
expect(
body.users.map((user) => user.email),
"and sees the workspace's members",
).toContain(admin.credentials?.email);
});

await step("Revoke asks for confirmation, then the key stops working", async () => {
await page.getByRole("button", { name: "Revoke e2e backend reader" }).click();
// The confirm dialog names the key and warns; nothing is revoked yet.
await page
.getByRole("heading", { name: "Revoke organization key" })
.waitFor({ state: "visible", timeout: 30_000 });
await page.getByRole("button", { name: "Revoke key" }).click();
await page
.getByRole("heading", { name: "Revoke organization key" })
.waitFor({ state: "hidden", timeout: 30_000 });

// The revoked value no longer authenticates.
const after = await fetch(new URL("/api/admin/users", target.baseUrl), {
headers: { authorization: `Bearer ${mintedValue}` },
});
expect(after.status, "the revoked key is refused").toBe(401);
});
});

// ── The plain member's view ───────────────────────────────────────────
yield* browser.session(forBrowser(member), async ({ page, step }) => {
await step("A plain member is not shown the Organization keys section", async () => {
await page.goto("/", { waitUntil: "networkidle" });
await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
timeout: 30_000,
});
const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";
await page.goto(`/${slug}/api-keys`, { waitUntil: "networkidle" });
// The personal half renders for everyone…
await page
.getByRole("heading", { name: "Personal keys" })
.waitFor({ state: "visible", timeout: 30_000 });
// …the org section does not exist for a non-admin.
expect(
await page.getByRole("heading", { name: "Organization keys", exact: true }).count(),
"a plain member is not shown a section that would only refuse them",
).toBe(0);
});
});
}),
);
2 changes: 1 addition & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
},
"dependencies": {
"@executor-js/api": "workspace:*",
"@executor-js/emulate": "^0.13.6",
"@executor-js/emulate": "^0.13.9",
"@executor-js/mcporter": "^0.11.4",
"@executor-js/plugin-graphql": "workspace:*",
"@executor-js/plugin-mcp": "workspace:*",
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/api/account-atoms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ export const apiKeysAtom = AccountApiClient.query("account", "listApiKeys", {
export const createApiKey = AccountApiClient.mutation("account", "createApiKey");
export const revokeApiKey = AccountApiClient.mutation("account", "revokeApiKey");

// ── Org API keys ────────────────────────────────────────────────────────────

// Admin-gated on the server (403 for a plain member) and refused entirely on
// self-host. The page only reads this atom for an admin on a host that mints
// org keys, so a failure here renders as the section's error state, not as a
// role probe.
export const orgApiKeysAtom = AccountApiClient.query("account", "listOrgApiKeys", {
reactivityKeys: [ReactivityKey.orgApiKeys],
});

export const createOrgApiKey = AccountApiClient.mutation("account", "createOrgApiKey");
export const revokeOrgApiKey = AccountApiClient.mutation("account", "revokeOrgApiKey");

// ── Organization members ─────────────────────────────────────────────────────

// `refreshOnWindowFocus` is BROWSER-ONLY: its signal atom subscribes to
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/api/analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ export interface AnalyticsEvents {
api_key_created: { success: boolean };
api_key_revoked: { success: boolean };
api_key_copied: { kind: "value" | "bearer_header" };
org_api_key_created: { success: boolean };
org_api_key_revoked: { success: boolean };
org_api_key_copied: { kind: "value" | "bearer_header" };

// ── Organization ─────────────────────────────────────────────────────────
org_renamed: { success: boolean };
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/api/reactivity-keys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export const ReactivityKey = {
orgDomains: "org:domains",
orgInfo: "org:info",
apiKeys: "api-keys",
/** Org-owned keys (the platform credential) — separate from `apiKeys` so
* minting one does not refetch every member's personal-key list. */
orgApiKeys: "org:api-keys",
auth: "auth",
/** The tenant-wide admin users view. Read-only today (the admin plane has no
* writes), so nothing invalidates it — it exists so the pages refresh
Expand Down Expand Up @@ -89,6 +92,9 @@ export const orgInfoWriteKeys = [ReactivityKey.orgInfo, ReactivityKey.auth] as c
/** Cloud-only: user API key mutations. */
export const apiKeyWriteKeys = [ReactivityKey.apiKeys] as const;

/** Cloud-only: org API key mutations. */
export const orgApiKeyWriteKeys = [ReactivityKey.orgApiKeys] as const;

/** Cloud-only: auth mutations (org switch/create) — invalidate everything user-visible. */
export const authWriteKeys = [
ReactivityKey.auth,
Expand Down
Loading
Loading