Skip to content

access

Organization roles and the member check for a Void app, on Better Auth’s organization access control, with an optional platform tier on its admin plugin. The first Flare plugin on Void (see “Plan: Flare as small plugins on Void” in research/void-spike.md). starters/api uses it.

Role Your resources
owner everything
admin everything
member everything but delete and manage
viewer list, read, export (read-only)

Those are the defaults; roles names your own (below). Better Auth’s own statements (organization, member, invitation, team) follow each role’s organization preset. A member with several roles (Better Auth stores them comma-separated) may do what any of them may.

lib/access.ts belongs to the app. Add resources here:

import { defineAccess } from "@recranet/flare/access"
export const access = defineAccess({ task: ["list", "read", "create", "update", "delete"] })

In auth.ts: the roles, and invitations (below).

import { admin, organization } from "better-auth/plugins"
import { defineAuth } from "void/auth"
import { access } from "./lib/access.ts"
import { sendInvitation } from "./lib/invitations.ts"
export default defineAuth(({ defaults }) => ({
...defaults,
emailAndPassword: { enabled: true },
plugins: [
organization({ ac: access.ac, roles: access.roles, creatorRole: "owner", sendInvitationEmail: sendInvitation, requireEmailVerificationOnInvitation: false }),
admin({ defaultRole: "user", adminRoles: ["admin"] }), // only for the platform tier
],
}))
export const GET = defineHandler(async (c) => {
const m = await access.requireMember(c, { task: ["list"] })
return db.select().from(tasks).where(eq(tasks.organizationId, m.organizationId))
})

requireMember returns { userId, organizationId, role } for the session’s active organization. It answers 401 when signed out, and 403 without an active organization, membership or the permission. Filter every query on m.organizationId, and take it from here on insert, never from the request body. A record in another organization is then a 404, the same as a missing one: throw notFound().

For checks on a record, such as “members delete only their own”:

const mayDelete = access.can(m.role, { task: ["delete"] }) || (task.userId === m.userId && access.can(m.role, { task: ["update"] }))

defineAccess(resources, { roles }) takes the role names and what each may do. Each role is:

  • actions: on every app resource, "all", { except: [...] } or { only: [...] }. Default none.
  • resources: per-resource grants, in place of actions for those named.
  • organization: Better Auth’s statements, "owner", "admin", "member" (the default), "none", or explicit.
  • readOnly: refuseViewerWrites refuses its writes.

Recranet Studio’s set, with a list action added to Better Auth’s member (packages/flare/test/unit/access-studio.test.ts):

import { adminAc, ownerAc } from "better-auth/plugins/organization/access"
const crud = ["list", "read", "create", "update", "delete"] as const
const members = ["create", "update", "delete", "list"] as const
export const access = defineAccess(
{ page: crud, entry: crud, member: members },
{
roles: {
owner: { actions: "all", organization: { ...ownerAc.statements, member: members } },
manager: { actions: "all", organization: { ...adminAc.statements, member: members, organization: ["update"] } },
editor: { actions: "all", organization: "none" },
viewer: { actions: { only: ["list", "read"] }, organization: "none", readOnly: true },
member: { actions: "all", organization: "member" },
},
},
)

access.roles goes to Better Auth’s organization plugin as before; access.roleNames lists them.

Better Auth’s admin plugin keeps a global role in user.role. The session carries it, so these read nothing:

  • access.isAdmin(c): whether the user holds one of platform.adminRoles (default ["admin"], as the admin plugin’s own).
  • access.requireAdmin(c): { userId }, or 401 signed out and 403 ADMIN_REQUIRED.
  • access.requireScope(c, permission): requireMember, or for an admin asking with ?context=admin every organization: organizationId is then null. Anyone else asking for it gets their own organization.
const scope = await access.requireScope(c, { task: ["list"] })
const rows = await db.select().from(tasks).where(scope.organizationId ? eq(tasks.organizationId, scope.organizationId) : undefined)

platform.context changes the flag’s value; Studio’s is recranet-admin. With Better Auth’s session cookie cache, a changed role shows once the cache expires (5 minutes in the starter).

refuseViewerWrites is middleware. A write (not GET, HEAD or OPTIONS) from a member whose roles are all readOnly is a 403 VIEWER_READ_ONLY before the route runs. Platform admins, Better Auth’s /api/auth/, and the prefixes in except pass; so do signed-out and organization-less requests, which the route’s requireMember answers.

middleware/01.viewers.ts
import { defineMiddleware } from "void"
import { access } from "../lib/access.ts"
export default defineMiddleware(access.refuseViewerWrites({ except: ["/api/me/"] }))

requireFeature(c, key) checks a per-organization flag, 403 FEATURE_DISABLED with params.feature. The registry and its defaults go in features.defaults; an organization’s overrides come from a JSON features column on Better Auth’s organization table by default. Declare it with input: false, so members can’t switch their own:

lib/access.ts
export const access = defineAccess(resources, { features: { defaults: { ai: false, booking: true } } })
// auth.ts
organization({ ..., schema: { organization: { additionalFields: { features: { type: "json", required: false, input: false } } } } })
// a route
await access.requireFeature(c, "ai")

features.read(c, organizationId) reads them from elsewhere, and access.hasFeature(c, organizationId, key) answers without throwing.

Every refusal is { error, code }, with params or issues when there are any. error is English, for logs and clients that don’t know the code; code is stable, for a client to translate. Never rename or reuse one.

Status Code When
401 UNAUTHORIZED signed out
403 NO_ACTIVE_ORGANIZATION no active organization
403 FORBIDDEN not a member, or the role lacks the permission
403 ADMIN_REQUIRED requireAdmin for someone else
403 VIEWER_READ_ONLY refuseViewerWrites
403 FEATURE_DISABLED requireFeature
404 NOT_FOUND notFound()

accessError(status, error, code, { params, issues }) makes the app’s own. formatError(err) turns any thrown error into that shape, for a Hono app.onError; an HTTPException’s cause may carry code, params and issues, and anything else is a 500 INTERNAL that doesn’t leak its message. Void’s validator answers 400 with { error, issues } itself.

Better Auth asks for a verified address before an invitee accepts, unless it made the invitation id itself. With advanced.database.generateId set (the starter’s random UUIDs), it can’t tell, so accepting fails without a verification mail. requireEmailVerificationOnInvitation: false turns that off, and the invitation mail is the proof instead:

  • only the mail carries the id, so send it to the invited address alone;
  • accept-invitation checks that the session’s email is the invited one;
  • list-user-invitations still refuses unverified users, so an account made at someone else’s address can’t look the id up.

starters/api/test/api.test.ts checks all three. The starter logs the invitation, having no mail domain; examples/crm sends it with void/email.

requireMember and refuseViewerWrites read the role from Better Auth’s member table through the DB binding, with Void’s column names, and requireFeature reads the organization table. readRole(c, { organizationId, userId }) and features.read take their place, for Postgres through Hyperdrive or another schema.

Recranet Studio’s auth (api/src/lib in recranet/studio: Hono, Better Auth 1.7.7 on Postgres) against this plugin.

Studio @recranet/flare/access
Organization from the session, member row re-read per request requireMember; readRole for Postgres
Org roles owner, manager, editor, viewer, member (permissions.ts) defineAccess(resources, { roles }), shown in the test
requireOrgManager: hasPermission({ member: ["create"] }) requireMember(c, { member: ["create"] }) with Studio’s roles
Global admin / user on the admin plugin, isAdmin, requireAdmin isAdmin, requireAdmin on user.role; by role name, not userHasPermission
?context=recranet-admin cross-organization listings requireScope with platform: { context: "recranet-admin" }
refuseViewerWrites with writable prefixes, admins exempt refuseViewerWrites({ except }), any readOnly role, admins exempt
requireFeature on organizations.features with registry defaults requireFeature, features: { defaults, read }
requireApp, refusePreview Not here: app-specific, built on accessError
PBKDF2 (pbkdf2-sha256$…) and legacy Symfony bcrypt passwords Not here: Studio’s own formats, kept in Studio
{ error, code, params?, issues? }, codes in errors.ts, app.onError Same body; errorCodes, accessError, formatError
404 across organizations notFound(), after filtering on organizationId
requireEmailVerificationOnInvitation: false, invite mail from the app Same, documented and tested in starters/api
VIEWER_READ_ONLY code Same code
Content-API tokens, MCP per-tool checks, OAuth provider Not here
  • requireMember reads c.var.user and c.var.session (Void’s auth middleware sets them). Any Hono context fits; requireScope and the middleware also read c.req.
  • The module imports nothing else from @recranet/flare, and nothing from void, so it also works in the browser (access.can, access.roles for Better Auth’s client plugin).