Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/cloudflare-access-member-row.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 26 additions & 5 deletions apps/host-cloudflare/src/account/account-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions apps/host-cloudflare/src/worker.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading