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 fromapps/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:
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).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.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.Account And Membership Split
Better Auth Admin and Better Auth Organization have separate responsibilities:| Layer | Table/plugin | Responsibility |
|---|---|---|
| Login account | Better Auth Admin, hol_auth_users | Email/password/passkey identity, bans, account CRUD, account bootstrap role metadata. |
| Runtime entitlement | Better Auth Organization, hol_auth_organizations and hol_auth_members | Organization membership, active organization, role switching, and route RBAC. |
| Session scope | hol_auth_sessions.active_organization_id | The currently active organization for the browser session. |
Current Membership Roles
| Active organization member role | Request role | Meaning |
|---|---|---|
owner | superuser | Organization owner and operational root for setup, seeding, migrations, and machine administration. |
admin | admin | Dashboard operator with admin-level access inside the active organization. |
member | staff | Dashboard staff access for event operations in the active organization. |
kitchen_pos | staff | Staff-equivalent request role for kitchen/POS surfaces in the active organization. |
| none or inactive organization | public | Guest/public website and booking access; dashboard routes reject this state. |
| Helper | Allows |
|---|---|
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. Thepermissions 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.
Organization Switching
Dashboard organization switching uses Better Auth’s Organization plugin: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.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.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.GET /api/auth/me returns:
Public API Protection
Public API Policy Mode
ThePUBLIC_API_POLICY_MODE environment variable controls route-level access policy enforcement:
| Mode | Behavior |
|---|---|
report | Log policy violations without blocking (default, safe for initial rollout) |
enforce | Block requests that violate access policies |
off | Disable policy checks entirely |
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.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 inapps/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.| Route | Access | Notes |
|---|---|---|
POST /api/ai/demo | Public | Non-streaming demo response for customer support prompts. |
POST /llm/chat | Public | Streaming website chatbot endpoint for the landing widget, powered by Mastra through AI SDK UI. |
POST /api/ai/customer-support | Public | Legacy 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/* | Public | Assistant-safe support catalog summaries for show discovery, upcoming events, availability, booking options, and localized public route resolution. |
GET /api/ai/customer-support/knowledge | Public | Public read of published, dashboard-approved support knowledge used for static prompt context. |
POST /api/ai/customer-support/chat-notifications | Service token | Internal Mastra-to-Hono notification endpoint for full website chat transcripts. Requires HOL_LLM_SERVICE_TOKEN. |
GET /api/whatsapp/webhook | Public | Meta webhook verification using WHATSAPP_VERIFY_TOKEN. |
POST /api/whatsapp/webhook | Public | WhatsApp text webhook. Acknowledges quickly, then queues a demo Workers AI response through waitUntil(). |
POST /api/whatsapp/send | Admin+ | Existing template-send route. Public policy allows the route family, but the handler enforces admin access. |
POST /api/referral-links/resolve/{slug} | Public | Resolves a friendly /go/{slug} referral link, records attribution, and returns hidden booking attribution. |
GET /api/referral-partners/directory | Public | Direct /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}/profile | Public | Noindex /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-links | Admin+ | Lists hidden public referral links for dashboard management. |
POST /api/referral-links | Admin+ | 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}/sticker | Admin+ | Uploads a locally rendered referral sticker PNG to R2 and attaches its public asset URL to the managed referral link. |
GET /api/referral-commissions | Admin+ | Lists earned referral commission rows. |
PATCH /api/referral-commissions/{id}/status | Admin+ | Updates referral commission settlement status. |
POST /api/referral-commissions/payouts | Admin+ | Creates a payout batch for approved referral commissions. |
/api/mastra/* | Disabled | Mastra routes remain unmounted. The demo uses explicit Hono routes instead of a broad Mastra public surface. |
@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 var | Default | Purpose |
|---|---|---|
PUBLIC_API_POLICY_MODE | report | Policy enforcement mode (report/enforce/off) |
ALLOWED_ORIGIN | * | Wildcard-origin CORS requirement for browser API calls |
TURNSTILE_SECRET_KEY | empty | Cloudflare Turnstile secret (set via wrangler secret put) |
HOL_LLM_SERVICE_TOKEN | empty | Internal service token for Mastra-to-Hono protected LLM endpoints |
Protected Route Families
| Route family | Current access | Notes |
|---|---|---|
GET /api/ai/customer-support/catalog/* | public | Compact, assistant-safe catalog summaries. Does not expose raw booking SPA catalog rows, reservation rows, table assignments, or staff notes. |
GET /api/ai/customer-support/knowledge | public | Read-only static knowledge for the website customer-support agent; only published documents are returned. |
POST /api/ai/customer-support/chat-notifications | HOL_LLM_SERVICE_TOKEN | Internal Mastra service endpoint for full website chat transcript notification. It is not callable by browser clients. |
POST /api/reservations/customer-lead | public | Narrow booking contact capture. Upserts CRM customer details before reservation creation/payment so operators can call back if checkout fails. |
/api/ai/knowledge CRUDL | admin, superuser for all list/create/update/publish/archive operations | Dashboard-facing knowledge management. staff has no write access. |
/api/ai/knowledge/{id}/revisions | admin, superuser | Revision history is write-gated together with management routes. |
| Public booking/catalog routes | public where explicitly allowed | Public routes must be explicit policy cases. Do not make broad prefixes public to bypass auth friction. |
POST /api/analytics/meta/outbound-event | public | Public landing endpoint for selected browser-origin Meta CAPI events. Hono audits outbound send attempts only; it does not store inbound receive counts. |
| Dashboard staff operations | staff, admin, superuser | Event operations, check-in, and staff views use canUseStaffRoutes. |
GET /api/show-events/{id}/logistics | staff, admin, superuser | Mission Control logistics workload. Staff role required before service access. |
GET /api/check-ins/{id} | staff, admin, superuser | Check-in lookup by UUID or ?token= query param. Staff role required before both lookup modes. |
GET /api/check-ins/by-token/{token} | staff, admin, superuser | Deprecated token-based check-in lookup. Staff role required before token normalization. |
| Dashboard admin operations | admin, superuser | Commercial setup, shows, offers, payments, and CRM admin tools use canUseAdminRoutes. |
GET /api/integration/meta/conversions-api-events | admin, superuser | Lists Meta Conversions API audit rows for dashboard attribution diagnostics. Uses canUseAdminRoutes. |
/api/show-events CRUDL | staff for list/get/logistics, admin for create/update/archive/action mutations, superuser for batch seed creation | Event archive uses the existing cancellation flow rather than hard-deleting operational history. |
/api/shows/{id} archive | admin, superuser | Catalog archive is policy-checked by the show service; canonical shows are protected. |
/api/storage/blobs CRUDL | staff for blob reads/uploads, superuser for R2 object list/delete and hard storage cleanup | R2 object deletion removes the object only; database blob rows are preserved until normal reset/archive flows clear references. |
/api/promo-codes CRUDL | admin, superuser | Delete/archive marks promo codes INACTIVE to preserve reservation references. |
/api/referral-codes CRUDL | admin, superuser | Delete/archive marks referral codes ARCHIVED to preserve attribution history. |
/api/referral-partners/admin* | admin, superuser | First-class partner management. One partner can own multiple referral codes and links; explicit duplicate slugs are rejected. |
/api/referral-links CRUDL and sticker upload | admin, superuser | Public 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/directory | public | Direct /referral SSR source. Exposes only safe directory fields; raw notes, IDs, commission values, and CSV place IDs stay hidden. |
GET /api/referral-partners/{slug}/profile | public | Direct /partner/{slug} SSR source for active partners only. Exposes safe rules, stats, codes, and links; contact fields and notes stay hidden. |
/api/vouchers CRUDL | staff, admin, superuser | Delete/archive expires the voucher and clears remaining value. |
GET /api/email/messages | staff, admin, superuser | Global 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 CRUDL | staff for reads/email operations, admin for customer soft delete | Customer delete sets isDeleted=true; email history remains for audit. |
/api/waitlist CRUDL | public for join, staff for list/get/update/notify/archive | Waitlist archive marks entries EXPIRED; converted entries are protected. |
/api/tables CRUDL | staff, admin, superuser | Table delete relies on FK set null behavior for reservations and guest profiles. |
/api/inquiries CRUDL | public for create, staff for list/get/update/follow-up/archive | Inquiry archive sets status ARCHIVED; Turnstile still protects public creation when configured. |
/api/profiles CRUDL | Public guest profile/reaction flows, staff for profile soft delete | Profile delete sets isDeleted=true; public mutation endpoints remain constrained by their request tokens and existing service validation. |
/api/auth/me | Any non-public | Returns current principal metadata, active organization, available memberships, API key info, and permissions. |
/api/sudo/users (list, create, update, delete) | superuser only | Dashboard account CRUD through Better Auth Admin. |
/api/sudo/ensure-organization-member | superuser only | Seeder/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 only | Machine credential administration. |
/api/sudo/reset-database | superuser only | Destructive. Must not become admin-accessible. |
/api/sudo/run-migrations | superuser only | Schema migration. |
/api/sudo/create-fulfillment-items | superuser only | Seeder setup. |
/api/sudo/offers | superuser only | Seeder setup. |
/api/sudo/upload-seed-asset | superuser only | Asset upload for seeding. |
/api/sudo/batch-update-reservation-statuses | superuser only | Bulk status mutation. |
/api/sudo/backfill-event-slugs | superuser only | One-time data migration. |
GET /api/analytics/summary | staff, admin, superuser | Aggregate analytics summary. Uses canUseStaffRoutes. |
GET /api/analytics/top-shows | staff, admin, superuser | Top-performing shows ranking. Uses canUseStaffRoutes. |
GET /api/analytics/customer-intelligence | staff, admin, superuser | Aggregate-only customer intelligence. No raw PII in response. Uses canUseStaffRoutes. |
POST /api/system/visit-notification | public | Stores day/week aggregate counters in hol_landing_visit_counters only. Path/referrer accepted but not persisted. |
GET /api/system/visit-notification/stats | public | Returns current aggregate visit counters. |
Seeded Dashboard Accounts
The seeder creates login accounts and attaches them to the canonicalhouse-of-legends organization:
| Username | Email used by auth | Role | Intended permissions |
|---|---|---|---|
admin | admin@houseoflegends.vn | organization admin | Admin dashboard operations. |
marketing | marketing@houseoflegends.vn | organization admin | Admin dashboard operations. |
staff | staff@houseoflegends.vn | organization member | Staff event operations. |
@, 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.| Action | Required role | Required permission |
|---|---|---|
| List dashboard users | superuser | none; role is the boundary |
| Create dashboard user | superuser | none; role is the boundary |
| Update dashboard user profile/role/password | superuser | none; role is the boundary |
| Attach or update organization membership | superuser | none; role is the boundary |
| Delete dashboard user | superuser | none; role is the boundary |
| Reset database, seed data, run migrations | superuser only | none; role is the boundary |
| Manage API keys | superuser only | API-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
| Concern | Owner | Status |
|---|---|---|
| Role and auth-source resolution | apps/hono/src/middleware/role-context.ts | Shipped |
| Organization membership role lookup | apps/hono/src/services/auth/organization-role.service.ts | Shipped |
| Role helper functions | apps/hono/src/lib/roles.ts | Shipped |
| API-key permission parsing | apps/hono/src/middleware/role-context.ts | Shipped (parsed, not broadly enforced by route handlers) |
| Better Auth Admin plugin setup | apps/hono/src/lib/auth.ts | Shipped |
| Better Auth Organization plugin setup | apps/hono/src/lib/auth.ts | Shipped |
| Organization tables | apps/hono/src/db/schema/organizations.sql.ts | Shipped |
| Public API policy registry | apps/hono/src/lib/public-api-policy.ts | Shipped |
| Public API policy middleware | apps/hono/src/middleware/public-api-policy.ts | Shipped |
| Error response sanitization | apps/hono/src/lib/error-sanitizer.ts | Shipped |
| CORS wildcard origin requirement | apps/hono/src/worker.ts | Shipped |
| Chatbot demo routes | apps/hono/src/worker.ts, apps/hono/src/routes/ai/, apps/hono/src/routes/communications/whatsapp/ | Demo shipped |
| Inquiry Turnstile validation | apps/hono/src/routes/inquiries/mutations/create.ts | Shipped |
| Dashboard user CRUD routes | apps/hono/src/routes/auth/ (mounted under /api/sudo/) | Shipped (superuser-only) |
| Organization bootstrap route | apps/hono/src/routes/auth/sudo/mutations/ensure-organization-member.ts | Shipped (superuser-only) |
| Machine API-key routes | apps/hono/src/routes/auth/ (mounted under /api/sudo/) | Shipped (superuser-only) |
| Frontend route visibility | apps/frontend/components/dashboard/_shared/dashboard-nav-config.ts | Shipped |
| Authenticated frontend principal | apps/frontend/contexts/dashboard-auth/dashboard-auth-context.tsx | Shipped |
| Frontend active organization switching | apps/frontend/components/dashboard/_shared/role-switcher.tsx | Shipped |
Engineering Rules
- 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
superuseronly. - 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.
