intent/docs/operations.md
Alec Perkins 604c2bbf91
Some checks failed
ci / test (push) Failing after 1m6s
ci / deploy (push) Failing after 0s
Harden for launch: limits, CSP, moderation, admin 2FA (Phase 5)
- Rate limits in the shared cache for comments, issues, uploads and
  invites; a limited HTMX reply keeps the draft.
- Content-Security-Policy (self only, no inline script or style, bucket
  origin for images and frames) and Permissions-Policy on every page.
  htmx configured not to inject styles or eval.
- Moderation: deactivating a user ends their sessions and keeps their
  content; deleted accounts show as "ghost"; reserved usernames; the
  admin won't delete an org's last owner.
- Admin requires a TOTP code (django-otp), with add_admin_totp to enrol.
- Email provider chosen by environment through django-anymail.
- Playwright browser tests: axe WCAG 2.1 AA on every page in light and
  dark, no CSP violations, keyboard walkthrough, theme toggle. Explicit
  Canvas/CanvasText on :root fixes dark-mode contrast detection.
- bin/start creates the data dir if missing; docs/operations.md runbook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:51:06 -04:00

129 lines
6.4 KiB
Markdown

# Operating Intent
How to deploy, configure and look after a production Intent. For what the app does, see
[concept.md](concept.md); for how it's built, [plan.md](plan.md).
## Shape
One Fly.io machine with a volume at `/data` holding the SQLite database. Litestream streams
the database to a Backblaze B2 bucket continuously. Uploaded files go to a private Tigris
bucket. `bin/start` runs as root only long enough to take ownership of the volume, then as
the `app` user it:
1. restores the database from B2 if the volume has none (a fresh volume),
2. runs migrations and creates the cache table,
3. starts gunicorn under `litestream replicate`.
## Secrets and settings
Set with `fly secrets set NAME=value`. Non-secret settings live in `fly.toml`.
| Name | Required | Purpose |
|---|---|---|
| `DJANGO_SECRET_KEY` | yes | Signs sessions and tokens. Long and random. |
| `DJANGO_ALLOWED_HOSTS` | yes (`fly.toml`) | Comma-separated hostnames. Also sets the trusted CSRF origins. |
| `EMAIL_BACKEND` | yes | Production refuses to start with the console backend. See Email. |
| `ANYMAIL_*` | with Anymail | The provider's settings, e.g. `ANYMAIL_POSTMARK_SERVER_TOKEN`. |
| `DEFAULT_FROM_EMAIL` | yes | e.g. `Intent <intent@example.org>`. Must be a sender the provider allows. |
| `LITESTREAM_BUCKET`, `LITESTREAM_ENDPOINT`, `LITESTREAM_ACCESS_KEY_ID`, `LITESTREAM_SECRET_ACCESS_KEY` | yes | B2 backup bucket and an application key limited to it. |
| `BUCKET_NAME`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_ENDPOINT_URL_S3`, `AWS_REGION` | yes | Tigris uploads. `fly storage create` sets these. |
| `CSP_BUCKET_ORIGIN` | no | Only if signed URLs aren't `https://<bucket>.fly.storage.tigris.dev`. |
| `SENTRY_DSN` | no | Error reporting. |
## First deploy
1. Replace the `CHANGEME` values in `fly.toml` (app name, region, host).
2. `fly launch --no-deploy`, then `fly volumes create intent_data --size 1`.
3. `fly storage create` for the Tigris bucket.
4. Set the secrets above.
5. `fly deploy`. Check `fly logs` for "running without backups" (Litestream not configured) or a restore message.
6. `fly ssh console`, then create the first administrator (next section).
## Administrators
Administrators are separate from regular users: they sign in only at `/admin/`, with a
password and a code from an authenticator app, and can't join organizations.
```sh
fly ssh console
cd /app
python manage.py createsuperuser
python manage.py add_admin_totp <username>
```
The second command prints an `otpauth://` link. Add it to an authenticator app (most can scan
it if you paste it into any QR generator you trust, or enter the secret by hand). If someone
loses their device, run it again with `--replace`.
Only superusers can make other accounts staff or give out permissions.
In local development the admin uses a password only. To try the real flow, run with
`ADMIN_REQUIRE_OTP=1`.
## Organizations and sign-in providers
- **Organizations** are created in the admin (Organizations → Add), naming the first owner. They invite everyone else.
- **GitHub and Discord sign-in:** in the admin, Social applications → Add, with the provider's client ID and secret.
- **A Forgejo instance:** register an OAuth2 application on the instance with the redirect URL `https://<host>/accounts/oidc/<provider_id>/login/callback/`, then add a Social application with provider "OpenID Connect", the same `provider_id`, the client ID and secret, and under Settings `{"server_url": "https://<forgejo-host>"}`. No deploy is needed.
## Email
Sign-in codes and email invitations need working email. Any provider
[Anymail](https://anymail.dev) supports works without code changes. For example, Postmark:
```sh
fly secrets set \
EMAIL_BACKEND=anymail.backends.postmark.EmailBackend \
ANYMAIL_POSTMARK_SERVER_TOKEN=... \
DEFAULT_FROM_EMAIL="Intent <intent@example.org>"
```
Then send a test and sign in with an emailed code:
```sh
fly ssh console -C "python /app/manage.py sendtestemail you@example.org"
```
## Moderation
- **Delete a comment:** the author, or an owner of the project, from the comment itself. It leaves a "Comment deleted" marker.
- **Close an issue:** any project collaborator or owner.
- **Block a person:** admin → Users → select → "Deactivate". They're signed out everywhere and can't sign in; what they wrote stays under their name. "Reactivate" undoes it.
- **Delete an account:** admin → the user → Delete. What they wrote stays, shown as written by "ghost". The admin won't delete an organization's last owner; make someone else an owner first.
## Limits
Per person: 30 comments or new threads per 10 minutes, 20 new issues per hour, 30 file uploads
per hour, 30 invitations per hour (and 3 per address per organization). allauth limits
sign-in attempts and code requests itself. Limits live in the database cache and reset when
their window ends. Change them in `intent/core/throttle.py`.
## Backups and the restore drill
Litestream keeps B2 within about a second of the database. Fly volume snapshots (daily by
default; check with `fly volumes snapshots list <volume-id>`) are a second, independent copy.
Run the restore drill before launch and after any change to `bin/start` or `litestream.yml`:
1. Note a recent change in the app, e.g. a comment you just posted.
2. Create a new volume and a new machine with it, from the same image:
`fly volumes create intent_data --size 1 --region <region>` then
`fly machine clone <machine-id> --attach-volume <new-volume-id>:/data`.
3. In `fly logs` for the new machine, confirm a Litestream restore happens before "Applying migrations" (or "No migrations to apply").
4. `fly ssh console --machine <new-machine-id>` and check the change from step 1 is there:
`python manage.py shell -c "from intent.comments.models import Comment; print(Comment.objects.latest('created_at').body)"`.
5. Destroy the clone and its volume. Two machines must never write to the same B2 path for long.
To recover for real, stop the machine, destroy its volume, create a new one, and start the
machine: `bin/start` restores from B2 on an empty volume.
## Tests
```sh
uv run playwright install chromium # once per machine
uv run pytest # everything, including real-browser tests
uv run pytest -m "not browser" # fast run without the browser
```
The browser tests run axe (WCAG 2.1 A and AA) on every page in light and dark mode, check
there are no CSP violations or script errors, and walk the main flow by keyboard.