Files
descrybe/docs/staging-auth-rehearsal.md
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

8.3 KiB

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. Status/blockers: migration-readiness.md.

Scope

In Out
Promote one company membership memberadmin 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). 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):

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)

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.

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 '<real-email>' -dry-run -postgres "$DATABASE_URL"
# or: -company-id '<non-a1-uuid>' -dry-run

# Apply only after reviewing dry-run output (never promotes a1=true)
go run ./cmd/migrator -promote-company-admins -email '<real-email>' -confirm -postgres "$DATABASE_URL"

Optional narrow: -user-id <uuid> and/or -company-id <uuid> (-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.

1a. List memberships (SQL alternate)

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)

UPDATE memberships
SET role = 'admin'
WHERE user_id = '<user-uuid>'
  AND company_id = '<company-uuid>'
  AND status = 'active'
RETURNING user_id, company_id, role;

Verify:

SELECT role FROM memberships
WHERE user_id = '<user-uuid>' AND company_id = '<company-uuid>';
-- expect: admin

1c. Platform admin (separate)

Legacy admin_usersusers.is_platform_admin is applied during migrator load (applyPlatformAdmins), not by the SQL above. For /admin routes, confirm:

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:

UPDATE users SET is_platform_admin = true WHERE email = lower('<real-email>');

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.

Staging load used -skip-post-import, so invites were not created automatically. Re-issue is Postgres-only.

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:

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 email<TAB>url 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, 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.jsonurl) 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:
SELECT email, must_set_password, password_hash IS NOT NULL AS has_hash
FROM users
WHERE lower(email) = lower('<smoke-email>');
-- expect: must_set_password = false, has_hash = true
  1. If you promoted company admin: open a company-admin surface (e.g. Settings team) and confirm it is allowed.
  2. 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 instead of flipping DNS.