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.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
# 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 '<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](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 = '<user-uuid>'
|
||||
AND company_id = '<company-uuid>'
|
||||
AND status = 'active'
|
||||
RETURNING user_id, company_id, role;
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```sql
|
||||
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:
|
||||
|
||||
```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('<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.
|
||||
|
||||
```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 `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](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('<smoke-email>');
|
||||
-- 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)
|
||||
Reference in New Issue
Block a user