- 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>
129 lines
6.4 KiB
Markdown
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.
|