# Runbook

> Attempt #2's only setup instructions lived in a plan file describing an architecture
> that had already been deleted, so they were actively wrong. Keep this file honest:
> if a command here doesn't work, fix it or delete it.

## Status

The app boots and serves. Everything marked **(#2)** is carried from the previous
attempt and has **not** been re-verified against Topcoat — treat it as a sketch.

## Requirements

- **rustc ≥ 1.95** — Topcoat 0.5 requires it, and on an older toolchain `cargo add
  topcoat` silently resolves to an empty `topcoat v0.0.0` placeholder rather than
  failing. Verified on 1.97.1.
- `git` on `PATH` (from Milestone 3 — `git init --bare` creates repos, and from
  Milestone 4 `git http-backend` serves the protocol). **`cargo test` needs it too**:
  `DiskGitStorage`'s tests run real `git init`, so a machine without `git` fails the
  suite, not just the app.

## Dev setup

```bash
cargo run                     # serves on http://127.0.0.1:3000
cargo test
```

```bash
cargo install topcoat-cli     # dev server: watch, rebuild, asset bundling
topcoat dev                   # working
```

`topcoat dev` builds, bundles assets, watches sources, and live-reloads pages that
include `topcoat::dev::script()`. Press `r` to force a rebuild.

## Configuration

`STEID_`-prefixed env vars via `dotenvy` + `envy`, read into `AppConfig`. Every value
has a default, so a bare `cargo run` works with no environment at all.

Live now:

```
STEID_DATABASE_URL=sqlite:steid.db?mode=rwc   # default
STEID_DATA_DIR=./data                         # default; bare repos, read from M3
STEID_INSECURE_COOKIES=false                  # default; see below
```

There is deliberately **no owner password in configuration** — the owner is created
through the claim flow instead. See
[0002](decisions/0002-first-run-claim-not-config-bootstrap.md).

The bind address is **not** a `STEID_` variable — Topcoat owns it:

```bash
HOST=0.0.0.0 PORT=8080 cargo run
```

That supersedes attempt #2's `STEID_LISTEN_ADDR`.

### `STEID_INSECURE_COOKIES` — development only

Topcoat's session cookie is `__Host-` prefixed and `Secure`. `Secure` means the browser
only keeps it over a trustworthy origin, and browsers disagree about whether
plain-HTTP `localhost` qualifies. Where it doesn't, **the failure is completely
silent**: the server issues a session and records the row, the browser discards the
cookie, and every page renders signed out with no error anywhere. This cost an
afternoon; the symptom looks exactly like broken auth logic.

Setting `STEID_INSECURE_COOKIES=true` swaps in `InsecureCookieTokenStore` — the same
cookie without `Secure` and without the prefix, named `steid-dev-session` so it can
never be confused with a hardened one. `HttpOnly` and `SameSite=Lax` are kept. Boot
prints a warning while it's on.

**Never set this on a deployed instance.** Without `Secure` the session cookie travels
unencrypted and anyone on the network path can lift it and become that user. Behind
TLS, leave it unset.

A gitignored `.env` in the repo root sets it for local work. Keep `.env` and
`.env.prod` out of git — both are gitignored.

## First run

```bash
cargo run          # or: topcoat dev
```

An unclaimed instance prints a setup token and redirects every route to `/auth/setup`.
Paste the token, choose a handle, email, and password, and the owner is created and
signed in.

The token is **held in memory only**, so every restart mints a new one — including
each rebuild under `topcoat dev`. Use the most recent one printed. Once claimed, no
token is minted at all and `/auth/setup` redirects away.

Sign in at `/auth/login` with the **email**, not the handle.

## Repo layout on disk (Milestone 3)

Bare repos at `{STEID_DATA_DIR}/{handle}/{name}.git`, created with no template (so no
`.sample` hooks) and `HEAD` pinned to `refs/heads/main` regardless of the host's
`init.defaultBranch`. See [0006](decisions/0006-git-binary-behind-narrow-ports.md).

Created empty — no initial commit and no branch, like GitHub.

Creating one refuses rather than reusing a directory that already exists, so an orphan
left by a create that died mid-way blocks that name until it is removed by hand.

## Git transport (Milestone 4)

Smart HTTP, delegated to `git http-backend`, authenticated with personal access tokens
over HTTP Basic — see [0001](decisions/0001-git-over-http-not-ssh.md). No SSH, no host
keys, no `authorized_keys`.

```bash
git clone http://host/{handle}/repos/{name}.git
```

Fill in the token workflow and the `body_limit` setting once this is built.

## Deployment (container)

A `Dockerfile` at the repo root builds a self-contained image. Two stages: `rust:1.97-bookworm`
compiles and bundles, `debian:bookworm-slim` runs. ~206 MB.

```bash
docker build -t steid .
docker volume create steid-data
docker run -d --name steid -p 3000:3000 -v steid-data:/data steid
docker logs steid          # the setup token is here, and only here
```

Verified end to end on 2026-08-29: the image builds, boots, applies migrations,
creates `/data/steid.db`, serves `/auth/setup` with its stylesheet, and `git init
--bare` succeeds inside `/data/repos` as the non-root user.

### The build needs `topcoat asset bundle`, not just `cargo build`

`cargo build --release` alone produces a binary that **fails to boot**. `main` calls
`AssetBundle::load()`, which walks up from the executable looking for
`assets/manifest.toml`; without one it returns `NotFound` and the process exits before
serving anything. `build.rs` does not write that bundle — it only runs Tailwind and
stages icons into `OUT_DIR`, where they are embedded in the binary.

The bundle comes from the CLI, which the builder stage installs:

```bash
cargo install topcoat-cli --version 0.5.0 --locked
topcoat asset bundle --release      # runs `cargo build --release` itself, then bundles
```

It writes `target/assets/`, which must be copied **next to the binary** in the runtime
image — `/app/steid` finds `/app/assets`. This is the same step `topcoat dev` performs
for you, and the reason a hand-built binary serves stale CSS.

Two build-time consequences worth knowing:

- The build **needs network access**: `build.rs` downloads the standalone Tailwind CLI
  from GitHub releases, and the bundler downloads any remote asset.
- The image is **Debian, not Alpine, on both sides**. Those Tailwind binaries are
  glibc-linked, so a musl builder fails during `cargo build`.

The builder mounts the cargo registry and `target/` as BuildKit caches. There is
deliberately **no dummy-`main.rs` dependency-caching trick**: `build.rs` scans the real
sources for Tailwind classes, and a faked source tree yields a stale stylesheet — a
wrong answer that still builds, which is the worst kind.

### Configuration in a container

The image sets these defaults, so the `docker run` above needs no `-e` flags at all:

```
STEID_DATABASE_URL=sqlite:/data/steid.db?mode=rwc
STEID_DATA_DIR=/data/repos
HOST=0.0.0.0                  # Topcoat's, not STEID_-prefixed — see Configuration above
PORT=3000
```

No public URL is configured anywhere: the origin is derived from the `Host` header and
`X-Forwarded-Proto`, so a proxy that forwards both needs nothing further.

**`STEID_INSECURE_COOKIES` is deliberately unset in the image and must stay unset.**
The session cookie is `Secure`, which means the deployment needs TLS — assume a
terminating proxy in front (Caddy, nginx, a platform router). Setting the variable to
paper over a missing certificate hands every session cookie to anyone on the network
path. `.dockerignore` excludes `.env` for the same reason: the dev `.env` sets it, and
copying it in would silently unharden a deployed image.

### State is one volume

Everything that must survive a restart lives under `/data`: the SQLite database as a
file directly in it, the bare repositories under `/data/repos`. `VOLUME ["/data"]` is
declared, so a container started without `-v` still keeps its state — in an anonymous
volume that is easy to lose track of. Name it.

`/data` itself must be writable, not just the database file: SQLite creates `-wal` and
`-shm` siblings next to it.

The container runs as uid **10001** (`steid`). A named volume inherits that ownership
from the image on first use. A **bind mount does not** — `-v /srv/steid:/data` starts
root-owned and the app fails to write, so `chown 10001:10001 /srv/steid` on the host
first.

### Claiming a deployed instance

The setup token is printed to **stdout only, and only while the instance is
unclaimed**. It is held in memory, so every restart — including every redeploy —
mints a new one, and a claimed instance mints none at all.

There is no way to recover it other than the platform's logs:

```bash
docker logs steid | tail -20
```

Read the token from the **most recent** boot, then claim at `https://your-host/auth/setup`.
Until it is claimed every route redirects there, so an instance left unclaimed on a
public address is an open door — claim it immediately after the first deploy.

## Manual verification checklist (#2)

Attempt #2 verified these by hand each milestone but never wrote down the steps. They
are the smoke test for Milestones 4–5. The auth rows assumed SSH keys; the shape of
the check still holds with tokens substituted:

- [x] Create a repo via the web UI → bare repo appears at
      `{data_dir}/{handle}/{name}.git` *(Milestone 3; also checked that the name
      normalises, that a duplicate re-renders the form, and that a private repo 404s
      for a signed-out visitor)*
- [ ] `git clone` an empty repo → succeeds
- [ ] `git clone` a repo with history → succeeds
- [ ] `git clone` a non-existent repo → clean error, not a hang or panic
- [ ] First push to an empty repo → succeeds
- [ ] Push to a repo with history → succeeds
- [ ] Push a repo large enough to exercise the `body_limit` cap → succeeds
- [ ] Clone with no credentials → rejected, and the prompt is comprehensible
- [ ] Clone with a valid token → succeeds
- [ ] Revoke the token → subsequent clone rejected at auth
- [ ] Clone a private repo as a non-member → rejected
- [ ] Push as a non-owner member → rejected

Worth automating as an integration test rather than re-running by hand a fourth time.

## `.gitignore`

Applied: `/target`, `/data`, `*.db*`, `.env`, `.env.prod`.

**Do not add `/plans`.** Attempt #2 did, and that is why these docs had to be
hand-carried between repos.

## Deploying this instance

The operator's path, as opposed to `README.md`, which is written for a stranger
installing their own. `jpgill.dev` serves two roles from one box: this instance,
and the place everyone else downloads Steid from.

### Order matters

1. **Provision — AWS Lightsail**, **Debian 13** blueprint (12 also works). Lightsail rather than EC2
   deliberately: it *is* AWS's VPS product, where EC2 makes you assemble the same box out
   of a VPC, security groups, an EBS volume and an Elastic IP, with per-GB egress on top.
   Either architecture works — releases are built for `x86_64` and `aarch64`, and
   `install.sh` picks between them from `uname -m`, so a Graviton instance is fine.

   **Not Amazon Linux**, which Lightsail pre-selects: `install.sh` is Debian-family, and
   it stops with a clear message on anything without `apt-get` rather than half-installing.
   Ubuntu 22.04/24.04 work too. Debian 13 and 12 are both verified end to end in a
   container — including that Caddy installs, since its apt repository URL is
   codename-independent and that was the only plausible difference between them.

   Three Lightsail-specific things, each of which breaks the deployment silently if
   missed:

   - **Attach a static IP.** A Lightsail instance takes a *new* public IP on stop/start.
     Without one, a reboot changes the address, DNS points at nothing, and Caddy's
     certificate renewals start failing with no obvious cause. Free while attached to a
     running instance.
   - **Open 80 and 443** in the instance's Networking tab. Only 22 is open by default,
     and **Let's Encrypt validates over port 80** — a firewall that allows 443 alone
     fails at certificate issuance, not at first request.
   - **Snapshots are not backups.** They are whole-disk and live in the same AWS account.
     The two state paths still want copying somewhere else.
2. **DNS first, then install.** `dig +short jpgill.dev` must return the box's IP
   *before* `install.sh` runs. Caddy requests a certificate on startup; if DNS has not
   propagated it fails and backs off, and the resulting error points nowhere useful.
3. **First install uses `--tarball`.** There is a bootstrap: this instance is what will
   serve the releases, so at that moment there is nowhere to download from.

```sh
scp dist/steid-0.1.0-x86_64-unknown-linux-gnu.tar.gz install.sh root@<ip>:/root/
ssh root@<ip> './install.sh --domain jpgill.dev \
    --tarball ./steid-0.1.0-x86_64-unknown-linux-gnu.tar.gz'
```

Then `curl https://jpgill.dev/healthz` — over https, with a real certificate.

### Becoming the distribution host

Only this instance does this. Uncomment the two `handle` blocks in
[deploy/Caddyfile](../deploy/Caddyfile) and rsync the artefacts into a **versioned**
directory, matching the URL `install.sh` builds
(`${RELEASE_BASE_URL}/v${VERSION}/…`):

```sh
rsync dist/*.tar.gz dist/*.sha256 root@<ip>:/var/lib/steid/dist/v0.1.0/
rsync install.sh root@<ip>:/var/lib/steid/dist/
```

**Those Caddy paths shadow Steid.** Nothing lives at
`/{handle}/repos/{name}/releases` today, so nothing breaks — but when Steid grows a real
release feature at that URL, Caddy will keep winning and the feature will look broken.
Delete the blocks then. The URL is deliberately the one that feature will use, so links
published now survive it.

`/install.sh` at the root is safe permanently rather than by luck: `OrgName` allows only
`[a-z0-9-]`, so no handle can contain a dot and none can ever collide with it. A
root-level `/releases` would **not** be safe — it is a valid handle shape.

### What is verified, and what is not

Verified in a Debian 12 container with `systemctl` stubbed: prerequisites install, the
tarball extracts, `/opt/steid` and `/var/lib/steid` get the right owners and modes, the
`steid` user is created with `nologin`, the env file and unit and Caddyfile are written,
and **the binary starts as the `steid` user**. The artifact itself was booted on Debian
11 (glibc 2.31) and served pages.

**Not verified anywhere but a real box:** the systemd unit lifecycle, and Caddy's ACME
certificate issuance. Expect the first real run to need a fix or two.

### One bug already found this way

`install.sh` defaulted to a `musl` target while `release.sh` had moved to `gnu`. The
symptom was `checksum mismatch` — because the installer was looking for a tarball that
was never built. The two defaults must agree; both now say so in a comment.
