docs: object storage is Garage now
All checks were successful
Build and Deploy to Production / test (push) Successful in 27s
Build and Deploy to Production / build (push) Successful in 1m10s
Build and Deploy to Production / deploy (push) Successful in 39s

CLAUDE.md and SECRETS.md still described MinIO: the endpoint, the
bootstrap script and the kind of key. Describe Garage, and say how the
deploy turns the ASXPIO_S3_* secrets into a Garage key.

Assisted-by: Claude:fable-5-2
This commit is contained in:
Sergei Poljanski 2026-11-11 04:28:35 +04:00
commit f5d0ce9b48
Signed by: asxpi
GPG key ID: 4F8851660FA4121B

View file

@ -5,7 +5,7 @@ The public-facing website for the user's Individual Entrepreneur registered in G
1. Present the legal entity and accept contact-form submissions.
2. Generate, store, and serve client invoices as signed PDF links.
The site depends on the **storage stack** in the sibling `~/Code/projects/asxpio/storage` repo, which runs Postgres + MinIO on the same host. asxpio is stateless; all invoicing state lives there.
The site depends on the **storage stack** in the sibling `~/Code/projects/asxpio/storage` repo, which runs Postgres + Garage (S3) on the same host. asxpio is stateless; all invoicing state lives there.
## Rule of thumb for this file
**Same confidentiality bar as the rendered HTML.** If a fact isn't on the public page, it doesn't belong here. Sensitive operational details (banking, registration number, tax-regime reasoning) live in the user's private memory, not in the repo.
@ -33,7 +33,7 @@ Banking details and registration number are deliberately not on the public page
- **Mail gem** for SMTP (Fastmail, STARTTLS on 587)
- **Sequel + pg** for Postgres (the `asxpio` DB on the storage stack)
- **Prawn + prawn-table** for PDF rendering, with vendored Noto Sans + Noto Sans Georgian in `public/fonts/`
- **aws-sdk-s3** against MinIO on the storage stack
- **aws-sdk-s3** against Garage on the storage stack
- In-memory `RateLimit` (5 req/hour/IP) — see `lib/rate_limit.rb`
- `Rack::Protection::AuthenticityToken` for CSRF (uses session-stored token)
- `AdminAuth` (`lib/admin_auth.rb`, a Sinatra extension): session login at `/admin/login` against the `users` table (`lib/user.rb`, scrypt via OpenSSL, no gem). Session holds user id + `session_version` (bump = logged out everywhere). `ADMIN_USER`/`ADMIN_PASSWORD` bootstrap the first superuser on an empty table and otherwise work only while no active superuser exists (recovery; it re-creates that superuser). Login is rate-limited per IP (`LOGIN_RATE_LIMIT`, default 10/15 min).
@ -42,22 +42,22 @@ Banking details and registration number are deliberately not on the public page
- Timestamps: `Sequel.default_timezone = :utc` (lib/db.rb); always write `Time.now.utc`.
- Honeypot field named `website` for spam
- Static assets in `public/`; views in `views/`
- Deploy: `Dockerfile` + `docker-compose.yml` (Traefik labels for asxp.io / www.asxp.io). Container joins two external Docker networks: `traefik` (ingress) and `storage` (Postgres + MinIO).
- Deploy: `Dockerfile` + `docker-compose.yml` (Traefik labels for asxp.io / www.asxp.io). Container joins two external Docker networks: `traefik` (ingress) and `storage` (Postgres + Garage).
- Dev shell: `flake.nix` (Ruby 3.4, bundler, openssl, zlib, libyaml, postgresql). Run `nix develop`.
The patterns mirror `~/Code/projects/narayana/www`. Use that repo as a reference for conventions (middleware, helpers, dotenv pattern) — but don't pull in narayana-specific things this site doesn't need (no Redis, no i18n, no API client).
## Storage stack dependency
Postgres and MinIO are owned by `~/Code/projects/asxpio/storage` (separate Forgejo repo, separate deploy pipeline). asxpio talks to:
Postgres and Garage are owned by `~/Code/projects/asxpio/storage` (separate Forgejo repo, separate deploy pipeline). asxpio talks to:
- `postgres:5432` over the `storage` Docker network — for the `invoices` table.
- `minio:9000` over the `storage` Docker network — for putting PDF bytes.
- `https://s3.asxp.io` over Traefik — used **only** for presigned URLs, so the browser can dereference them. Same MinIO, different hostname.
- `garage:3900` over the `storage` Docker network — for putting PDF bytes.
- `https://s3.asxp.io` over Traefik — used **only** for presigned URLs, so the browser can dereference them. Same Garage, different hostname.
If the storage stack is down, the asxpio container will fail to boot (Sequel connects at process start). The contact form goes down with the invoicing UI in that case. Accepted trade-off; the alternative was a much more elaborate "degrade gracefully when DB is gone" path that wasn't worth the code.
The `asxpio` Postgres role + `asxpio-invoices` MinIO bucket + scoped MinIO user are provisioned by the storage repo's init containers (`init/postgres-init.sh`, `init/minio-bootstrap.sh`). Changing those requires editing storage, not this repo.
The `asxpio` Postgres role + `asxpio-invoices` bucket + scoped S3 key are provisioned by the storage repo's init steps (`init/postgres-init.sh`, `init/garage-bootstrap.sh`). Changing those requires editing storage, not this repo.
## Page structure
@ -84,7 +84,7 @@ Admin (HTTP Basic via `ADMIN_USER`/`ADMIN_PASSWORD`):
- `GET /admin` → dashboard (`views/admin/dashboard.erb`): invoice counts/outstanding/overdue + recent five, bingo score/reserve/latest incident/missing photos. Shared admin nav in `views/admin/_nav.erb`.
- `GET /admin/invoices` — list, newest first.
- `GET /admin/invoices/new` — create form. Dynamic line items via `public/admin-invoice-form.js`.
- `POST /admin/invoices` — validate → allocate number → render PDF → upload to MinIO → persist row.
- `POST /admin/invoices` — validate → allocate number → render PDF → upload to S3 → persist row.
- `GET /admin/invoices/:uuid` — detail page with public URL + "Mark paid" toggle.
- `POST /admin/invoices/:uuid/paid` — toggles `paid_at`; optional `paid_on` (YYYY-MM-DD, no future dates) backdates it to that day 12:00 UTC. `POST …/paid-date` corrects the date of a paid invoice. The PDF doesn't print the date, so neither re-renders.
- `POST /admin/invoices/:uuid/cancel` (optional `reason`) — sets `cancelled_at`/`cancel_reason`, renders and uploads a third PDF variant (`…-cancelled.pdf`, diagonal CANCELLED stamp + date/reason line) which becomes current, so the client link and download switch over. Status precedence: cancelled > paid > pending; marking paid is refused while cancelled. `POST …/reinstate` clears it.
@ -94,12 +94,12 @@ Admin (HTTP Basic via `ADMIN_USER`/`ADMIN_PASSWORD`):
Public:
- `GET /i/:uuid` — landing page: client name, number, total, status badge, download button.
- `GET /i/:uuid/pdf` — 302 to a 5-minute MinIO presigned URL (Content-Disposition: attachment).
- `GET /i/:uuid/pdf` — 302 to a 5-minute presigned URL (Content-Disposition: attachment).
- `GET /healthz` — liveness (plus `SELECT 1` when invoicing is configured); used by the Docker HEALTHCHECK and the deploy pipeline's post-deploy wait.
### Storage model
- `invoices` table (`db/migrations/001_invoices.rb`): UUID PK, unique `number` (format `INV-{YYYY}-{NNNN}`, allocated by scanning the current year's max), JSONB `items`, captured-at-issue `gel_rate`, `paid_at` nullable, `pdf_key` for the MinIO object path (`invoices/<number>-<uuid>.pdf`).
- `invoices` table (`db/migrations/001_invoices.rb`): UUID PK, unique `number` (format `INV-{YYYY}-{NNNN}`, allocated by scanning the current year's max), JSONB `items`, captured-at-issue `gel_rate`, `paid_at` nullable, `pdf_key` for the S3 object path (`invoices/<number>-<uuid>.pdf`).
- `db/migrations/002_invoice_ltc.rb` added Litecoin-only columns; `003_invoice_crypto.rb` generalized them: `crypto_coin` (a `CryptoAsset` code, e.g. `BTC`, `USDT-TRC20`), `crypto_address` (captured at issue), `crypto_rate` (price in the invoice currency, snapshotted), `crypto_amount` (due — derived `total / crypto_rate` unless hand-overridden). All nullable; crypto is opt-in per invoice (blank address ⇒ no crypto block/QR). Pre-migration LTC invoices were backfilled with `crypto_coin = 'LTC'`.
- `Invoice` (Sequel model) — number allocation, line-item normalization, `total` / `total_gel` helpers, plus `crypto?` and `crypto_amount_due`. **Always assign `uuid` as an attribute after `Invoice.new(...)`** — passing it to `new` raises `MassAssignmentRestriction` because Sequel guards primary keys.
@ -119,11 +119,11 @@ Admin pages use a shared dark palette extended in `public/style.css` (search for
- The `INV-{YYYY}-{NNNN}` allocator is `SELECT MAX(...) + 1` over the numeric suffix. Concurrent creates (Puma threads) that collide on the unique constraint are retried — the whole build→render→upload→save sequence, since the PDF embeds the number; a losing attempt orphans its two S3 objects. If we ever add a second container, switch to a DB-side sequence per year.
- Anyone with the UUID can fetch the PDF. UUIDs are 122-bit random so not enumerable, but they aren't access-controlled. If a stronger gate is ever needed (email confirmation, expiry on paid, etc.) it goes in the `/i/:uuid` and `/i/:uuid/pdf` handlers.
- The MinIO bucket is **private**; only the app's scoped service account can put/get. Presigned URLs use `S3_PUBLIC_ENDPOINT=https://s3.asxp.io` so the signed host matches what the browser fetches.
- The bucket is **private**; only the app's scoped key can put/get. Presigned URLs use `S3_PUBLIC_ENDPOINT=https://s3.asxp.io` so the signed host matches what the browser fetches.
## Datacenter bingo (admin side)
The public site (datacenterbingo.xyz) lives in its own repo and container: `git.czsk.it/asxpi/datacenterbingo`, mirrored to GitHub. It reads the tables and photos below through a read-only DB role (`bingo`) and MinIO user. What stays here is the admin, which writes, and the schema.
The public site (datacenterbingo.xyz) lives in its own repo and container: `git.czsk.it/asxpi/datacenterbingo`, mirrored to GitHub. It reads the tables and photos below through a read-only DB role (`bingo`) and S3 key. What stays here is the admin, which writes, and the schema.
- **Schema is owned here.** After adding a bingo migration, refresh `test/schema.sql` in datacenterbingo and update its models. Keep `Bingo::PROVIDERS`, `Board` and the model methods in step with its `lib/bingo.rb`. UI strings (`T`) and views are only there.
- `BINGO_HOST` (deploy.yaml → `.env`) is used only for the IndexNow host and the admin's "view site" link; Traefik no longer routes it here.
@ -179,7 +179,7 @@ All colors in `public/style.css` are CSS custom properties on `:root` (design to
0. Test suite runs on the `nix-latest` runner label (postgres service container); a red suite blocks the build. Non-main branches get the same job from `test.yaml`.
1. Kaniko builds the image and pushes it to the Forgejo registry.
2. SSH copies `docker-compose.yml` to the deploy directory on the prod host.
3. SSH writes `.env` (mode 600) from Forgejo secrets. The full set of invoicing envs (`DATABASE_URL`, `S3_*`, `ADMIN_*`) is built here; the per-app `ASXPIO_DB_PASSWORD` / `ASXPIO_S3_*` values must match the storage repo's Forgejo secrets, since storage's init containers provision the corresponding role + MinIO user.
3. SSH writes `.env` (mode 600) from Forgejo secrets. The full set of invoicing envs (`DATABASE_URL`, `S3_*`, `ADMIN_*`) is built here; the per-app `ASXPIO_DB_PASSWORD` / `ASXPIO_S3_*` values must match the storage repo's Forgejo secrets, since storage's init steps provision the corresponding role + S3 key. Garage only takes keys in its own format, so the deploy hashes `ASXPIO_S3_*` into it exactly as `storage/init/garage-bootstrap.sh` does (`garage_key_id`, `garage_secret`); a pair already in Garage's format is used as is.
4. SSH `sed`-substitutes the image tag in `docker-compose.yml`, runs `docker compose up -d`, and waits (up to 90s) for the image `HEALTHCHECK` (`GET /healthz`) to report healthy — a crash-looping container fails the deploy.
## Where the secrets live
@ -191,8 +191,8 @@ All colors in `public/style.css` are CSS custom properties on `:root` (design to
| `ADMIN_USER` | HTTP Basic username for `/admin/*` | Change secret + push. |
| `ADMIN_PASSWORD` | HTTP Basic password for `/admin/*` | Generate fresh, update secret + push. **Forgejo doesn't show old values.** Save to password manager. |
| `ASXPIO_DB_PASSWORD` | Password for the `asxpio` Postgres role | Update on **both** repos to the same value. Push storage first (re-provisions role), then asxpio. |
| `ASXPIO_S3_ACCESS_KEY` | MinIO service account key, scoped to `asxpio-invoices` | Same dual-repo update. Push storage first. |
| `ASXPIO_S3_SECRET_KEY` | Matching MinIO secret | Same. |
| `ASXPIO_S3_ACCESS_KEY` | S3 key id (Garage), read-write on `asxpio-invoices` and the bingo photos | Same dual-repo update. Push storage first. |
| `ASXPIO_S3_SECRET_KEY` | Matching S3 secret | Same. |
| `CRYPTO_ADDRESSES` | **Seed only.** Imported into `payout_addresses` on the first boot with an empty table; afterwards edited in `/admin/invoices/payout-addresses`. | Delete the secret (and its line in deploy.yaml) once the admin page shows the imported addresses. |
| `INDEXNOW` | IndexNow key for the bingo (→ `INDEXNOW_KEY`, served at `/<key>.txt`) | New random key (8–128 chars `A-Za-z0-9-`) → update secret → push. The old key file stops being served. |
| `LTC_ADDRESS` | Legacy, seed only (fills LTC on that first import). | Delete together with `CRYPTO_ADDRESSES`. |