# Sprint Planner — Administrator Manual

This guide walks an administrator through the initial configuration and the
day-to-day operation of the Sprint Planner web application. It assumes a
Linux host with Docker and Docker Compose v2 installed.

For architecture, schema, and feature history, see [SPEC.md](../SPEC.md) in
the repository root.

---

## 1. Prerequisites

- A Linux (or macOS / Windows with WSL2) host with:
  - Docker Engine 24+ and the `docker compose` plugin.
  - Outbound HTTPS access during build (Composer + npm pull dependencies).
- One of the following for sign-in:
  - **Microsoft Entra ID tenant** with permission to register an application
    (preferred for production), or
  - **Local admin credentials** (email + password) — useful for initial
    setup, on-prem-only deployments, or development.
- A directory on the host to hold the persistent SQLite database and PHP
  session files. The compose file mounts `./data` next to
  `docker-compose.yml`; nothing else is needed for storage.

---

## 2. Getting the code

```bash
git clone <repository-url> sprint_planer_web
cd sprint_planer_web
```

All commands in the rest of this manual are run from the repository root.

---

## 3. Initial configuration of `.env`

The application reads its configuration from a single `.env` file in the
repository root. Start by copying the template:

```bash
cp .env.example .env
```

Open `.env` in an editor and fill in the values described below. The file
must be readable by the user that runs `docker compose`; on a shared host
restrict it with `chmod 600 .env`.

### 3.1 Entra ID (Microsoft 365) — production sign-in

If your team will sign in with their Microsoft 365 / Entra accounts:

| Variable              | What to put there |
|-----------------------|-------------------|
| `ENTRA_TENANT_ID`     | Directory (tenant) ID — a GUID, visible on the Entra "Overview" page. |
| `ENTRA_CLIENT_ID`     | Application (client) ID of the app registration. |
| `ENTRA_CLIENT_SECRET` | A client secret you generated for the app registration. Treat as sensitive. |

In the Entra app registration, configure:

- **Redirect URI** (Web platform): `{APP_BASE_URL}/auth/callback`,
  e.g. `https://sprint.example.com/auth/callback`.
- **ID tokens** issued from the auth endpoint: enabled.
- **Supported account types**: usually "Accounts in this organisational
  directory only".
- **API permissions**: `openid`, `profile`, `email` (delegated, default for
  OIDC). Grant admin consent if your tenant requires it.

Leave Entra fields blank if you only intend to use the local-admin
fallback. The OIDC sign-in button still appears, but clicking it will fail
until the variables are populated — keep `LOCAL_ADMIN_EMAIL` /
`LOCAL_ADMIN_PASSWORD_HASH` filled if you skip Entra entirely.

### 3.2 Application URL

```
APP_BASE_URL=https://sprint.example.com
```

The base URL the application is reachable at, **without** trailing slash.
This is used to construct the OIDC redirect URI and must match exactly
what is registered in Entra. For local testing the default
`http://localhost:8080` matches the default `HTTP_PORT` (see §3.3), so
no edits are needed unless you have changed one or the other.

### 3.3 Host port

```
HTTP_PORT=8080
```

The host port `docker-compose.yml` publishes for the app (container side
is fixed at Apache port 80). Default `8080`. Pick any free host port; you
do not need to edit `docker-compose.yml`. Whatever you set here must
match the port in `APP_BASE_URL` and the redirect URI registered in
Entra.

### 3.4 Database and session storage paths

```
DB_PATH=/var/www/data/app.sqlite
SESSION_PATH=/var/www/data/sessions
```

Leave the defaults unless you are also remapping the volume. The parent
directory `/var/www/data` is the volume mount point inside the container
and corresponds to `./data/` on the host.

### 3.5 Environment mode

```
APP_ENV=production
```

`production` silences verbose PHP errors. Any other value (e.g. `dev`)
turns them on — useful when troubleshooting in a non-public install.

### 3.6 Reverse proxy and HTTPS

```
TRUSTED_PROXIES=10.0.0.0/8,192.168.0.0/16
```

Set this when the application is exposed through a reverse proxy or load
balancer (Nginx, Traefik, Caddy, an AWS ALB, etc.). Provide a
comma-separated list of CIDRs that contains every hop between the public
internet and the PHP container. Bare addresses without `/n` are treated as
host masks (`/32` for IPv4, `/128` for IPv6).

When the immediate peer matches one of those CIDRs the application will:

- walk `X-Forwarded-For` rightmost-to-leftmost to pick the originating
  client IP — this is what lands in the audit log and the local-admin
  login-throttle bucket (R01-N06 / R01-N07); and
- honour `X-Forwarded-Proto: https` so the session cookie keeps its
  `Secure` flag and the response carries `Strict-Transport-Security`
  even when the proxy → container link is plain HTTP (R01-N05).

Leave the variable blank when the container is exposed directly. With no
trusted proxies configured the application ignores both forwarded headers,
so a hostile client cannot lie its way into a different audit IP or into
HTTPS-only behaviour.

**Proxy contract.** Whichever proxy you put in front of the app must:

1. Forward the original client IP in `X-Forwarded-For` (most reverse
   proxies do this by default, but verify);
2. Forward the original scheme in `X-Forwarded-Proto` — this is the
   single signal the application uses to decide whether the user-facing
   request was over TLS;
3. Be reachable by the PHP container at an address that falls inside one
   of the CIDRs you listed in `TRUSTED_PROXIES`.

If `APP_BASE_URL` is `https://…` and the application detects that the
live request is genuinely over HTTP (either no proxy at all, or a trusted
proxy that explicitly reports `X-Forwarded-Proto: http`), it issues a
permanent redirect to the canonical HTTPS URL. `/healthz` is exempt so
liveness probes keep working over either scheme. The redirect is
suppressed when the proxy is silent about the scheme — there is no safe
way to distinguish "real HTTP request" from "TLS-terminating proxy that
forgot to set the header", and a redirect in the latter case would
infinite-loop.

A typical Nginx snippet:

```nginx
location / {
    proxy_pass         http://sprint_planer:80;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
}
```

### 3.7 Nominating the first administrator (OIDC)

Historically the first user to complete sign-in via *any* path was promoted
to administrator. On a public-facing first deploy that is a land-grab risk
— anyone with a valid Entra account in your tenant could win the race
against the intended operator and lock everyone else out
(see `doc/REVIEW_01.md` finding R01-N03).

The OIDC sign-in path now auto-promotes only when the signing user matches
an explicit env-bootstrap value:

```
BOOTSTRAP_ADMIN_OID=00000000-0000-0000-0000-000000000000
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
```

| Variable                | What to put there |
|-------------------------|-------------------|
| `BOOTSTRAP_ADMIN_OID`   | The Entra `oid` claim (a GUID, immutable for the lifetime of the user) — preferred when known. Visible on the user's "Object ID" line in the Entra portal. |
| `BOOTSTRAP_ADMIN_EMAIL` | The user's primary email. Accepted as a fallback when only the email is on hand. Matched case-insensitively, after trimming. |

Either / both / neither may be set:

- **Both set**: the signing user is promoted on a match against either field.
- **One set**: only that channel matters.
- **Neither set**: the OIDC path will *never* auto-promote. In that case
  bootstrap the first administrator via the local-admin fallback (§3.8) or
  by setting `users.is_admin = 1` directly in `app.sqlite`.

Auto-promotion additionally requires that no administrator already exists
(`countAdmins() === 0`). Once a first admin is in place, subsequent
promotions go through the **Users** page (§5.1). The promotion is recorded
in the audit log as `BOOTSTRAP_ADMIN` with `via=oidc`.

The local-admin fallback (§3.8) is itself an explicit env-bootstrap and
does not require the variables above — its `BOOTSTRAP_ADMIN` audit row is
tagged `via=local`.

### 3.8 Local admin fallback (optional)

```
LOCAL_ADMIN_EMAIL=admin@example.com
LOCAL_ADMIN_PASSWORD_HASH='$2y$10$...'      # output of password_hash()
LOCAL_ADMIN_NAME=Local Admin
```

If both `LOCAL_ADMIN_EMAIL` and `LOCAL_ADMIN_PASSWORD_HASH` are set, a
second sign-in form appears at `/auth/local`. The submitted password is
verified with PHP's `password_verify()` against the stored bcrypt hash —
the plaintext password is never written to disk.

**Generating the hash.** Pick whichever one-liner matches what you have
installed; both prompt interactively so the password isn't kept in shell
history:

```bash
# Host PHP 8 (any minor):
php -r 'echo password_hash(readline("Password: "), PASSWORD_DEFAULT), PHP_EOL;'

# No host PHP — borrow the runtime image:
docker run --rm -it php:8.3-cli php -r \
  'echo password_hash(readline("Password: "), PASSWORD_DEFAULT), PHP_EOL;'
```

Each invocation prints a different `$2y$10$...` string for the same
input — that's the per-hash random salt. Either one verifies.

Paste the result into `.env` between **single quotes** so the shell that
launches `docker compose` doesn't try to expand the `$` segments inside
the hash:

```
LOCAL_ADMIN_PASSWORD_HASH='$2y$10$abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnop'
```

Then:

- Pick a long, unique passphrase you do not reuse anywhere else.
- Make sure `.env` is `chmod 600` and owned by a single trusted user.
- Rotate the passphrase by regenerating the hash, replacing the line in
  `.env`, and restarting the container — active sessions stay valid
  until they expire (the hash is only consulted at sign-in time).

The local-admin user is recorded in the database as
`entra_oid = "local:<email>"` with `is_admin = 1`, and is always promoted
to admin on every successful local sign-in (so a manual demotion in the
Users page is undone on the next local sign-in). When the `users` table
was empty before the sign-in, an audit entry of type `BOOTSTRAP_ADMIN`
with `via=local` is also recorded.

For OIDC-only deployments — where you don't want the local fallback at
all — leave `LOCAL_ADMIN_EMAIL` / `LOCAL_ADMIN_PASSWORD_HASH` blank and
nominate the first admin via the OIDC bootstrap (§3.6).

To disable the fallback later, blank both variables and restart.

---

## 4. Running the containers

The project ships a multi-stage Dockerfile (Node-based Tailwind build →
PHP 8.3 + Apache runtime) and a `docker-compose.yml` that wires the
container to the host.

> **Operators vs. developers.** Everything in this section is the
> production deployment path: the bare `docker compose` commands
> targeting `docker-compose.yml` only. Developers iterating on code
> use a separate `docker-compose.dev.yml` overlay (bind mounts +
> `tailwindcss --watch` sidecar) plus the `appctl` shell wrapper; that
> story lives in `SPEC.md` §11 and is not relevant to operating a
> deployment.

### 4.1 First start

```bash
docker compose up --build
```

What happens:

1. Compose builds the image. The Node stage compiles
   `assets/css/input.css` into `public/assets/css/app.css`. The PHP
   stage installs Composer dependencies and copies the application
   source into `/var/www/html`.
2. The container's entrypoint (`bin/docker-entrypoint.sh`) runs
   `php bin/migrate.php` to create `app.sqlite` inside the volume and
   apply every file in `migrations/` BEFORE Apache binds the port. If
   a migration fails, the container exits non-zero and Apache never
   starts — check `docker compose logs` for the migration's stderr.
3. Apache then starts on port 80, exposed on the host as the port set
   by `HTTP_PORT` in `.env` (default **8080** — see §3.3 / §4.5).

The web request path itself never applies SQL — it only checks that
`schema_version` matches the on-disk migration set and refuses to serve
(503 Service Unavailable, `Retry-After: 30`) when something is pending.
A forgotten deploy step therefore produces a loud, fast-failing 503
instead of silent stale-schema serving (R01-N22).

Open `http://<host>:8080`. If you used the local-admin fallback, sign
in at `/auth/local`. Otherwise click the Entra sign-in CTA on `/`.

### 4.2 Running detached (recommended for production)

```bash
docker compose up -d --build
```

Logs are then read with:

```bash
docker compose logs -f
```

Stopping the stack:

```bash
docker compose down
```

`down` keeps the `./data` volume intact — your database and sessions
survive. To wipe and start over, stop the stack and remove `./data`
manually (see §5.4).

### 4.3 Rebuilding after changes

You only need to rebuild the image when the build inputs change:

- `Dockerfile`, `composer.json` / `composer.lock`, or `package.json` /
  `package-lock.json`
- Tailwind sources under `assets/css/` or any view file that introduces
  new utility classes (the css-builder stage scans the views).

```bash
docker compose build --no-cache && docker compose up -d
```

For pure PHP / view edits without a rebuild, restart the container:

```bash
docker compose restart
```

### 4.4 Health check

The application exposes an unauthenticated `GET /healthz` route that
returns `200 OK` with a small JSON body. Use it from a load balancer or
uptime monitor:

```bash
curl -fsS http://<host>:8080/healthz
```

### 4.5 Changing the host port

Set `HTTP_PORT` in `.env` to any free port on the host:

```
HTTP_PORT=9090
```

`docker-compose.yml` substitutes the variable into the `ports` mapping
(`"${HTTP_PORT:-8080}:80"`); the default `8080` applies when the
variable is unset. After editing, `docker compose up -d` re-creates the
container with the new port — no rebuild required. Update `APP_BASE_URL`
in `.env` and the redirect URI in Entra to match.

---

## 5. Day-to-day administration

### 5.1 Promoting and demoting administrators

Once the bootstrap admin is signed in, additional administrators are
managed through the web UI:

1. Open the hamburger menu → **Users**.
2. Toggle the admin checkbox next to a user and confirm.

Guardrails enforced server-side:

- An administrator cannot demote themselves while signed in (avoids
  locking yourself out).
- The last remaining administrator cannot be demoted (avoids locking
  the org out).

If both guardrails fire at once — i.e. you are the only admin — the
only safe escape is to sign in via the local-admin fallback (if
configured) and promote a second account first.

### 5.1.1 Honouring privacy / right-to-be-forgotten requests (R01-N23)

Users are never deleted — the `users.id` column is a foreign key for
audit rows and dropping it would break the trail. To honour an
erasure-of-personal-data request, mark the user as a *former user*
("tombstone"):

1. Open **Users**.
2. Click **Mark as former user** under the user's row.
3. The live UI now shows `(former user)` in place of their email and
   display name, both on this page and anywhere else the application
   surfaces a user object. The row keeps its position so audit-log
   joins and the admin counter still work.

Important details:

- **Tombstoning forces `is_admin = 0`** — a tombstoned admin is
  nonsensical (the UI hides their identity). The same demotion guard
  rails apply: you cannot tombstone yourself, and you cannot tombstone
  the last remaining administrator.
- **The audit log is unchanged.** `audit_log.user_email` still carries
  the original address verbatim. Erasure regulations under GDPR /
  similar frameworks typically permit retention of audit data for
  legal and security purposes — the Sprint Planner takes that
  position. If your jurisdiction requires audit-row redaction too,
  you must edit `app.sqlite` manually and document the redaction
  procedure separately.
- **A subsequent successful sign-in clears the tombstone** (OIDC or
  local-admin). The marker is a *display* redaction, not an access
  control. If access must be revoked, do that in the OIDC tenant
  (group / app permission) — the Sprint Planner has no such mechanism
  of its own.
- **Restore** is reversible from the same row (via the **Restore**
  button). It clears `tombstoned_at` but does NOT auto-promote the
  user; if they need admin again, set the admin checkbox separately.

The audit log records both transitions as `TOMBSTONE` and `RESTORE`
on `entity_type = user`, so the privacy operation itself is itself
auditable.

### 5.2 Backups

Everything that needs a backup lives under `./data/`:

- `app.sqlite` — the database, including audit log.
- `sessions/` — active PHP session files. Losing these only forces
  re-login, no data loss.

A safe nightly backup is a straight file copy while the container is
running. SQLite handles concurrent reads fine; for a guaranteed
consistent snapshot, prefer `sqlite3` `.backup`:

```bash
docker compose exec app sqlite3 /var/www/data/app.sqlite \
  ".backup /var/www/data/app.sqlite.bak"
cp ./data/app.sqlite.bak /your/offsite/backup/location/
```

### 5.3 Inspecting the audit log

Every create / update / delete on a domain entity is recorded, plus
sign-in events. Admins can browse and filter the log via the hamburger
menu → **Audit log**, with filters by entity type, action, user, and
date range, and collapsible JSON diffs per row.

### 5.4 Resetting the application

To wipe all data and start from a blank slate:

```bash
docker compose down
rm -rf ./data
docker compose up -d
```

Migrations run on the next container start (entrypoint), before
Apache binds the port. The first administrator is re-seeded as
described in §3.6 (OIDC bootstrap) or §3.7 (local-admin fallback) —
there is no longer an unconditional "first sign-in becomes admin" path.

### 5.5 Updating to a new release

```bash
git pull
docker compose build --no-cache
docker compose up -d
```

Schema migrations under `migrations/` run from the entrypoint as the
container starts (R01-N22) — `bin/docker-entrypoint.sh` calls
`php bin/migrate.php` before `apache2-foreground`. Apache only binds
the port if migrations succeed. Always take a backup of
`./data/app.sqlite` before pulling.

**Running migrations outside Docker.** If you deploy the code without
the container (e.g. a bare PHP-FPM host), apply migrations manually as
part of the release procedure, BEFORE restarting the web server:

```bash
php bin/migrate.php
# → migrate: schema already current (version N)
# → or: migrate: applied K migration(s):  …  schema now at version N
```

If a deploy ever skips this step, the request path will return
`503 Service Unavailable` with `Retry-After: 30` until the operator
runs `bin/migrate.php`. The application server log carries a single
line listing the pending filenames. The 503 is intentional: the
alternative — auto-migrating on a request that may then crash mid-DDL
— is the failure mode this design rules out.

**Composer dependency cadence (R01-N16).** The XLSX import wizard is
backed by [PhpSpreadsheet](https://github.com/PHPOffice/PhpSpreadsheet),
which has a long history of XML-related advisories. The `composer.json`
caret range (`^3.4`) lets minor upgrades land on each `docker compose
build --no-cache`, but the operator is responsible for rebuilding
promptly when a new release ships. Recommended cadence: rebuild after
any `git pull`, and at minimum monthly even on a quiet branch. Run the
auditor to surface known CVEs in the currently locked versions:

```bash
./bin/audit.sh
# → "No security vulnerability advisories found." when clean.
# → exit 1 + a vulnerability table when an advisory matches.
```

The script wraps `composer audit --locked` inside the runtime image so
the audit reflects the exact dependency tree the live container runs.
A weekly cron is a low-friction option:

```cron
# /etc/cron.d/sprint-planner-audit
0 7 * * 1  www-data  cd /opt/sprint-planer-web && ./bin/audit.sh \
                       || mail -s 'sprint-planer composer advisory' admin@example.com
```

### 5.6 Tabbed sign-in note (R01-N17)

The OIDC handshake stores its `state` and PKCE `code_verifier` in the
PHP session under fixed keys. If you start a sign-in flow in two
browser tabs at the same time, the second tab overwrites the first
tab's state — when you finish the first tab, the callback rejects the
state mismatch and you are bounced back to `/?auth_error=1`. This is
not a security issue (the rejection is the correct OIDC behaviour),
but it can be confusing. **Complete one sign-in at a time**, or close
the older tab before starting a fresh login.

### 5.7 Session-file garbage collection (R01-N27)

PHP's built-in session GC is probabilistic — it only fires on request
traffic with a small probability per request. On a quiet deployment the
session directory accumulates stale files for days past the configured
`gc_maxlifetime` (8h). The container's entrypoint compensates by
launching a backgrounded `find … -mmin +480 -delete` sweep every hour:

```bash
# bin/docker-entrypoint.sh — see source for the full loop.
find "${SESSION_PATH}" -mindepth 1 -type f -mmin +"${SESSION_GC_MAX_AGE_MINUTES}" -delete
```

Two env vars override the defaults:

| Variable                       | Default | Meaning                                            |
|--------------------------------|--------:|----------------------------------------------------|
| `SESSION_GC_MAX_AGE_MINUTES`   | `480`   | Delete session files older than N minutes (= 8h). |
| `SESSION_GC_INTERVAL_SECONDS`  | `3600`  | Sweep every N seconds (= once per hour).          |

The loop is a child of PID 1 inside the container, so `docker stop`
propagates and reaps it together with Apache. No additional packages
are installed — `find` ships with the `php:8.3-apache` base image.

If you deploy outside Docker (bare PHP-FPM host), wire an equivalent
sweep yourself, e.g. a system cron entry:

```cron
17 * * * * www-data find /var/www/data/sessions -mindepth 1 -type f -mmin +480 -delete
```

---

## 6. Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `/auth/login` returns "redirect URI mismatch" | The redirect URI registered in Entra does not equal `{APP_BASE_URL}/auth/callback`. | Update either `APP_BASE_URL` in `.env` or the Entra app registration. |
| `/auth/local` returns 404 | `LOCAL_ADMIN_EMAIL` or `LOCAL_ADMIN_PASSWORD_HASH` is blank. | Set both (see §3.7 for the hash recipe), then `docker compose restart`. |
| Sign-in succeeds but every page shows "not authorised" | The user has no `is_admin` flag and is trying to access an admin-only page. | Promote them via Users (logged in as another admin). |
| Container restarts in a loop | Most often a malformed `.env` line or a permission problem on `./data`. | `docker compose logs` will show the PHP fatal. Check that `./data` is writable by the `www-data` user inside the container (uid 33 on the Apache image). |
| CSS looks broken / unstyled | The Node build stage was skipped or used a stale layer. | `docker compose build --no-cache` and restart. |
| OIDC works but PHP 8.4 deprecation warnings appear in logs | Known upstream issue in `jumbojett/openid-connect-php`. | Harmless on PHP 8.3 (the shipped runtime); ignore until upstream releases a fix. |

For anything else, check the audit log first — most user-facing errors
also leave a trail there with the exact request that failed.

---

## 7. Where to go next

- [SPEC.md](../SPEC.md) — full specification, schema, route list, and
  build phase history. Read this if you intend to modify the code.
- [ACCEPTANCE.md](../ACCEPTANCE.md) — manual acceptance checklist used
  to validate releases.
