# UJ-001 — First-run install claims the site and creates the only admin

```yaml
id: UJ-001
title: First-run install claims the site and creates the only admin
actors: [anonymous-operator]
surface: install-wizard
goal: Operator completes first-run setup once, the site becomes configured, and the operator lands in the admin as the only admin.
trigger: Activate Finish setup on /install.
fixtures: [fresh-unconfigured-postgres-branch]
success_state:
  visible: Admin content list renders with document title “Content · mod-cms admin”.
  durable: auth_users holds exactly one row with roles ["admin"] and status active; site_settings key site_configured holds true.
  persistence: Fresh GET /api/admin/me returns 200 with roles ["admin"] for the created admin.
source_specs: [apps/mod-cms/src/pages/install.astro, apps/mod-cms/src/pages/api/install.ts, apps/mod-cms/src/lib/install.ts, apps/mod-cms/src/lib/boot-gate.ts, apps/mod-cms/src/pages/admin/index.astro, apps/mod-cms/src/pages/api/admin/me.ts, apps/mod-cms/e2e/install-appearance.spec.ts, packages/auth/src/engine-custom/index.ts]
readiness: blocked
canonical: false
blockers: [SP-001, SP-002]
```

## Path

| Hop | Proven contract | Evidence |
|---|---|---|
| UI trigger | Final wizard step renders the control labelled `Finish setup` at `/install`. | `apps/mod-cms/src/pages/install.astro:1077` |
| Request construction | Client posts a JSON payload carrying the csrf token embedded in the form to `/api/install`. | `apps/mod-cms/src/pages/install.astro:1097`, `apps/mod-cms/src/pages/install.astro:1100` |
| Endpoint auth and parse | Endpoint parses JSON, bounds every field, then verifies the double-submit token constant-time and the request origin before touching the database. | `apps/mod-cms/src/pages/api/install.ts:38`, `apps/mod-cms/src/pages/api/install.ts:61`, `apps/mod-cms/src/pages/api/install.ts:64` |
| Authoritative write | One atomic lease statement picks a single winner; the winner inserts the admin row, then flips the lease to the permanent live marker. | `apps/mod-cms/src/lib/install.ts:159`, `packages/auth/src/engine-custom/index.ts:297`, `apps/mod-cms/src/lib/install.ts:173` |
| Response | Winner receives `303` with `Location=/admin`; a lost claim receives `409`. | `apps/mod-cms/src/pages/api/install.ts:126`, `apps/mod-cms/src/pages/api/install.ts:77` |
| Terminal render | `/admin` renders the admin content list under document title “Content · mod-cms admin”. | `apps/mod-cms/src/pages/admin/index.astro:22` |

## Happy path

### H1
- Setup: `fresh-unconfigured-postgres-branch` has no `auth_users` row and no `site_configured` row; the operator has the wizard open with a fresh `install_csrf` cookie.
- Action: Operator activates Finish setup on the final wizard step.
- Request: `POST /api/install`.
- Response: `303` with empty body and `Location=/admin`.
- Visible: Admin content list renders with document title “Content · mod-cms admin”.
- Durable: `auth_users` holds exactly one row, with the submitted email, roles `["admin"]`, and status `active`; `site_settings` key `site_configured` holds `true`.
- Fresh read: New authenticated request sends `GET /api/admin/me` and receives `200` with roles `["admin"]` for the created admin.
- Forbidden: No second `auth_users` row is inserted, no non-admin role is granted, and the submitted password is never stored in plaintext.
- Evidence:
  - Setup: `apps/mod-cms/e2e/install-appearance.spec.ts:5`
  - Action: `apps/mod-cms/src/pages/install.astro:1077`
  - Request: `apps/mod-cms/src/pages/install.astro:1100`
  - Response: `apps/mod-cms/src/pages/api/install.ts:126`
  - Visible: `apps/mod-cms/src/pages/admin/index.astro:22`
  - Durable: `packages/auth/src/engine-custom/index.ts:297`, `apps/mod-cms/src/lib/install.ts:173`
  - Fresh read: `apps/mod-cms/src/pages/api/admin/me.ts:17`
  - Forbidden: `packages/auth/src/engine-custom/index.ts:295`

## Alternate and failure paths

### A1
- Setup: `fresh-unconfigured-postgres-branch` has no `auth_users` row; the operator enters a 1025-character admin password, which passes the client's 12-character floor.
- Action: Operator activates Finish setup on the final wizard step.
- Request: `POST /api/install`.
- Response: `400` with `{"error":{"code":"validation_error","message":"Password is too long."}}`.
- Visible: “Password is too long.” renders in the form error region and the wizard stays on the final step.
- Durable: `auth_users` holds no row; `site_settings` holds no `site_configured` row.
- Fresh read: New anonymous browser context sends `GET /` and receives `302` with `Location=/install`.
- Forbidden: No admin row is inserted, no lease is claimed, and the password never reaches the hash function.
- Evidence:
  - Setup: `apps/mod-cms/src/pages/install.astro:1019`
  - Action: `apps/mod-cms/src/pages/install.astro:1077`
  - Request: `apps/mod-cms/src/pages/install.astro:1100`
  - Response: `apps/mod-cms/src/pages/api/install.ts:56`
  - Visible: `apps/mod-cms/src/pages/install.astro:1117`
  - Durable: `apps/mod-cms/src/pages/api/install.ts:52`
  - Fresh read: `apps/mod-cms/src/lib/boot-gate.ts:29`
  - Forbidden: `apps/mod-cms/src/pages/api/install.ts:56`

### A2
- Setup: `fresh-unconfigured-postgres-branch` has no `auth_users` row; the wizard has been open past the 900-second `install_csrf` cookie lifetime, so the browser sends no cookie token.
- Action: Operator activates Finish setup on the final wizard step.
- Request: `POST /api/install`.
- Response: `403` with `{"error":{"code":"forbidden","message":"Invalid request."}}`.
- Visible: “Invalid request.” renders in the form error region and the wizard stays on the final step.
- Durable: `auth_users` holds no row; `site_settings` holds no `site_configured` row.
- Fresh read: New anonymous browser context sends `GET /` and receives `302` with `Location=/install`.
- Forbidden: No admin row is inserted, no lease is claimed, and no site setting is written.
- Evidence:
  - Setup: `apps/mod-cms/src/pages/install.astro:26`
  - Action: `apps/mod-cms/src/pages/install.astro:1077`
  - Request: `apps/mod-cms/src/pages/install.astro:1100`
  - Response: `apps/mod-cms/src/pages/api/install.ts:62`
  - Visible: `apps/mod-cms/src/pages/install.astro:1117`
  - Durable: `apps/mod-cms/src/pages/api/install.ts:61`
  - Fresh read: `apps/mod-cms/src/lib/boot-gate.ts:29`
  - Forbidden: `apps/mod-cms/src/pages/api/install.ts:62`

### A3
- Setup: The branch is already installed by a prior H1 run, so `site_settings` key `site_configured` holds `true` and one admin row exists; the operator replays the wizard with a fresh `install_csrf` cookie and a second email.
- Action: Operator activates Finish setup on the final wizard step.
- Request: `POST /api/install`.
- Response: `409` with `{"error":{"code":"install_in_progress"}}`; the body's message field carries the literal rendered in Visible.
- Visible: “Setup is already in progress or complete.” renders in the form error region and the wizard stays on the final step.
- Durable: `auth_users` still holds exactly the one admin row from the first install; `site_settings` key `site_configured` still holds `true`.
- Fresh read: New anonymous browser context sends `GET /` and receives `200` with the first install's site title rendered.
- Forbidden: No second admin row is inserted, the second email gains no access, and the first install's settings are not overwritten.
- Evidence:
  - Setup: `apps/mod-cms/src/lib/install.ts:173`
  - Action: `apps/mod-cms/src/pages/install.astro:1077`
  - Request: `apps/mod-cms/src/pages/install.astro:1100`
  - Response: `apps/mod-cms/src/pages/api/install.ts:77`
  - Visible: `apps/mod-cms/src/pages/install.astro:1117`
  - Durable: `apps/mod-cms/src/lib/install.ts:164`
  - Fresh read: `apps/mod-cms/src/lib/install.ts:130`
  - Forbidden: `apps/mod-cms/src/lib/install.ts:164`

### A4
- Setup: UNKNOWN — SP-002: no concurrency barrier seam exists to hold two `claimInstall` racers at the lease statement.
- Action: Second operator activates Finish setup while a first install is still in flight.
- Request: `POST /api/install`.
- Response: `409` with `{"error":{"code":"install_in_progress"}}`; the body's message field carries the literal rendered in Visible.
- Visible: “Setup is already in progress or complete.” renders in the form error region for the losing racer.
- Durable: `auth_users` holds exactly one admin row, belonging to the winning racer's email.
- Fresh read: New anonymous browser context sends `GET /` and receives `200` with the winning racer's site title rendered.
- Forbidden: The losing racer's email gains no admin row, no access, and no site setting.
- Evidence:
  - Setup: UNKNOWN — SP-002: no concurrency barrier seam exists to hold two `claimInstall` racers at the lease statement.
  - Action: `apps/mod-cms/src/pages/install.astro:1077`
  - Request: `apps/mod-cms/src/pages/install.astro:1100`
  - Response: `apps/mod-cms/src/pages/api/install.ts:77`
  - Visible: `apps/mod-cms/src/pages/install.astro:1117`
  - Durable: `apps/mod-cms/src/lib/install.ts:164`
  - Fresh read: `apps/mod-cms/src/lib/install.ts:130`
  - Forbidden: `apps/mod-cms/src/lib/install.ts:168`

### A5
- Setup: UNKNOWN — SP-001: no post-commit response-loss fault seam exists in the mod-cms e2e harness.
- Action: Operator activates Finish setup on the final wizard step.
- Request: `POST /api/install`.
- Response: N/A — transport lost the response after the authoritative commit.
- Visible: “Setup could not be completed. Check your connection and try again.” renders in the form error region and the wizard stays on the final step.
- Durable: `auth_users` holds exactly one row with roles `["admin"]`; `site_settings` key `site_configured` holds `true`.
- Fresh read: New anonymous browser context sends `GET /` and receives `200` with the submitted site title rendered.
- Forbidden: No second admin row is inserted on retry, and no success confirmation renders while the site is already live.
- Evidence:
  - Setup: UNKNOWN — SP-001: no post-commit response-loss fault seam exists in the mod-cms e2e harness.
  - Action: `apps/mod-cms/src/pages/install.astro:1077`
  - Request: `apps/mod-cms/src/pages/install.astro:1100`
  - Response: `apps/mod-cms/src/pages/install.astro:1121`
  - Visible: `apps/mod-cms/src/pages/install.astro:1122`
  - Durable: `apps/mod-cms/src/lib/install.ts:173`
  - Fresh read: `apps/mod-cms/src/lib/install.ts:130`
  - Forbidden: `apps/mod-cms/src/pages/api/install.ts:76`

## Permissions and boundaries

N/A — the install endpoint runs before any account exists and authenticates no actor; its only authority boundary is the single-use claim lease, whose denial outcome is covered by A3 and A4.

## Source specs

- `apps/mod-cms/src/pages/install.astro`
- `apps/mod-cms/src/pages/api/install.ts`
- `apps/mod-cms/src/lib/install.ts`
- `apps/mod-cms/src/lib/boot-gate.ts`
- `apps/mod-cms/src/pages/admin/index.astro`
- `apps/mod-cms/src/pages/api/admin/me.ts`
- `apps/mod-cms/e2e/install-appearance.spec.ts`
- `packages/auth/src/engine-custom/index.ts`
