Add /v1/forge/{source}/* proxy endpoint to the API server with an
allowlisted set of hosts. Frontend readme and language requests now
go through the proxy instead of hitting forge APIs directly (Gitea
has no CORS headers). Gitea readme fetch tries README.md, readme.md,
and Readme.md casings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
moments
Personal activity timeline. Polls public sources (GitHub, Gitea, Mercurial, Bugzilla), stores raw payloads in Postgres, and serves a reshaped timeline to a static React frontend.
Successor to the now-defunct grenade-events-react, which depended on MongoDB Stitch (retired by MongoDB in September 2022).
Layout
crates/
moments-entities/ # types and DTOs
moments-core/ # ingestion + reshape logic
moments-data/ # postgres adapter + migrations
moments-api/ # axum read-only HTTP API (binary)
moments-worker/ # ingestion daemon (binary)
ui/ # vite + react + swc + ts frontend
asset/ # systemd, nginx, firewalld, manifest.yml
script/deploy.sh
Architectural conventions follow grenade/architecture/generic.md.
Local development
cargo build --workspace
cargo run -p moments-api # serves on 127.0.0.1:8080
cargo run -p moments-worker # one-shot ingest tick (until --interval is wired up)
The API expects a Postgres reachable at DATABASE_URL. In production this is an mTLS connection using the host cert. For local dev against a throwaway database:
DATABASE_URL=postgres://localhost/moments cargo run -p moments-api
Migrations live in crates/moments-data/migrations/ and run automatically on worker startup. The API connects as moments_ro and never runs migrations — the worker (as moments_rw) is the schema owner.
Deployment
./script/deploy.sh <env> all # api + worker + web
./script/deploy.sh <env> api worker # subset
./script/deploy.sh <env> default # api + web only (worker untouched)
./script/deploy.sh <env> all --dry-run
Concrete hosts, ports, and the site's server_name live in asset/manifest.yml. The shape of the deployment:
| Component | Notes |
|---|---|
| api | binds the port from api.config.bind; firewalld service moments-api |
| worker | no listening port; pollers only |
| web | per-site nginx ingress; /api/* reverse-proxies to the api host |
| db | postgres mTLS, passwordless |
Postgres roles moments_rw and moments_ro must exist on the primary, with pg_ident.conf.d/<host>.conf mapping the api host's FQDN → moments_ro and the worker host's FQDN → moments_rw. See asset/sql/bootstrap-moments.sql, asset/postgres/ident.conf.tmpl, and script/db-perms.sh (idempotently adds the cert_cn lines on the postgres primary + standby and reloads postgres).
Inter-host traffic over the WG mesh: web's nginx connects to the api host in plaintext. The mesh provides the encryption layer; per-hop TLS for an internal HTTP read-only API on already-public data is deferred. If that changes, swap the api binary to rustls + the host cert pair, and update the nginx upstream to https://.
Secrets are resolved at deploy time via pass. The mapping of env-var name → pass-store path lives under worker.secrets in manifest.yml; deploy.sh iterates the map, fetches each secret, and substitutes the matching {{NAME}} placeholder in worker.env.tmpl. To add a secret: add a worker.secrets entry, add NAME={{NAME}} to worker.env.tmpl, and ensure pass show <path> returns the value on the deploying machine.
First-time setup
After the first successful prod deploy:
- Point public DNS for the site at the web host's public IP (unproxied).
- Confirm
curl --fail --silent --show-error https://<site>/api/v1/healthzreturnsok. - If migrating from a predecessor, archive the old repo with a pointer to this one.