diff --git a/.changeset/cloudflare-access-member-row.md b/.changeset/cloudflare-access-member-row.md new file mode 100644 index 0000000000..1e1bbfa1ce --- /dev/null +++ b/.changeset/cloudflare-access-member-row.md @@ -0,0 +1,5 @@ +--- +"@executor-js/host-cloudflare": patch +--- + +List the Cloudflare Access caller as the workspace's one active member, so admins in `ADMIN_EMAILS` can add and browse integrations in the console again. diff --git a/apps/host-cloudflare/src/account/account-provider.ts b/apps/host-cloudflare/src/account/account-provider.ts index bbbbd3d522..8f1555db26 100644 --- a/apps/host-cloudflare/src/account/account-provider.ts +++ b/apps/host-cloudflare/src/account/account-provider.ts @@ -17,10 +17,10 @@ import type { CloudflareConfig } from "../config"; // uses), reading the `Cf-Access-Jwt-Assertion` header off the request. // // Single-tenant + Access-managed: members, roles, and API keys live in -// Cloudflare Access, NOT in the app. The shell hides the API-keys footer and -// shows no members page, so those methods are never reached from the UI; they -// return empty (reads) or a clear "managed by Cloudflare Access" error (writes) -// to satisfy the provider shape. +// Cloudflare Access, NOT in the app. The member list is only the caller, with +// the role Access and ADMIN_EMAILS grant, because the console derives admin +// gating from that row. The other methods return empty (reads) or a clear +// "managed by Cloudflare Access" error (writes) to satisfy the provider shape. // --------------------------------------------------------------------------- const NOT_IN_APP = "Managed by Cloudflare Access, not in the app."; @@ -66,7 +66,28 @@ export const cloudflareAccountProvider = ( listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }), createOrgApiKey: () => forbiddenWrite, revokeOrgApiKey: () => forbiddenWrite, - listMembers: () => Effect.succeed({ members: [] }), + listMembers: (headers) => + principalFrom(headers).pipe( + Effect.flatMap((principal) => + principal + ? Effect.succeed({ + members: [ + { + id: principal.accountId, + userId: principal.accountId, + email: principal.email || null, + name: principal.name, + avatarUrl: principal.avatarUrl, + role: principal.orgRole === "admin" ? "admin" : "member", + status: "active", + lastActiveAt: null, + isCurrentUser: true, + }, + ], + }) + : Effect.fail(new AccountUnauthorized()), + ), + ), listRoles: () => Effect.succeed({ roles: [] }), inviteMember: () => forbiddenWrite, removeMember: () => forbiddenWrite, diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 30798ca2f2..c6132289dc 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -252,6 +252,27 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { expect(me.user.id).toBe("dev"); }); + it("lists the caller as the one active member, with the admin role the server grants", async () => { + const res = await worker.fetch("/api/account/members"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + members: ReadonlyArray<{ + userId: string; + role: string; + status: string; + isCurrentUser: boolean; + }>; + }; + expect( + body.members.map(({ userId, role, status, isCurrentUser }) => ({ + userId, + role, + status, + isCurrentUser, + })), + ).toEqual([{ userId: "dev", role: "admin", status: "active", isCurrentUser: true }]); + }); + it("lists tools on a follow-up request after a fresh initialize (DO session survives across requests)", async () => { // The production regression: `initialize` creates the session, then a // SEPARATE `tools/list` request must find it. With the old in-process store a