Skip to main content
Case studies

Personal project farshidpourlatifi/edgeseed 2026

Where EdgeSeed enforces organization roles

Only an owner can change a member's role or remove another member: the auth library's narrowed role table enforces that, not the members page.

  • TypeScript
  • Better Auth
  • Hono
  • React Router v7
  • Cloudflare Workers
  • Playwright

Case study - EdgeSeed, my open-source starter for applications on Cloudflare Workers.

Problem

EdgeSeed’s organizations have three roles, and only an owner can change a member’s role or remove another member. The rule holds when the endpoint is called directly: end-to-end tests post to it as an admin and are refused.

The members page renders that policy and enforces none of it. Enforcement lives in the role table of Better Auth, the authentication library EdgeSeed’s organizations are built on, and EdgeSeed’s configuration narrows that table.

Constraints

  • The page’s role-change and removal buttons call Better Auth’s /api/auth/organization/* endpoints directly, through authClient.
  • Better Auth’s stock admin role may update and delete members.

My responsibility

I am EdgeSeed’s only contributor: 212 commits over five months in 2026. The work in this study is mine as well - the role matrix, the narrowed role table, the API’s refusal handling, and the deny tests - built with AI coding assistance.

One matrix, read by the page

packages/auth/src/helpers/roles.ts states the policy once, as ORG_CAPABILITIES. Inviting, revoking an invitation, and reading pending invitations need admin or above. Changing a role and removing another member are owner-only, and anyone can leave. Call sites ask can(role, capability), and an unknown role fails closed.

The file has no imports, by rule. The web app imports it into browser components, and reaching it through the package index would pull better-auth into the client bundle.

The members page derives its four controls from can() and uses the result only to decide what to render (dashboard.members.tsx:166-169). The buttons call authClient.organization.updateMemberRole and removeMember (member-actions.tsx:178, :198), and Better Auth answers from its own role table.

The role table enforces

The stock admin role in Better Auth’s organization plugin, adminAc, grants member: ["create", "update", "delete"]. That is documented, intended behavior: in Better Auth’s model an admin manages members. EdgeSeed’s rule keeps role changes and removing other members with the owner, so I narrowed the admin role in packages/auth/src/organization.ts:73-77:

export const ORGANIZATION_ROLES = {
  owner: ownerAc,
  admin: defaultAc.newRole({ ...adminAc.statements, member: ["create"] }),
  member: memberAc,
};

Without the narrowing, the owner-only rule would have existed only in which buttons the page renders, and any admin could have changed a role or removed a member by posting to the endpoint. The rule and its enforcement arrived in one commit, 4ebe048 (PR #62): changeRole and removeMember in ORG_CAPABILITIES, the narrowed role table, the role-change and removal UI, and the deny tests all first appear there. No release had the rule without the enforcement.

The invitation hook adds a rule the role table has no way to express: nobody is invited as an owner, because ownership is a promotion and goes to someone already in the organization. beforeCreateInvitation throws OWNER_MUST_BE_PROMOTED_NOT_INVITED (organization.ts:105-113).

The deny tests in tests/e2e/member-actions.spec.ts:452-499 skip the UI. Each signs in as an admin, posts to the Better Auth endpoint, and asserts the refusal, the specific error code, and that the member’s row did not change. The comment on the test for changing a plain member’s role says stock adminAc would answer 200 and make the member an admin, and ends “it was watched doing exactly that before the override was kept.”

When the API and the role table disagree

EdgeSeed’s REST API, /api/v1, also accepts membership writes and delegates each one to Better Auth, so the same role table enforces them. Before delegating, the API checks can() to decide which refusal the caller hears first and to save a round trip on a write that would fail (api-organization.ts:46-48).

On a role change or removal, can() has already approved the caller, so a role refusal from Better Auth there means can() and ORGANIZATION_ROLES disagree, a bug in EdgeSeed. I left it to escape as a 500 with a correlation id (api-organization.ts:314-318) instead of telling the caller they lack a role the matrix says they have.

The invitation hook’s refusal arrives as a 403, so PRODUCT_REFUSAL_CODES (roles.ts:143) separates it, and the API answers it with 400 and its code. The commit that added these routes, 8d25234, let that case escape as an unhandled 500, while the browser path had already asserted 403 on it. The unit test had mocked a 400 the hook never throws. d6e0cd1 fixed it in the same pull request, #64, before it reached main.

Trade-offs

  • One policy, written twice. The page and the API read ORG_CAPABILITIES, and Better Auth enforces ORGANIZATION_ROLES, so a change to the rule has to land in both.
  • Deriving the admin role instead of restating it. Because I spread adminAc.statements, a resource that a Better Auth upgrade adds to its admin role reaches EdgeSeed’s admins as well. The comment above the table gives the reason: restating the statements “would mean an upgrade that adds a resource to Better Auth’s admin role silently denies it here, which is the failure that reads as a product bug rather than as a version bump.”

Results

  • Three end-to-end tests assert that an admin is refused at the endpoint, and the comment on the role-change test records that test being watched failing against the stock defaults.

Stack

TypeScript · React Router v7 · Hono with @hono/zod-openapi · Better Auth organization plugin · Drizzle ORM · Cloudflare Workers · Playwright

What I’d change today

I’d keep enforcement in Better Auth’s organization role table. The browser calls Better Auth’s endpoints directly, so the role table is the one place every role change and removal passes through.

A test already compares ORG_CAPABILITIES with the role table for each role and shared operation, alongside the direct-endpoint deny tests. What these tests rely on is written by hand: the permission each endpoint checks, and the admin statements they pin.

I’d make dependency upgrades surface both. Spreading adminAc.statements was a deliberate choice, but I’d want a failing test to flag a new permission, or an endpoint checking a different one, so I could review it before it reaches EdgeSeed’s admins.