Register endpoint (POST /api/v1/auth/register) only accepts role: "patient" | "doctor". Staff cannot self-register — you must promote an existing user.
Two supported paths: env-managed (recommended, auditable) or direct DB (one-off bootstrap).
The target user must already exist — register normally as patient or doctor first via the web UI or API, then promote them.
Uses STAFF_ALLOWLIST + STAFF_SYNC_MODE. The sync runs lazily on the next authenticated request, and automatically demotes anyone removed from the list, so the env var is the source of truth.
staff_management_states table must exist:
python3 -m migrations.run_allEdit app/.env (local) or Vercel project env (prod):
STAFF_ALLOWLIST=user:5 # or email:you@example.com, phone:+66..., telegram:...
STAFF_SYNC_MODE=dry-runRestart the API, trigger any authenticated request (e.g. open the web app), then check logs:
[staff-sync] Would promote user 5 (patient -> staff)
If the wrong user would be promoted — fix the allowlist before continuing.
STAFF_SYNC_MODE=applyRestart. On the next request the user's role flips to staff in the DB and a row is written to staff_management_states (so the system remembers to demote them if they drop out of the allowlist).
Log in as that user → /dashboard now shows the Membership Admin panel. The "System: Backups" link in the top-right links to /admin/system/backups.
| Prefix | Example | Notes |
|---|---|---|
user: |
user:5 |
User ID — most reliable |
email: |
email:you@example.com |
Hashed match against users.email_hash |
phone: |
phone:+66800000000 |
Hashed match against users.phone_number_hash |
telegram: |
telegram:123456789 |
Telegram user ID |
Multiple entries are comma-separated: user:5,email:admin@example.com.
- Unset — no sync, no access filter. Role comes from DB only. Fine for local dev.
STAFF_ALLOWLIST=NONE+apply— demotes all env-managed staff. Use only when you want to clear out previously promoted users.- Empty string — same as unset (warning logged).
Per request, for users whose signature changed:
- In allowlist & not staff → promote (
role = "staff", row added tostaff_management_states). - In
staff_management_statesbut not in allowlist → demote back tooriginal_role, metadata row deleted. - Already staff and in allowlist → unchanged.
Manually promoted staff (Path B below, without a staff_management_states row) are not touched by sync.
Only use this if Path A is blocked (e.g. migrations haven't run and you need access to run them). This bypasses staff_management_states so the user will not be auto-demoted later.
sqlite3 blood_pressure.db
> UPDATE users SET role='staff' WHERE id=5;
> .quitpsql "$DATABASE_URL"
=> UPDATE users SET role='staff' WHERE id=5;
=> \qThen set STAFF_ALLOWLIST with that user so they remain whitelisted even after the feature is fully rolled out:
STAFF_ALLOWLIST=user:5
STAFF_SYNC_MODE=applyWithout the allowlist entry, if STAFF_ALLOWLIST=NONE is ever set the manually-promoted user stays as staff (no staff_management_states row means sync ignores them), but endpoints guarded by require_staff will still reject access because is_staff_access_allowed returns False when the filter is enforced.
# 1. Login → copy JWT from cookie or response
# 2. Hit an admin endpoint
curl -H "Authorization: Bearer <JWT>" \
-H "X-API-Key: bp-web-app-key" \
https://your-api/api/v1/admin/usersExpected: 200 OK with masked user list. If 403 "Staff access denied" → allowlist doesn't match; 403 "Staff access required" → DB role isn't staff.
| Symptom | Likely cause |
|---|---|
| Dashboard shows patient view for a staff user | Browser has stale user cookie — log out / log back in |
403 Staff access denied on admin endpoints |
STAFF_ALLOWLIST set but user not in it |
[staff-sync] Metadata table 'staff_management_states' is missing |
Run python3 -m migrations.run_all |
| Allowlist change doesn't take effect | Sync only re-runs when STAFF_ALLOWLIST+STAFF_SYNC_MODE signature changes, or after a cold start; restart the API process |
| Vercel: sync never runs | Each serverless invocation has a fresh process, so sync runs on first authenticated request per instance — that's expected |
Logs spam [staff-sync] Timed out before loading sync candidates on Vercel |
Default STAFF_SYNC_TIMEOUT_MS=800 is too tight for Vercel + Neon cold start. Set STAFF_SYNC_TIMEOUT_MS=5000 in Vercel env and redeploy |
- BACKUP_AND_MIGRATION_SPEC — superadmin-only backup tool that requires this bootstrap step first.
- CLAUDE.md §Staff Admin Panel — endpoint reference.
- staff_sync.py — authoritative implementation.