Doc Status: Accurate as of 2026-06-01. Runtime dashboard RBAC now uses Better Auth Organization memberships. Better Auth Admin still owns login-account lifecycle and account bootstrap metadata, but hol_auth_users.role is not a runtime authorization fallback.

Why This Matters

House of Legends has several kinds of privileged actors: venue admins, staff, kitchen/POS operators, superuser seed scripts, and machine credentials used by automation. They should not all receive the same access. RBAC keeps the operational dashboard usable while making dangerous paths explicit. The backend remains the source of truth: the frontend may hide links and buttons, but every protected action must still be checked by Hono route handlers.

How It Works Today

Every Hono request receives an auth context from apps/hono/src/middleware/role-context.ts. The middleware resolves identity and sets role, authSource, permissions, and identity metadata on the Hono context. The middleware resolves identity in this order:
1

DB-backed API key

If the request includes a valid x-api-key header, the backend hashes the key, looks it up in the Better Auth apiKey table, verifies it is enabled and not expired, and confirms the owning user has SUPERUSER role. If any of these checks fail, resolution falls through to the next step. On success, the key’s stored permission map is flattened into strings such as reservations:read and set on the context. The request count update is best-effort (fire-and-forget via waitUntil).
2

Superuser API token

If the request includes a configured superuser token through x-api-token or Authorization: Bearer ..., the backend performs a timing-safe comparison against the SUPERUSER_API_TOKENS (or SUPERUSER_API_TOKEN) environment variable. On match, the request receives role superuser, auth source api-token, and empty permissions ([]). Role alone grants full access; there is no permission map for superuser tokens.
3

Better Auth session

If the request has a valid dashboard session cookie, the backend reads session.activeOrganizationId, loads that user’s membership from hol_auth_members, and maps the active organization member role into the request role. There is no fallback to hol_auth_users.role; a session without an active organization membership resolves as public/unauthorized.
4

Public request

If no privileged credential is present, the request receives role public, auth source public, and empty permissions.

Account And Membership Split

Better Auth Admin and Better Auth Organization have separate responsibilities:
LayerTable/pluginResponsibility
Login accountBetter Auth Admin, hol_auth_usersEmail/password/passkey identity, bans, account CRUD, account bootstrap role metadata.
Runtime entitlementBetter Auth Organization, hol_auth_organizations and hol_auth_membersOrganization membership, active organization, role switching, and route RBAC.
Session scopehol_auth_sessions.active_organization_idThe currently active organization for the browser session.
hol_auth_users.role is not a runtime authorization fallback. It remains only for Better Auth Admin plugin compatibility and seeded account defaults. If a signed-in account has no active organization membership, dashboard API requests are unauthorized.

Current Membership Roles

Active organization member roleRequest roleMeaning
ownersuperuserOrganization owner and operational root for setup, seeding, migrations, and machine administration.
adminadminDashboard operator with admin-level access inside the active organization.
memberstaffDashboard staff access for event operations in the active organization.
kitchen_posstaffStaff-equivalent request role for kitchen/POS surfaces in the active organization.
none or inactive organizationpublicGuest/public website and booking access; dashboard routes reject this state.
The helper ownership is:
HelperAllows
isSuperuserRole(role)superuser only
canUseAdminRoutes(role)superuser, admin
canUseStaffRoutes(role)superuser, admin, staff

Current Permission Boundary

Dashboard users currently rely on the active organization member role. API keys already support a permission map, but those permissions belong to machine credentials, not human dashboard accounts. The permissions array on the request context is only populated for API-key requests; session and superuser-token auth sources receive []. Current route handlers do not use that array as the primary authorization boundary; they check the normalized role.
Do not confuse API-key permissions with organization member roles. Today, permissions on the request context are non-empty only for API keys, and protected Hono routes still authorize with isSuperuserRole, canUseAdminRoutes, canUseStaffRoutes, or equivalent direct role checks. Dashboard sessions are authorized by active organization membership role.

Organization Switching

Dashboard organization switching uses Better Auth’s Organization plugin:
1

Seeder/bootstrap attaches memberships

Seeded dashboard accounts are created through the generated Hono client and then attached to the house-of-legends organization through client.sudo.ensureOrganizationMember(...). The seeder does not call raw /api paths directly.
2

Session creation selects an organization

When Better Auth creates a session, apps/hono/src/lib/auth.ts sets activeOrganizationId to the first membership for that user. This allows migrated users to log in without using legacy account roles.
3

Dashboard reads the principal from Hono

The frontend dashboard auth context calls GET /api/auth/me, not session.user.role, to load the current role, active organization, and available memberships.
4

User switches active organization

The role switcher calls authClient.organization.setActive(...), then invalidates the current-principal query. The next request resolves RBAC from the new active membership.
GET /api/auth/me returns:
type CurrentDashboardPrincipalResponse = {
  role: "superuser" | "admin" | "staff" | "public";
  source: "api-key" | "api-token" | "better-auth-session" | "public";
  subject: string | null;
  organizationId: string | null;
  activeOrganization: {
    organizationId: string;
    organizationSlug: string;
    organizationName: string;
    memberId: string;
    memberRole: "owner" | "admin" | "member" | "kitchen_pos";
    role: "superuser" | "admin" | "staff" | "public";
  } | null;
  organizations: Array<{
    organizationId: string;
    organizationSlug: string;
    organizationName: string;
    memberId: string;
    memberRole: "owner" | "admin" | "member" | "kitchen_pos";
    role: "superuser" | "admin" | "staff" | "public";
  }>;
  apiKeyId: string | null;
  apiKeyName: string | null;
  permissions: string[];
};

Public API Protection

Implemented 2026-05-31. These defense layers protect public-facing routes from abuse, information leakage, and unauthorized access.

Public API Policy Mode

The PUBLIC_API_POLICY_MODE environment variable controls route-level access policy enforcement:
ModeBehavior
reportLog policy violations without blocking (default, safe for initial rollout)
enforceBlock requests that violate access policies
offDisable policy checks entirely
The policy registry lives in apps/hono/src/lib/public-api-policy.ts. It maps route prefixes to minimum access levels. Violations are logged with the [public-api-policy] prefix.

CORS Wildcard Origins

Current user requirement: Hono /api/* and Mastra /llm/* browser endpoints must remain wildcard-origin capable. Do not replace reflected/wildcard CORS with a production origin allowlist unless this requirement is explicitly reversed.
Hono uses credentialed CORS, so it reflects the incoming Origin header instead of returning literal *. Mastra /llm/chat also allows any browser origin and may return literal * when no request Origin is present. ALLOWED_ORIGIN = "*" documents the wildcard requirement for Hono deployment config. Better Auth may still consume first-party origins separately for auth-specific trusted-origin checks, but CORS itself is intentionally wildcard-capable.

Error Response Sanitization

Public (unauthenticated) callers receive sanitized error responses — no stack traces, no internal error names, no cause chains. Authenticated roles (staff, admin, superuser) continue to see full error details. The sanitizer lives in apps/hono/src/lib/error-sanitizer.ts and is applied in the global error handler and 404 handler in worker.ts.

Chatbot Demo Routes

Approved demo scope: the public website chat streams a Mastra customer support agent through AI SDK UI from /llm/chat. Hono remains the source of truth for assistant-safe catalog and knowledge data. The demo does not create reservations, modify bookings, process payments, or create staff handoff records yet.
RouteAccessNotes
POST /api/ai/demoPublicNon-streaming demo response for customer support prompts.
POST /llm/chatPublicStreaming website chatbot endpoint for the landing widget, powered by Mastra through AI SDK UI.
POST /api/ai/customer-supportPublicLegacy Hono streaming demo route; do not add new website chat behavior here while /llm/chat owns the Mastra Worker surface.
GET /api/ai/customer-support/catalog/*PublicAssistant-safe support catalog summaries for show discovery, upcoming events, availability, booking options, and localized public route resolution.
GET /api/ai/customer-support/knowledgePublicPublic read of published, dashboard-approved support knowledge used for static prompt context.
POST /api/ai/customer-support/chat-notificationsService tokenInternal Mastra-to-Hono notification endpoint for full website chat transcripts. Requires HOL_LLM_SERVICE_TOKEN.
GET /api/whatsapp/webhookPublicMeta webhook verification using WHATSAPP_VERIFY_TOKEN.
POST /api/whatsapp/webhookPublicWhatsApp text webhook. Acknowledges quickly, then queues a demo Workers AI response through waitUntil().
POST /api/whatsapp/sendAdmin+Existing template-send route. Public policy allows the route family, but the handler enforces admin access.
POST /api/referral-links/resolve/{slug}PublicResolves a friendly /go/{slug} referral link, records attribution, and returns hidden booking attribution.
GET /api/referral-partners/directoryPublicDirect /referral page SSR source. Returns active partner names, contacts, safe CSV-derived directory fields, referral codes, guest-discount flag, and public /go slug/channel only.
GET /api/referral-partners/{slug}/profilePublicNoindex /partner/{slug} SSR source. Returns safe active partner summary, generated earning rules, aggregate stats, active codes, and active links.
/api/referral-partners/admin*Admin+Dashboard referral partner management. Creates/updates partner identity, status, contact fields, public slug, and aggregate admin metrics.
GET /api/referral-linksAdmin+Lists hidden public referral links for dashboard management.
POST /api/referral-linksAdmin+Creates a friendly public referral link for QR stickers and influencer profiles.
PATCH /api/referral-links/{id}Admin+Updates referral link slug, destination, channel, label, or status.
POST /api/referral-links/{id}/stickerAdmin+Uploads a locally rendered referral sticker PNG to R2 and attaches its public asset URL to the managed referral link.
GET /api/referral-commissionsAdmin+Lists earned referral commission rows.
PATCH /api/referral-commissions/{id}/statusAdmin+Updates referral commission settlement status.
POST /api/referral-commissions/payoutsAdmin+Creates a payout batch for approved referral commissions.
/api/mastra/*DisabledMastra routes remain unmounted. The demo uses explicit Hono routes instead of a broad Mastra public surface.
The first demo intentionally has no persistent conversation assignment or staff handoff state. Human escalation should be added as a separate feature with queueing, staff ownership, and rate limits before production rollout. Mastra calls these Hono data endpoints through the generated @hol/client package. In Cloudflare production, the Mastra Worker uses the HOL_HONO service binding for that injected client fetch so the request still enters Hono’s route boundary instead of bypassing API ownership. Website chat passes the active locale into /llm/chat; Mastra uses Hono-generated showUrl, bookingUrl, and /api/ai/customer-support/catalog/routes values for assistant links. The Mastra Worker also audits generated internal links against the same route data after the response stream is created.
The /api/ai/customer-support/knowledge endpoint is read-only and returns only published documents. Editing or publishing knowledge documents is not part of this route’s surface.

Turnstile on Inquiry Submissions

Inquiry creation (POST /api/inquiries) requires and validates a Cloudflare Turnstile token when TURNSTILE_SECRET_KEY is configured. Without a valid token, the request is rejected with 400. The turnstileToken field in the request body is optional when no secret key is configured (dev mode).

Configuration Reference

Env varDefaultPurpose
PUBLIC_API_POLICY_MODEreportPolicy enforcement mode (report/enforce/off)
ALLOWED_ORIGIN*Wildcard-origin CORS requirement for browser API calls
TURNSTILE_SECRET_KEYemptyCloudflare Turnstile secret (set via wrangler secret put)
HOL_LLM_SERVICE_TOKENemptyInternal service token for Mastra-to-Hono protected LLM endpoints

Protected Route Families

Route familyCurrent accessNotes
GET /api/ai/customer-support/catalog/*publicCompact, assistant-safe catalog summaries. Does not expose raw booking SPA catalog rows, reservation rows, table assignments, or staff notes.
GET /api/ai/customer-support/knowledgepublicRead-only static knowledge for the website customer-support agent; only published documents are returned.
POST /api/ai/customer-support/chat-notificationsHOL_LLM_SERVICE_TOKENInternal Mastra service endpoint for full website chat transcript notification. It is not callable by browser clients.
POST /api/reservations/customer-leadpublicNarrow booking contact capture. Upserts CRM customer details before reservation creation/payment so operators can call back if checkout fails.
/api/ai/knowledge CRUDLadmin, superuser for all list/create/update/publish/archive operationsDashboard-facing knowledge management. staff has no write access.
/api/ai/knowledge/{id}/revisionsadmin, superuserRevision history is write-gated together with management routes.
Public booking/catalog routespublic where explicitly allowedPublic routes must be explicit policy cases. Do not make broad prefixes public to bypass auth friction.
POST /api/analytics/meta/outbound-eventpublicPublic landing endpoint for selected browser-origin Meta CAPI events. Hono audits outbound send attempts only; it does not store inbound receive counts.
Dashboard staff operationsstaff, admin, superuserEvent operations, check-in, and staff views use canUseStaffRoutes.
GET /api/show-events/{id}/logisticsstaff, admin, superuserMission Control logistics workload. Staff role required before service access.
GET /api/check-ins/{id}staff, admin, superuserCheck-in lookup by UUID or ?token= query param. Staff role required before both lookup modes.
GET /api/check-ins/by-token/{token}staff, admin, superuserDeprecated token-based check-in lookup. Staff role required before token normalization.
Dashboard admin operationsadmin, superuserCommercial setup, shows, offers, payments, and CRM admin tools use canUseAdminRoutes.
GET /api/integration/meta/conversions-api-eventsadmin, superuserLists Meta Conversions API audit rows for dashboard attribution diagnostics. Uses canUseAdminRoutes.
/api/show-events CRUDLstaff for list/get/logistics, admin for create/update/archive/action mutations, superuser for batch seed creationEvent archive uses the existing cancellation flow rather than hard-deleting operational history.
/api/shows/{id} archiveadmin, superuserCatalog archive is policy-checked by the show service; canonical shows are protected.
/api/storage/blobs CRUDLstaff for blob reads/uploads, superuser for R2 object list/delete and hard storage cleanupR2 object deletion removes the object only; database blob rows are preserved until normal reset/archive flows clear references.
/api/promo-codes CRUDLadmin, superuserDelete/archive marks promo codes INACTIVE to preserve reservation references.
/api/referral-codes CRUDLadmin, superuserDelete/archive marks referral codes ARCHIVED to preserve attribution history.
/api/referral-partners/admin*admin, superuserFirst-class partner management. One partner can own multiple referral codes and links; explicit duplicate slugs are rejected.
/api/referral-links CRUDL and sticker uploadadmin, superuserPublic link management and locally rendered PNG sticker uploads. Sticker uploads store R2 blob metadata and never make referral-code management public.
GET /api/referral-partners/directorypublicDirect /referral SSR source. Exposes only safe directory fields; raw notes, IDs, commission values, and CSV place IDs stay hidden.
GET /api/referral-partners/{slug}/profilepublicDirect /partner/{slug} SSR source for active partners only. Exposes safe rules, stats, codes, and links; contact fields and notes stay hidden.
/api/vouchers CRUDLstaff, admin, superuserDelete/archive expires the voucher and clears remaining value.
GET /api/email/messagesstaff, admin, superuserGlobal outbound email ledger for dashboard tracking. Returns delivery metadata and linked customer fields; rendered HTML/text snapshots stay out of the list response.
/api/customers CRUDLstaff for reads/email operations, admin for customer soft deleteCustomer delete sets isDeleted=true; email history remains for audit.
/api/waitlist CRUDLpublic for join, staff for list/get/update/notify/archiveWaitlist archive marks entries EXPIRED; converted entries are protected.
/api/tables CRUDLstaff, admin, superuserTable delete relies on FK set null behavior for reservations and guest profiles.
/api/inquiries CRUDLpublic for create, staff for list/get/update/follow-up/archiveInquiry archive sets status ARCHIVED; Turnstile still protects public creation when configured.
/api/profiles CRUDLPublic guest profile/reaction flows, staff for profile soft deleteProfile delete sets isDeleted=true; public mutation endpoints remain constrained by their request tokens and existing service validation.
/api/auth/meAny non-publicReturns current principal metadata, active organization, available memberships, API key info, and permissions.
/api/sudo/users (list, create, update, delete)superuser onlyDashboard account CRUD through Better Auth Admin.
/api/sudo/ensure-organization-membersuperuser onlySeeder/bootstrap endpoint that creates or updates a Better Auth organization member using one small idempotent request per account.
/api/sudo/api-keys (list, create, update, delete)superuser onlyMachine credential administration.
/api/sudo/reset-databasesuperuser onlyDestructive. Must not become admin-accessible.
/api/sudo/run-migrationssuperuser onlySchema migration.
/api/sudo/create-fulfillment-itemssuperuser onlySeeder setup.
/api/sudo/offerssuperuser onlySeeder setup.
/api/sudo/upload-seed-assetsuperuser onlyAsset upload for seeding.
/api/sudo/batch-update-reservation-statusessuperuser onlyBulk status mutation.
/api/sudo/backfill-event-slugssuperuser onlyOne-time data migration.
GET /api/analytics/summarystaff, admin, superuserAggregate analytics summary. Uses canUseStaffRoutes.
GET /api/analytics/top-showsstaff, admin, superuserTop-performing shows ranking. Uses canUseStaffRoutes.
GET /api/analytics/customer-intelligencestaff, admin, superuserAggregate-only customer intelligence. No raw PII in response. Uses canUseStaffRoutes.
POST /api/system/visit-notificationpublicStores day/week aggregate counters in hol_landing_visit_counters only. Path/referrer accepted but not persisted.
GET /api/system/visit-notification/statspublicReturns current aggregate visit counters.

Seeded Dashboard Accounts

The seeder creates login accounts and attaches them to the canonical house-of-legends organization:
UsernameEmail used by authRoleIntended permissions
adminadmin@houseoflegends.vnorganization adminAdmin dashboard operations.
marketingmarketing@houseoflegends.vnorganization adminAdmin dashboard operations.
staffstaff@houseoflegends.vnorganization memberStaff event operations.
The frontend login accepts username-style input. If the identifier does not contain @, it maps the username to @houseoflegends.vn.

User Management Rules

Human account management remains a superuser/bootstrap boundary. Organization membership is the runtime entitlement layer, but destructive account and machine-credential administration is still restricted.
ActionRequired roleRequired permission
List dashboard userssuperusernone; role is the boundary
Create dashboard usersuperusernone; role is the boundary
Update dashboard user profile/role/passwordsuperusernone; role is the boundary
Attach or update organization membershipsuperusernone; role is the boundary
Delete dashboard usersuperusernone; role is the boundary
Reset database, seed data, run migrationssuperuser onlynone; role is the boundary
Manage API keyssuperuser onlyAPI-key permissions do not grant API-key administration
Organization admins must never receive access to seed/reset/migration endpoints through organization membership alone. Those routes stay superuser only because they can destroy or rewrite production data.

Implementation Ownership

ConcernOwnerStatus
Role and auth-source resolutionapps/hono/src/middleware/role-context.tsShipped
Organization membership role lookupapps/hono/src/services/auth/organization-role.service.tsShipped
Role helper functionsapps/hono/src/lib/roles.tsShipped
API-key permission parsingapps/hono/src/middleware/role-context.tsShipped (parsed, not broadly enforced by route handlers)
Better Auth Admin plugin setupapps/hono/src/lib/auth.tsShipped
Better Auth Organization plugin setupapps/hono/src/lib/auth.tsShipped
Organization tablesapps/hono/src/db/schema/organizations.sql.tsShipped
Public API policy registryapps/hono/src/lib/public-api-policy.tsShipped
Public API policy middlewareapps/hono/src/middleware/public-api-policy.tsShipped
Error response sanitizationapps/hono/src/lib/error-sanitizer.tsShipped
CORS wildcard origin requirementapps/hono/src/worker.tsShipped
Chatbot demo routesapps/hono/src/worker.ts, apps/hono/src/routes/ai/, apps/hono/src/routes/communications/whatsapp/Demo shipped
Inquiry Turnstile validationapps/hono/src/routes/inquiries/mutations/create.tsShipped
Dashboard user CRUD routesapps/hono/src/routes/auth/ (mounted under /api/sudo/)Shipped (superuser-only)
Organization bootstrap routeapps/hono/src/routes/auth/sudo/mutations/ensure-organization-member.tsShipped (superuser-only)
Machine API-key routesapps/hono/src/routes/auth/ (mounted under /api/sudo/)Shipped (superuser-only)
Frontend route visibilityapps/frontend/components/dashboard/_shared/dashboard-nav-config.tsShipped
Authenticated frontend principalapps/frontend/contexts/dashboard-auth/dashboard-auth-context.tsxShipped
Frontend active organization switchingapps/frontend/components/dashboard/_shared/role-switcher.tsxShipped

Engineering Rules

Frontend role checks are a convenience only. A dashboard nav item may hide a page, but backend route handlers must still enforce the permission boundary.
  • Public route families must be explicit. Do not make a broad route prefix public to bypass auth friction.
  • Machine API-key permissions and human organization memberships are separate permission domains.
  • Seeder/reset/migration routes remain superuser only.
  • Dashboard account creation should go through the Hono API, not direct table writes from the frontend.
  • Dashboard session authorization must resolve from active organization membership, not hol_auth_users.role.
  • If route access changes, update this page and regenerate OpenAPI/Fern when the API contract changes.
The implementation plan for the Better Auth Organization migration lives at:
docs/superpowers/plans/2026-06-01-better-auth-organization-role-switching.md