From 44437b9cb94f21b5b1a898a40f71b5f87237c4a3 Mon Sep 17 00:00:00 2001 From: Davies Ayo Date: Fri, 2 Oct 2026 08:45:56 +1000 Subject: [PATCH] fix(host-cloudflare): list the Access caller as the workspace member The console derives admin gating from /account/members since #2051, and the Cloudflare host returned an empty list. Every Access user, including ADMIN_EMAILS admins, saw "Add integration" and "Browse integrations" disabled even though the server still granted admin. listMembers now returns the caller as one active member whose role mirrors the server's own admin rule (orgRole === "admin"). --- .changeset/cloudflare-access-member-row.md | 5 +++ .../src/account/account-provider.ts | 31 ++++++++++++++++--- .../src/worker.e2e.node.test.ts | 21 +++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 .changeset/cloudflare-access-member-row.md 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