The workspace stops holding one version. ipx keeps 0.12.1-dev and its vX.Y.Z tags; the directory starts at 0.1.0-dev, tagged directory-vX.Y.Z, and the page at 0.1.0-dev in package.json, tagged web-vX.Y.Z, which web/build.mjs gives the script for the logo's tooltip beside ipx's. crates/feed is a library, not released. CLAUDE.md's release steps are for one service at a time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
22 KiB
Working on ipx
Notes for whoever picks this up next. Read docs/architecture.md for how the thing is built; this file is about working on it without repeating mistakes that have already been made here.
Where things are
Production is the iPX container on Tower (192.168.1.130), the ipx service of the
Arcane project ipx: /mnt/fast/arcane/projects/ipx/compose.yaml. That file is what runs, and
the containers of the split (#207) join it there. docker-compose.yml in this repo is a sample for
others, ipx alone on SQLite; editing it changes nothing in production. Until 2026-10-12 ipx was a
service of the content project, beside audiobookshelf, immich and Plex (#208).
| Host | In the container | |
|---|---|---|
| Image | 192.168.1.130:5000/ipx:latest |
|
| Config | /mnt/fast/appdata/ipx/config.toml: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database |
/config/config.toml |
| Database | Postgres 18, database ipodderx, login ipodderx, on the postgres container of the Arcane project databases (192.168.1.130:5433). The URL is in ipx.env beside the compose file (/mnt/fast/arcane/projects/ipx/ipx.env, mode 600), passed to the container as IPX_DATABASE_URL. A relative env_file: Arcane runs compose in its own container, where /mnt/fast/appdata does not exist |
|
| Old database | /mnt/user/ipx/state.db, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback |
/data/state.db |
| Downloads | /mnt/user/ipx/downloads |
/downloads |
| Directory | ipx-directory in the same project, 192.168.1.130:8100, public at ipx-dir.sdf1.net through the tunnel with no Access in front: tokens are its gate. Config /mnt/fast/appdata/ipx-directory/directory.toml (token hashes; Tower's own token in tower.token beside it, and entered in ipx's Admin, Directory, which keeps it in the database's settings), database ipx_directory on the same Postgres, URL in directory.env beside the compose file. See docs/directory.md |
/config/directory.toml |
| Web UI | ipx-web (Caddy) in the same project, at 192.168.1.130:8099, also ipodderx.sdf1.net via a Cloudflare tunnel. It serves the page and passes /api and /media to ipx, whose own port is published nowhere; its address on ipx_default is pinned to 192.168.64.10 (#203) |
ipx 0.0.0.0:8099, Caddy :8080 |
| Sign-in via the tunnel | Cloudflare Access app ipodderx, with Authentik as its identity provider; see docs/sso.md |
Caddy passes Cf-Access-* only from 192.168.64.1, the ipx_default gateway cloudflared arrives from (TUNNEL_PEER; the subnet is pinned in the compose file); ipx trusts them only from Caddy, 192.168.64.10, and only with a valid Cf-Access-Jwt-Assertion (access_team, access_aud) |
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
TODO.md. /src/tea is logged in: /src/tea issues list --login git.sdf1.net --repo rays/ipx.
Every problem found gets an issue, before it is fixed. A bug, a gap or a security hole turned
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
when. Name the issue in the commit that fixes it ((#40) in the subject). Once the fix is on
main and pushed, comment on the issue with what changed and the commit, then close it:
R="--login git.sdf1.net --repo rays/ipx"
t() { timeout 30 /src/tea "$@" < /dev/null; }
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
t issues close $R 40
Give tea a closed stdin and a timeout, as t does. Without a terminal, tea comment waits on
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
Labels: bug for something wrong, enhancement for something missing. A problem found and left
for later stays open, and that is how it gets picked up again.
Deploying a change is: build and push the image, then pull it and recreate the container.
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx
docker logs --tail 20 iPX
The directory is the same, with its own image:
docker buildx build -f Dockerfile.directory --tag 192.168.1.130:5000/ipx-directory:latest . --push
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-directory
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-directory
And the page, ipx-web, the same again; a change to web/ alone needs only this one:
docker buildx build -f Dockerfile.web --tag 192.168.1.130:5000/ipx-web:latest . --push
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-web
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-web
Testing sign-in from Tower itself proves nothing about the tunnel: Tower's own requests reach the
published port as 192.168.1.130, and Caddy rightly strips their Cf-Access-*. Send them from a
container on another network, as cloudflared's are (#209).
Name the service. A bare up -d recreates every container in the project, which once the
split lands means the frontend and directory too. Run pull before up, because up reuses whatever latest the host already has.
A build that fails with 429 Too Many Requests on a base image is Docker Hub rate-limiting
this host. There is no Docker Hub login here, and the build asks about debian:trixie-slim and
rust:1-slim-trixie every time unless they are already stored locally. Pull them from Google's
mirror and tag them; the build then uses the local copies without asking Docker Hub:
docker pull mirror.gcr.io/library/debian:trixie-slim
docker tag mirror.gcr.io/library/debian:trixie-slim debian:trixie-slim
docker pull mirror.gcr.io/library/rust:1-slim-trixie
docker tag mirror.gcr.io/library/rust:1-slim-trixie rust:1-slim-trixie
Run those again now and then, or the local copies go stale.
The healthcheck runs ipx status against the control socket, so (healthy) in docker ps means
the daemon answers there and can read its database, not just that the web port is up. The socket
answers status itself instead of queuing it behind the worker's current job, so a long scan or
download does not fail the check; it also means a worker stuck on one job would still pass. The
container restarts on its own after a reboot.
Before the container, ipx ran by hand in code-server, with its files in /config/.config/ipx/ and
/config/.local/share/ipx/. Those are still there and the container does not read them. If you run
a daemon by hand for testing, stop it by its own PID: start it with & echo $! > pid and
kill $(cat pid). Never pkill -x ipx: Tower sees the container's processes, so it kills
production's daemon as well (issue #38). Never pkill -f ipx either: -f matches the shell
running the command and kills the session (exit 144), which has happened more than once.
Before you touch the page
There are three pages: the app (web/index.html), the admin page (web/admin.html, sent to
admins only) and sign-in (web/login.html). The app and admin pages share one stylesheet,
web/app.css, and their script is TypeScript in web/src/; web/build.mjs lists which files
make up each page's script. The page is its own image, ipx-web (#203): web/build.mjs uses
swc to strip the types and minify each script (app.js, admin.js, login.js), minifies the
pages, and puts them with the icons and typeface in web/dist, which Caddy serves
(web/Caddyfile, Dockerfile.web), passing /api and /media to ipx on the same origin. ipx
serves no page. The page loads its script as /app.js?v=<hash of its contents>, and /app.css
and /boot.js the same way: the page is served no-cache and those immutable, so a browser
keeps them until a deploy changes them and their names. So every page change needs ipx-web
rebuilt and deployed, not ipx, and building needs node and npm ci run once.
Caddy asks ipx before it serves a page (forward_auth): /api/page-check sends a browser that is
not signed in to /login and anyone but an admin away from /admin (and refuses them
/admin.js); /api/login-check sends someone Cloudflare Access vouches for past the form; a page
asked for with ?token= goes to /api/signin, which sets the cookie. The page is the same file
for everyone, so what ipx used to write into it per person is the page's own job now:
web/boot.js, in <head>, puts the theme from its cookie and an admin's link from the last visit
on the page before the first frame, and /api/me brings the rest (version, the account's old
theme).
The files in web/src are not modules. They are one script split up, concatenated in the order
web/build.mjs lists them, sharing one top-level scope as the single inline script did; a new
file goes into that list. Top-level names are kept as they are, because markup calls some by
name (onclick="closeModal()") and the browser tests reach others through page.evaluate.
After any edit to it:
npx tsc -p .
node tests/page-smoke.js
The first type-checks web/src (loosely: strict is off, and $ returns any). The second
builds the page as shipped and runs its script against a stub DOM, checking every selector it
wires at load actually exists. That check exists because a patch once anchored on a deleted
function, String.replace silently matched nothing, and the whole UI died with a
ReferenceError while every server-side test passed.
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
(sed -n 'START,ENDp'), match it verbatim, and assert the replacement happened rather than hoping.
Tests
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src
node tests/page-smoke.js
node tests/native-bridge.js # the page hands playback to a native shell
node tests/contrast.js # every theme's palette against WCAG AA
npx playwright test # 66 browser tests against a real daemon on fixture feeds
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
Things about the browser suite that have cost time:
- It starts its own daemon and database under
/tmp/ipx-ui-test, wiped once per run. Playwright re-imports the config in every worker, soprepare()guards onTEST_WORKER_INDEX— without that guard a worker deleted the database out from under the running daemon, which then kept serving from the unlinked inode while everything else saw an empty file. - Tests share that daemon and run in order. A test that opens an item marks it read and changes what later tests see. Write assertions that do not depend on what ran before, or normalise the state first.
- Fixture feeds must not share an enclosure URL, because
enclosures.urlis globally unique and whichever feed is scanned first claims it. webServerstarts beforeglobalSetup, which is why the fixture config is written at config-load time instead.
The load tests (tests/load, issue #133) are for what one browser cannot show: twenty-five people
browsing at once, a hundred players saving positions while a scan writes, fifty listeners
seeking through files, a flood of wrong passwords. Things about them:
- They need k6, which
/src/install.shinstalls from k6's own signed apt repository, and they build and run a release binary: Argon2 in a debug build takes about a second a sign-in, which would measure the build. run.jsserves the feeds itself, generated, and starts its own daemon under/tmp/ipx-load, wiped each run, on ports 8793 and 8794, so it can run beside the browser suite. It stops that daemon by its PID. People sign in by theX-Load-Userheader, trusted from 127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password.- Each threshold is a budget well above what the request takes now: it is there to catch a query that has started asking once per feed, or writers queueing on a lock, not a slow minute.
ipx status, the Docker healthcheck, runs every second throughout, and a run fails if one takes the healthcheck's 5s.- One at a time:
node tests/load/run.js signin. All four take about six minutes. - The daemon is on SQLite unless
--postgres, which puts it inIPX_TEST_DATABASE_URL, in a schema of its own (ipx_load) dropped and made again each run. That URL, in/src/.envrc, isipodderx_teston thedatabasesproject's server, production's, asipodderx, taken fromipx.env; the harness refuses a database whose name does not end in_test. Postgres's numbers are what production would see, and a run loads the server production's database is on, so not while someone is using iPX in earnest. Each budget has a value for each database. - The Rust tests run on Postgres with the same URL:
. /src/.envrc && cargo test. Each test makes a schemaipxt_<pid>_<n>, and the next run drops those an earlier one left.
Non-trivial logic leaves one runnable check behind. Pure functions (merge_policy, pick,
matches_keywords, parse_interval) are the easiest place to put it.
Things that are true and easy to get wrong
enclosures.urlis globally UNIQUE. It is the dedupe key and the reason one file serves every subscriber. Two feeds publishing the same URL means only the first one scanned shows it.- Read state lives in
entry_state, per user, and nowhere else.entrieshadread,flaggedandpositioncolumns from before accounts; two bugs came from queries still reading them (retention, and the entry pruner), and they were dropped in 0.5. - The catalogue and the server settings are in the database, not config.toml (issue #18):
tables
catalogue(each feed'sconfig::Feedas JSON) andsettings(general:config::Stored). ipx still runs from one in-memoryConfig, config.toml for where things are and who gets in, the database for the rest (assemble_config); a change goes throughCtx::store_cfg, never a write to the file. The first start on a database without them imports config.toml's and trims the file, keepingconfig.toml.pre-database. A feed exists once;subscriptions(user_id, feed_id)says who wants it and with what settings. OPML children are derived and never in the catalogue. - Postgres connections ask for no notices (
client_min_messages=warning,db::url_for). Postgres sends one for everyCREATE ... IF NOT EXISTSon something existing, sqlx logs each, and tracing-subscriber's per-layer filters then dropped the next line ipx logged. - One fetch serves everyone, so scan policy is a union of subscribers' wants (
merge_policy). Anyone wanting an item is enough to fetch it. - The UI hiding a control is not enforcement. Admin-only actions check
user.is_adminin the handler and return403. - A
tokio::select!only races its branches at the point of selection. A long download has to watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason. - Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons fighting over the database, with the stale one still holding the port.
- The Grafana dashboard reads the log's fields (
grafana/dashboard.py). Production logs JSON (IPX_LOG_FORMAT=json); the access log'smethod,path,route,status,duration_msand the events'ev,feed,new,bytes,msg(log_eventin ipc.rs) are what the panels query. Rename one and its panels go blank without an error; regenerate the dashboard to match. /api/settingsanswering200does not mean the daemon is well — the web server is a different task.ipx statuschecks the control socket and the database; to see the worker getting through its jobs, watch forscan completein the log.- The database goes through SeaORM, and the entities in
crates/ipx/src/entity.rsare the schema.Db::opencreates any missing table or index from them (create_missing), on everyipxcommand, the healthcheck'sipx statusincluded, so it must never write when nothing is missing: SeaORM's experimental schema sync dropped and remade an index on every open, the write lock that took madeipx statustime out behind a busy daemon, and it was removed for it. A new column on an existing table needs its ownALTER; nothing adds one for you. Look for the column first and alter only when it is missing, asfeeds.descriptiondoes. - SQL written by hand in
db.rshas to run on SQLite and Postgres both (issue #18):$1parameters, bound only if used;ON CONFLICT, notINSERT OR IGNORE; yes/no columns tested as themselves (NOT coalesce(s.read, false)) and written astrue/false, never compared to 1; norowid,GLOBorUPDATE OR IGNORE.Argsindb.rsbuilds the parameters.
House style
Comments explain why, not what. If a line looks odd, the comment says what went wrong without it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and addressed to the person using it.
Every change gets one line under ## [Unreleased] in the changelog of the service it changes, in
its Keep a Changelog group: Added, Changed, Deprecated,
Removed, Fixed or Security. One changelog a service: crates/ipx/CHANGELOG.md
(the daemon and its API; until 0.12.0 everything), web/CHANGELOG.md (the page
and ipx-web) and crates/directory/CHANGELOG.md (ipx-directory). A
change to more than one gets a line in each, each saying what that service does differently. The
monitoring (the Grafana dashboard, the prod-check skill) and the repository's own tooling are not
a service anyone uses and get no line. Say it the way someone using ipx would notice it. When there is more to
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
body, where git log and git blame find it beside the change. (There was a long-form
docs/history.md until 0.7.0; it grew too large to be useful and was removed. It is in git.)
Each service has its own version and is released on its own, when its changelog has something to release:
| service | version in | changelog | tag |
|---|---|---|---|
| ipx | crates/ipx/Cargo.toml |
crates/ipx/CHANGELOG.md |
vX.Y.Z, as every release before the split |
| ipx-directory | crates/directory/Cargo.toml |
crates/directory/CHANGELOG.md |
directory-vX.Y.Z |
| ipx-web, the page | package.json |
web/CHANGELOG.md |
web-vX.Y.Z |
crates/feed is a library the two Rust services build in, not released (0.0.0); a change to it
is a line in the changelog of each service it changes. The page shows ipx's version and its own in
the logo's tooltip; the directory logs its own at start; each binary answers --version.
Cutting a release of one: first read its [Unreleased] against its last release, not line by
line, and rewrite it as what changed between the two. Lines go in as the work happens, so they
record the way there: a feature added and then reworked in the same release is one line saying
what it is now, under Added; something added and taken out again before the release, or a fix for
a bug no release ever had, is not there at all; and "instead of" and "no longer" compare with the
last release, not with last week. Issue numbers stay, all of them, on the line that survives. Then
rename [Unreleased] to ## [X.Y.Z] - YYYY-MM-DD and open a new empty [Unreleased] above it,
set the service's version to X.Y.Z (dropping -dev), tag the commit with the service's tag, and
update the compare links at the bottom of its changelog. Then, in the next commit, set its version
to the next patch with -dev (after 0.9.0, 0.9.1-dev), so a build between releases says so
instead of claiming to be the last release. The release that follows can still be a minor or major
one; -dev only says the work comes after X.Y.Z. A service never released is at 0.1.0-dev,
working towards its first release, 0.1.0.
Traces first, logs second. Anything that does work (a service, a handler, a job, a call to
another service) gets spans, and passes traceparent on to whatever it calls, so one trace in
Tempo follows a request or a job across ipx-web, ipx and the directory. A trace shows where the
time went and what called what; a log line only that something happened. Log what a trace cannot
carry: events worth counting on the dashboard, warnings and errors. A new service or route without
traces is not finished.
Deliberate simplifications get a ponytail: comment naming the ceiling and the upgrade path, e.g.
// ponytail: global connection mutex, move to a pool if feed count makes it contend.
Known gaps
- They are the open issues in Gitea, not a list here: a limitation known and left in place is an issue left open.
Command output
Command output here is condensed to save tokens, keeping every signal and
dropping costly noise. Treat it as the complete result: run commands
normally, and batch related commands into one call to avoid extra turns.
Truncated results state their recovery path in their own output. Re-run a
command as rtk proxy <cmd> only when its result is unusable: empty when
output was clearly expected, contradicting its exit code, or garbled.