Model
One identity system covers everyone. A person signs in through Supabase Auth — Google OAuth is the primary, passwordless path for everyone (and the only one for staff); clients can also use an email magic link or an optional passkey. What they can do is decided by their role, not by how they signed in.
accountsis the single identity table (1:1 withauth.usersvia a nullableuser_id— a row can exist before the person ever signs in, e.g. a lead we're working). A client is an account with no role. Staff hold one or more office roles viaaccount_roles.- Office roles (
rolestable,domain = 'office', ranked):staff(rank 10) — read-only across the office (for now).manager(rank 20) — write everything except/inventory,/settings, and/data.administrator(rank 30) — full access. Roles are many-to-many, so an account can also hold orthogonal roles in other domains (e.g. aneditorfor editorial) without affecting the office ladder.
- Gates (
src/lib/office/auth.ts):requireStaff()— any office role; gates read pages / the dashboard shell.requireRole(min)— minimum rank.requireWrite(section)—administratorforinventory/settings/data,managerotherwise. Server actions that mutate call this.getOfficeUser()is the non-redirecting variant (returnsOfficeUser | null). AnOfficeUsercarries{ id, accountId, email, roles, rank, isDevBypass };accountId(theaccounts.id) is what attribution uses (audit_log.actor_account_id).
- No env allowlist. Office access requires an
accountsrow that holds an office role inaccount_roles— there's noADMIN_EMAILSand no auto-bootstrap. Administrators manage roles at/office/team(see "Provisioning"). Magic links are a client convenience: the sign-in action declines any email holding an office role (staff sign in with Google), without leaking which. - Sign-in flow:
/auth/sign-inis the single door (office + portal). Google OAuth or a client magic link →/auth/callbackexchanges the PKCEcode(or verifies the magic-link OTP) for a cookie session →destinationForSession()routes by audience: an office role →/office; anyone else →/portal, auto-creating a client account on first sign-in (open signup —provision_oauth_account). The office gate (getOfficeAccess) then requires an office role and a live rolling staff session. - REST API (
src/lib/office/api/auth.ts): native/portal clients present a Bearer JWT;authenticateApiRequest(req, { minRole })validates it and resolves the same office identity. Read endpoints take the default (staff+); the pickup/return write endpoints requireminRole: "manager". - Why OAuth / no passwords: Supabase Auth gives real
auth.usersidentities for the audit trail (audit_log.actor_account_id) and avoids credential handling. Google (Workspace-friendly, centrally 2FA-able) is the staff path; there are no passwords. Staff passkeys (and the old enrol wall) were dropped — passkeys are now a client-only convenience, optional.
Data-access security (important)
Auth identifies who and what role; it is not how data is protected.
Every rental table is RLS-locked (deny-all for anon/authenticated); the
browser/session client can read nothing. All office data access uses the
service-role client (supabaseAdmin()), server-side only, which bypasses
RLS. So:
The real access control is
requireStaff()/requireWrite(section)running before any service-role query. New tables are auto-RLS-locked by therls_auto_enableevent trigger, so they're service-role-only by default too (this includesaccounts,client_profiles,roles,account_roles, andaccount_verifications).
A leaked anon key exposes nothing in these tables, and there are no per-row RLS policies to maintain.
Env vars
ADMIN_DEV_BYPASS=1 # dev only; non-prod; opens /office w/o login
# ADMIN_DEV_EMAIL=you@studio.chat # cosmetic email for the dev-bypass admin
# NEXT_PUBLIC_SITE_URL=https://studio.chat # for the OAuth / magic-link callback in prod
There is no ADMIN_EMAILS — office access is entirely DB-driven (a role in
account_roles).
NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY (already set) back
the auth client; SUPABASE_SERVICE_ROLE_KEY backs all data access.
Dev bypass
devBypassEnabled() (src/lib/office/auth.ts) decides whether to skip
real auth and return a synthetic administrator (email =
ADMIN_DEV_EMAIL or dev@studio.chat, id/accountId = null) so you can open
/office without receiving an email. It keys off VERCEL_ENV, not NODE_ENV. Resolution
order:
VERCEL_ENV=production(real production) → always off. No override.ADMIN_DEV_BYPASS=0→ off (force real auth, e.g. to test the Google sign-in flow on a preview / locally).ADMIN_DEV_BYPASS=1→ on (local dev, or any non-prod host).- Otherwise → on for Vercel preview deploys only (
VERCEL_ENV=preview), since per-deploy preview URLs aren't in Supabase's redirect allowlist and magic-link can't complete there.
The sidebar shows an amber dev_bypass_ marker. It's impossible to enable on
real production because the VERCEL_ENV=production check short-circuits before
any ADMIN_DEV_BYPASS override. Audit rows created under the bypass have
actor_user_id = null (the change is still recorded, just unattributed).
Why a default-on preview bypass is safe. A bypassed session is a full
admin, so what protects data is not the bypass — it's the database the deploy
talks to. Production (VERCEL_ENV=production) has the bypass hard off. Every
non-production deploy is VERCEL_ENV=preview and is wired, via the
Preview-scoped SUPABASE_* env vars, to the dev Supabase project — never
prod. So a bypassed admin on a preview can only touch the dev database (test
data); the real PII in prod is unreachable from any preview. Invariant to
preserve: the Preview environment's NEXT_PUBLIC_SUPABASE_URL /
SUPABASE_SERVICE_ROLE_KEY must stay pointed at dev. Aiming them at prod (e.g.
"to debug with real data") is what would turn the bypass into a real exposure —
that, not the bypass, is the thing never to do. (This is the resolution of audit
finding A1, which assumed the shared-DB topology this project doesn't use.)
No passwords
There are no passwords. Everyone uses Google; clients can also use a
magic link or a passkey. The default local path is the dev bypass
(above), but local Google sign-in does work against the local stack:
[auth.external.google] in supabase/config.toml is enabled as
config-as-code, reading env(GOOGLE_OAUTH_CLIENT_ID) / env(GOOGLE_OAUTH_SECRET)
(set them in .env, gitignored — not the old SUPABASE_AUTH_EXTERNAL_*
names, which supabase secrets set rejects for the reserved SUPABASE_ prefix).
Add http://127.0.0.1:54321/auth/v1/callback to the OAuth client's redirect
URIs and restart the stack. CI/branch previews don't carry those secrets, so
ci.yml passes dummy fallbacks (${{ secrets.GOOGLE_OAUTH_CLIENT_ID || 'ci-dummy…' }})
to keep supabase start from failing.
Provisioning office access
Administrators manage who can sign into the office at /office/team: change
a role or remove it (→ a plain client) inline, and add or promote someone by
email on the /office/team/add sub-page (find-or-create the accounts row,
grant a role). Staff = any account with an office role — no email-domain
restriction (contractors included). Every change is audited
(entity_type = account_roles). Protections:
-
Last administrator can't be removed (lockout guard).
-
Super administrator — the earliest-granted (real) administrator — can't be demoted or removed by anyone; the row is locked in the UI and the actions reject it. "Whoever is first gets it."
-
Service accounts — the system account (
administrator@studio.chat, all-zerosSYSTEM_ACCOUNT_ID; trigger / dev-bypass / automation attribution) and the agent account (agent@studio.chat,AGENT_ACCOUNT_ID; AI-agent / automation actions,source = 'agent', migration0010) — are shown but immutable:setOfficeRoleand the team actions reject them (IMMUTABLE_ACCOUNT_IDS), and they're excluded from the admin count and super-admin selection (they can't sign in). -
Members whose email is outside the
studio.chatWorkspace are flagged "2SV not enforced" in the roster — a reminder that org-enforced 2-Step Verification only covers Workspace accounts (seesrc/lib/office/workspace.ts). -
No staff are seeded.
supabase/seed.sqlcarries the real catalog (items/assets) but no staff account — staff sign in with Google and get roles via/office/team. Locally, open the office with the dev bypass, or bootstrap an admin by SQL:insert into accounts (email, display_name, source) values ('you@studio.chat', 'You', 'office') on conflict (lower(email)) do nothing; insert into account_roles (account_id, role_id) select a.id, r.id from accounts a, roles r where lower(a.email) = 'you@studio.chat' and r.key = 'administrator';Then sign in at
/auth/sign-inwith Google;resolveOfficeUser()links yourauth.usersrow to the account on first sign-in.
Caveat — promoting an active client. The office gate checks role + live session, not auth method. If you promote a client who's currently signed in (via magic link / passkey), that session keeps office access until it idles (15 min) or hits the 12-hour cap; new magic links are refused once they hold a role. To cut access immediately, revoke their sessions.
Sign-out
The sidebar "sign out" posts a server action that calls
supabase.auth.signOut() (revoking the rolling staff session) and redirects to
/auth/sign-in.
Design history: the original passkeys-as-staff-door model (and why it was retired for Google-only staff auth) is preserved in the archive.