# Staging auth rehearsal (no SMTP) Operator checklist to prove **promote company admin → re-issue set-password → one login** on staging Postgres **without** real SMTP. Delivery path = **copy invite URL** from migrator stdout / `artifacts/password_invites.json`. Companion scripts (print SQL + optionally run invite re-issue): - `scripts/staging-auth-rehearsal.ps1` (Windows / Laragon) - `scripts/staging-auth-rehearsal.sh` (bash) Full cutover remains [cutover.md](cutover.md). Status/blockers: [migration-readiness.md](migration-readiness.md). ## Scope | In | Out | |---|---| | Promote one company membership `member` → `admin` | Production DNS / Clerk decommission | | Re-issue set-password invites (`-issue-set-password-invites`) | `cmd/mailhooks` / SMTP send | | Copy one printed URL → `/accept-invite` → set password → `/login` | Live `.env` files (do not commit or paste secrets into docs) | | Confirm `must_set_password = false` | Bulk email blast | **ASSUMPTION:** Staging already has a migrated Postgres (see [migration-run-log.md](migration-run-log.md)). This rehearsal does **not** re-run MySQL ETL. ## Preconditions - [ ] Staging API + web reachable (defaults: API `:8080`, web `http://localhost:5174`) - [ ] Operator-supplied `DATABASE_URL` in the shell only — **do not** load or edit committed secrets; **do not** use production `.env` - [ ] At least one smoke user has a **real** email (not `*@legacy.local`) — `prepareSetPasswordHooks` skips synthetics via `IsSyntheticLegacyEmail` - [ ] You know which membership to promote (email + company), or will pick from the listing SQL below - [ ] `artifacts/` exists or will be created; gitignored — never commit invite tokens Local default DSN (matches `docker-compose.yml` / cutover quick reference): ```text postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable ``` Optional: set `WEB_ORIGIN` so printed links match the web you will open. Default when unset: `http://localhost:5174` (API `config.Load`, migrator `webOrigin`, Vite `apps/web/vite.config.ts`, `.env.example`). ## 0. Health (optional) ```bash curl -sS http://127.0.0.1:8080/healthz curl -sS http://127.0.0.1:8080/readyz ``` Both should return ready-ish JSON; `/readyz` fails if Postgres is down. ## 1. Promote company admin Import defaults all memberships to `role=member`. Company-admin APIs require session role `admin` (`CompanyAdminAllowed`). **Preferred:** migrator Postgres-only tooling (dry-run first; live needs `-confirm`). See also [cutover.md](cutover.md). ```bash cd apps/api export DATABASE_URL="postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable" # List active role=member rows (read-only) go run ./cmd/migrator -list-member-memberships -postgres "$DATABASE_URL" # Preview promote for one email or company (no writes; A1 rows always skipped as a1_cohort) go run ./cmd/migrator -promote-company-admins -email '' -dry-run -postgres "$DATABASE_URL" # or: -company-id '' -dry-run # Apply only after reviewing dry-run output (never promotes a1=true) go run ./cmd/migrator -promote-company-admins -email '' -confirm -postgres "$DATABASE_URL" ``` Optional narrow: `-user-id ` and/or `-company-id ` (`-company-id` alone is enough to scope). Unscoped promote is refused. **Never** promote A1 (`a1=true`); local Demo is already admin via `seed-demo` — see [demo-user.md](demo-user.md). ### 1a. List memberships (SQL alternate) ```sql SELECT u.id AS user_id, u.email, u.must_set_password, u.is_platform_admin, m.company_id, c.name AS company_name, m.role, m.status FROM memberships m JOIN users u ON u.id = m.user_id JOIN companies c ON c.id = m.company_id WHERE m.status = 'active' ORDER BY c.name, u.email; ``` ### 1b. Promote one row (SQL alternate; operator fills UUIDs) ```sql UPDATE memberships SET role = 'admin' WHERE user_id = '' AND company_id = '' AND status = 'active' RETURNING user_id, company_id, role; ``` Verify: ```sql SELECT role FROM memberships WHERE user_id = '' AND company_id = ''; -- expect: admin ``` ### 1c. Platform admin (separate) Legacy `admin_users` → `users.is_platform_admin` is applied during migrator load (`applyPlatformAdmins`), not by the SQL above. For `/admin` routes, confirm: ```sql SELECT email, is_platform_admin FROM users WHERE is_platform_admin = true; ``` If the smoke user must be a platform admin and is not, set only on staging with an explicit operator decision: ```sql UPDATE users SET is_platform_admin = true WHERE email = lower(''); ``` ### 1d. Alternate (after someone can already log in) Settings Team → Make admin, or `PATCH /api/team/{userID}` (requires an existing company admin session). Prefer SQL for first staging rehearsal when nobody can log in yet. ## 2. Re-issue set-password (link copy — no SMTP) Staging load used `-skip-post-import`, so invites were not created automatically. Re-issue is Postgres-only. ```bash cd apps/api export DATABASE_URL="postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable" # Optional: export WEB_ORIGIN="http://localhost:5174" go run ./cmd/migrator -issue-set-password-invites \ -postgres "$DATABASE_URL" \ -maps-dir ../../artifacts ``` PowerShell: ```powershell cd apps/api $env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable" # Optional: $env:WEB_ORIGIN = "http://localhost:5174" go run ./cmd/migrator -issue-set-password-invites ` -postgres $env:DATABASE_URL ` -maps-dir ../../artifacts ``` Or: `.\scripts\staging-auth-rehearsal.ps1 -IssueInvites` (requires `DATABASE_URL` in the environment). ### What success looks like - Stdout: `=== Set-password invite URLs ===` then `emailurl` lines (`writeSetPasswordArtifacts`) - Files (gitignored): `artifacts/password_invites.json`, `artifacts/set-password-hooks.json` - Counts: `set_password_hooks: N`; synthetic emails appear as skips, not URLs - Dry-run only: add `-dry-run` — prints `mode: dry-run (no invites written)` and writes nothing **Do not** run `go run ./cmd/mailhooks …` for this rehearsal. SMTP proof is a separate gate ([ops-runtime.md](ops-runtime.md), [cutover.md](cutover.md) §4). Invite URLs are `/accept-invite?token=…` (DB invite row → `POST /api/auth/accept-invite`). That differs from admin HMAC links (`?mode=set-password` via `POST /api/admin/emails/set-password`), which need a configured mailer. ## 3. One login smoke 1. Pick **one** row from stdout (or `password_invites.json` → `url`) for the promoted / real-email user. 2. Open the URL in a browser against your staging web origin. 3. Submit a password (≥8 characters). 4. Confirm you land in-app (invite accept may establish a session) **or** are sent to `/login`. 5. Sign in at `/login` with the same email + new password. 6. Confirm in Postgres: ```sql SELECT email, must_set_password, password_hash IS NOT NULL AS has_hash FROM users WHERE lower(email) = lower(''); -- expect: must_set_password = false, has_hash = true ``` 7. If you promoted company admin: open a company-admin surface (e.g. Settings team) and confirm it is allowed. 8. If platform admin: open `/admin`. ### Fail closed | Symptom | Likely cause | |---|---| | No URLs printed | All candidates `@legacy.local`, inactive, or no active membership | | Accept-invite error | Expired invite, wrong `WEB_ORIGIN`, or token already used — re-issue | | Login rejected | Password not set / still `must_set_password` / wrong email | | Company admin 403 | Membership still `member` | ### Escape hatch (not this rehearsal) `go run ./cmd/migrator -set-password "email:pass" -postgres "$DATABASE_URL"` bypasses the invite link path — local bootstrap only, not proof of cutover invite flow. ## Pass / fail **Pass** when: one company admin membership is `admin`, one set-password invite was issued and opened without SMTP, and that user can log in once with `must_set_password = false`. **Fail / stop** if you need SMTP, production credentials, or a live `.env` committed to the repo. Document the blocker in [migration-readiness.md](migration-readiness.md) instead of flipping DNS. ## Related - [cutover.md](cutover.md) §4 Set passwords after import - [migration-readiness.md](migration-readiness.md) After a live import - [go-live-checklist.md](go-live-checklist.md) Staging login rehearsal - [ops-runtime.md](ops-runtime.md) SMTP (deferred)