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.
Set up
Section titled “Set up”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 ],}))In a route
Section titled “In a route”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 ofactionsfor those named.organization: Better Auth’s statements,"owner","admin","member"(the default),"none", or explicit.readOnly:refuseViewerWritesrefuses 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 constconst 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.
Platform admins
Section titled “Platform admins”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 ofplatform.adminRoles(default["admin"], as the admin plugin’s own).access.requireAdmin(c):{ userId }, or 401 signed out and 403ADMIN_REQUIRED.access.requireScope(c, permission):requireMember, or for an admin asking with?context=adminevery organization:organizationIdis 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).
Guards
Section titled “Guards”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.
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:
export const access = defineAccess(resources, { features: { defaults: { ai: false, booking: true } } })
// auth.tsorganization({ ..., schema: { organization: { additionalFields: { features: { type: "json", required: false, input: false } } } } })
// a routeawait access.requireFeature(c, "ai")features.read(c, organizationId) reads them from elsewhere, and
access.hasFeature(c, organizationId, key) answers without throwing.
Errors
Section titled “Errors”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.
Invitations
Section titled “Invitations”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-invitationchecks that the session’s email is the invited one;list-user-invitationsstill 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.
Other databases
Section titled “Other databases”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.
Studio parity
Section titled “Studio parity”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 |
requireMemberreadsc.var.userandc.var.session(Void’s auth middleware sets them). Any Hono context fits;requireScopeand the middleware also readc.req.- The module imports nothing else from
@recranet/flare, and nothing fromvoid, so it also works in the browser (access.can,access.rolesfor Better Auth’s client plugin).