The nightly refresh.yml and deploy.yml each substituted asset/nginx/
site.conf.tmpl with their own inline python. When bb2f5b1 templated the
listen line as {{WEB_LISTEN}} (moving the vhost behind oolon's stream SNI
router), it added the substitution to the template and deploy.yml but not
to refresh.yml. The daily refresh then rsynced a literal
`listen {{WEB_LISTEN}};` into /etc/nginx/conf.d/rob.tn.conf, `nginx -t`
failed for the whole edge, and — because the file is written into the live
conf.d before it is tested — every vhost's reload (including the step@
cert renewals) stayed frozen. Internal vhosts, cichlid.internal among
them, served certs that had expired days earlier while the renewed certs
sat unused on disk.
- Replace both inline renderers with script/render-site-conf.py, shared by
deploy.yml and refresh.yml so they cannot drift on what they substitute.
- Guard rails: the renderer fails if any {{PLACEHOLDER}} lacks an env value
or survives substitution, so a forgotten/misnamed variable is a red build
on the runner instead of a broken vhost on the edge.
- Add the missing WEB_LISTEN to refresh.yml's env (the immediate drift).
- Rename the template's {{DOCROOT}} to {{WEB_ROOT}} so every placeholder
maps to the env var of the same name.
- Remove script/deploy.sh: the third, unused renderer of the same template
(superseded by the Actions workflows) and a standing source of drift.
- Docs (readme, CLAUDE.md) updated to the Actions-only deploy path.
Known follow-up (needs a sudoers change + infra-setup re-run on oolon, so
out of scope here): the rendered vhost is still rsynced straight into the
live conf.d and only then `nginx -t`'d, so a valid-but-wrong config could
still wedge nginx. Stage-validate-swap with rollback would close that.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsH1rcWQYtRVhvaftiKm22
6.2 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
moments is a personal activity timeline and portfolio site. It ingests developer activity from multiple forges (GitHub, Gitea, Mercurial, Bugzilla), stores raw JSON payloads in PostgreSQL, and serves a React frontend showing contribution graphs, a ranked project dashboard, and a filterable activity timeline.
Architecture
Hexagonal (ports & adapters) Rust backend with a React/TypeScript frontend.
Crate Dependency Graph
moments-entities — pure types/DTOs, no DB or HTTP deps
^
moments-core — port traits (EventReader, EventWriter, EventSource, PollerStateStore)
+ presentation reshape + poller loop
^
moments-data — sole adapter: PgStore implements all core traits
+ EventSource impls (github, gitea, hg, bugzilla)
+ SQL migrations
^
moments-api — axum HTTP API binary (read-only, connects as moments_ro)
moments-worker — ingestion daemon binary (runs migrations, connects as moments_rw)
Key Design Decisions
- Raw payload storage: upstream JSON is stored verbatim in
events.payload(JSONB). Thereshape()function inmoments-core/src/presentation.rstransforms payloads intoTimelineItemat request time — no re-ingestion needed to change presentation. - Public/private gate:
events.publicboolean controls API visibility. Onlypublic = truerows are served. - Wire types are hand-maintained:
ui/src/api/client.tsmirrors Rust entity types manually. - Migrations: run automatically on worker startup via
sqlx::migrate!. The API binary never runs migrations.
Frontend
React 19 + Vite 6 (SWC) + TypeScript + Bootstrap 5. State/data via @tanstack/react-query. Package manager is pnpm.
Routes: / (dashboard), /activity (timeline), /project/:source/* (project detail), /blog + /blog/:slug (blog), /cv (resume).
Build & Dev Commands
Rust
cargo build --workspace # build all crates
cargo build --workspace --release # release build
cargo clippy --workspace # lint
cargo fmt --check # format check
cargo test --workspace # run tests
# Run binaries (need DATABASE_URL)
DATABASE_URL=postgres://localhost/moments cargo run -p moments-api
DATABASE_URL=postgres://localhost/moments cargo run -p moments-worker
Frontend
cd ui
pnpm install # install deps
pnpm dev # dev server on :5173 (proxies /api/* to localhost:8080)
pnpm lint # tsc --noEmit type-check
pnpm build # production build: client bundle, then prerender
The build is three steps (see ui/package.json): tsc -b → vite build (client
SPA) → pnpm run prerender (an SSR build of src/entry-server.tsx, driven by
run-prerender.mjs, that bakes one static index.html per route into ui/dist/).
The prerender fetches data at build time from VITE_API_BASE (default
https://rob.tn/api/v1) and inlines the dehydrated react-query cache as
window.__RQ_STATE__; the client hydrates it and refetches live. So a plain
curl of any route returns full content (for crawlers / AI screeners), while the
browser keeps full interactivity. Date formatting in the shared tree is pinned to
UTC + explicit field widths so SSR and client hydration match byte-for-byte.
Database
PostgreSQL with three migrations in crates/moments-data/migrations/. Two roles: moments_rw (worker, full access) and moments_ro (API, SELECT-only).
API Endpoints
All under /v1/: healthz, events, sources, projects, blog, blog/{slug}, activity/daily, forge/{source}/*, og/contributions.png.
Blog posts are markdown files with YAML frontmatter (title, slug, date; optional draft/public) in the grenade/blog Gitea repo. The worker's BlogSource polls the repo (branch-tip sha as change detection) and upserts posts into events with source='blog' and occurred_at from the frontmatter date, so imported posts keep their original publish dates. The repo is the source of truth for the full set of posts: publishing, editing, renaming, and deleting are all just pushes — each poll upserts the current tree and prunes source='blog' rows that are no longer in it.
Deployment
CI-driven via Gitea Actions (.gitea/workflows/), the source of infra truth
(hosts/ports/paths live in the workflow env, not a manifest):
deploy.yml— on push tomain(or manual dispatch): lint/test gate, build the api + worker as static musl binaries (pure-rustls, so no glibc skew) and the prerendered web bundle, then deploy each component over SSH as thegitea_ciuser with scoped sudo (asset/sudoers.d/). Services run under systemd with hardened units; the api/worker reach postgres over mTLS using the host cert.refresh.yml— dailyschedule:(+ manual): rebuilds and redeploys only the web tier, re-baking the prerendered crawler snapshot from the current gist (CV) and activity API without bouncing the api/worker.
One-time per-host provisioning (the gitea_ci user, its authorized_keys, the
scoped sudoers drop-in) is script/infra-setup.sh, run once per host by an
operator. Gitea repo secrets: RSYNC_SSH_KEY, QUERY_GITHUB_TOKEN,
QUERY_GITEA_TOKEN (the bare GITHUB_TOKEN/GITEA_TOKEN names are reserved by
Actions, so the worker poller's tokens use the QUERY_ prefix).
Nginx reverse-proxies /api/ to the API host and serves the per-route static
files via try_files $uri $uri/ /index.html.
Both workflows render the nginx vhost through the shared script/render-site-conf.py
rather than an inline substitution per workflow. It requires every {{PLACEHOLDER}}
in asset/nginx/site.conf.tmpl to have a matching env var and refuses to emit a
file with any placeholder left unrendered — so a variable added to the template
but forgotten in one workflow's env: fails that build instead of shipping a
broken vhost to the edge. (The former per-workflow renderers drifted exactly this
way once: WEB_LISTEN reached the template and deploy.yml but not refresh.yml,
and the nightly refresh froze every reload on oolon.)