Staff authentication
Better Auth session required for every /dashboard API route and the dashboard SPA. Path context stays the tenant authority.
Staff authentication
Slice 9 wired Better Auth (ADR-019) for staff email + password. This slice (Auth → Dashboard-Gate) requires that session for every /dashboard/* API route and for the dashboard SPA.
Dashboard mutations stay on /dashboard/restaurants/:restaurantId/... (ADR-021). session.restaurantId is not this slice and does not replace the path. Guest checkout stays without a customer account. Storefront hosts have no staff login.
Staging Traefik HTTP BasicAuth is unchanged and is not a substitute for this session gate.
Session shape
Staff session identity is:
| Field | Source | Notes |
|---|---|---|
userId | Better Auth session / user id | Opaque string (Better Auth text id) |
role | user.role additional field | RESTAURANT or ADMIN only (STAFF_ROLES) |
There is no session.restaurantId and nothing reads one for authorization. A logged-in Luigi operator may still call Mario's dashboard path. Isolation Luigi ↔ Mario remains an API/DB property (WHERE restaurant_id = :pathId). Both RESTAURANT and ADMIN sessions may use the dashboard. /admin requires a session and has no extra role matrix.
Customer accounts are not this slice. Guest checkout is unchanged.
Tables
Better Auth + Drizzle core tables, plus a server-owned role on user. Order tables are untouched. No membership table.
user
| Column | Notes |
|---|---|
id | Text (Better Auth default, not a product UUID) |
name / email | Email is unique |
email_verified | Seeds mark demo users verified |
image | Optional |
role | RESTAURANT | ADMIN. input: false — clients cannot set it |
created_at / updated_at | Timestamps |
session
| Column | Notes |
|---|---|
id / token | Session row and unique token |
user_id | FK → user.id (ON DELETE cascade) |
expires_at | Expiry |
ip_address / user_agent | Optional |
created_at / updated_at | Timestamps |
No restaurant_id column.
account
Credential accounts: provider_id = credential, account_id = the user's id, password = Better Auth hash.
verification
Required by Better Auth. Unused (no email verification flow).
API
Handler lives on Fastify at /auth/*. Browsers call same-origin /api/auth/*; Traefik already strips /api. There is no host-only Next /api rewrite.
Better Auth basePath is /auth because Fastify never sees the /api prefix. Cookie Path=/ so the browser sends the cookie on /api/auth/* and /api/dashboard/*. Cookies are host-only (no Domain) — storefront hosts do not receive a staff session.
| Method | Fastify path | Browser (dashboard) |
|---|---|---|
POST | /auth/sign-in/email | /api/auth/sign-in/email |
POST | /auth/sign-out | /api/auth/sign-out |
GET | /auth/get-session | /api/auth/get-session |
Public sign-up is disabled. Operators come from seeds.
BETTER_AUTH_SECRET signs cookies. Production/staging without a secret: auth routes are not registered (no silent Better Auth default). Local and test may use the .env.example placeholder. When auth is unregistered, /dashboard/* still returns 401.
A single Fastify hook (dashboardAuthPlugin) rejects unauthenticated /dashboard/* with 401. Menu CRUD, media uploads, hours GET/PUT, restaurant list, order list/get, accept/reject, and kitchen writes are in that set. Body restaurantId still does not change the tenant.
Guest storefront (restaurant, menu, cart, checkout, order GET, hours GET) stays 200 without a staff session. /auth/*, /health, /media, and /webhooks stay public (existing tenant skip list).
Dashboard
Unauthenticated visits to /, /menu, /orders, and /admin redirect to /login. After login, the SPA returns to the intended page. Sign out goes to /login. The shell shows email + role after login.
/admin requires a session. There is no extra role matrix: RESTAURANT and ADMIN may both open it.
Seeds
Optional demo users (idempotent upserts). No orders.
| Role | Password (seed/test only) | |
|---|---|---|
restaurant@bytebite.test | RESTAURANT | restaurant-seed |
admin@bytebite.test | ADMIN | admin-seed |
These are not production secrets.
Isolation
- Login with a seed user → session with
userId+role. Dashboard menu/orders/hours/uploads return 200. Logout → no session. - Same dashboard calls without a cookie → 401, no writes (including logo upload and kitchen/accept).
- Wrong password → Better Auth
401or400. No extra user-enumeration behavior. - Luigi guest checkout without a customer account stays 200.
- Luigi
orderIdunder Mario's dashboard path stays 404 (with a valid session). - Seeds create no orders.
- Body
restaurantIdstill does not change the tenant.
Not in this slice
Replacing the path with session.restaurantId, a user↔restaurant membership table, customer accounts, 2FA, OAuth, role-gating beyond “must be logged in”, BitePay, delivery, variants, e-mail sends, sound/PWA, production deploy. Closed checkout 409: Opening hours. SMS on accept/reject/timeout/ready: Transactional notifications.