Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
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 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). This rehearsal does not re-run MySQL ETL.
Preconditions
- Staging API + web reachable (defaults: API
:8080, webhttp://localhost:5174) - Operator-supplied
DATABASE_URLin 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) —prepareSetPasswordHooksskips synthetics viaIsSyntheticLegacyEmail - 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_users → users.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.
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.
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 ===thenemail<TAB>urllines (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— printsmode: 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
- Pick one row from stdout (or
password_invites.json→url) for the promoted / real-email user. - Open the URL in a browser against your staging web origin.
- Submit a password (≥8 characters).
- Confirm you land in-app (invite accept may establish a session) or are sent to
/login. - Sign in at
/loginwith the same email + new password. - 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
- If you promoted company admin: open a company-admin surface (e.g. Settings team) and confirm it is allowed.
- 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.
Related
- cutover.md §4 Set passwords after import
- migration-readiness.md After a live import
- go-live-checklist.md Staging login rehearsal
- ops-runtime.md SMTP (deferred)