dtourolle 6c0c80f20b
CI / fmt, clippy, test (pull_request) Successful in 1m48s
CI / advisories and licences (pull_request) Successful in 42s
CI / static musl binary (pull_request) Successful in 1m46s
CI / debian package (pull_request) Failing after 3h10m28s
feat(deb): package the server, and ask the questions that fail silently
DR-015, DR-016. §8 already shipped a static binary and an optional
container; this adds the third form, and it packages the SAME binary the
musl job proved static rather than building its own. Two builds of the
same commit could diverge, and the whole point of that assertion is that
the artifact an operator installs is the one that was checked.

Built with dpkg-deb from an explicit staging tree rather than cargo-deb.
debconf's `config` script and `templates` live in the control archive
next to the maintainer scripts, and controlling that archive directly
beats discovering what a wrapper will copy into it. dpkg-dev is on every
Debian builder, so this adds no build dependency.

Why debconf at all: two settings fail SILENTLY when unset. Without
JRAY_TMDB_API_KEY every upload stays `pending` and is never listed;
without JRAY_TRUSTED_PROXIES the X-Forwarded-For header is ignored, so
every client shares one rate-limit bucket and every abuse report points
at the proxy. Both leave a server that works and is quietly doing the
wrong thing — the worst thing to leave to a README nobody reads.

Three properties, each a way packaging usually goes wrong:

The generated config is NOT a dpkg conffile. It is written from the
debconf answers, so shipping it as one would make dpkg prompt on every
upgrade about changes the package itself had made.

Hand edits survive. postinst rewrites only the keys debconf manages;
comments, ordering and any other setting are left alone.

A blank key on reconfigure keeps the existing one. Otherwise pressing
Enter through a dpkg-reconfigure would unpublish every future upload.

The seeding guard is worth its comment, because the obvious version is
wrong twice over. `config` seeds unanswered questions from the env file
so a reconfigure shows what is actually in force. Seeding
unconditionally overwrites a preseed — debconf-set-selections marks what
it sets as seen — so every unattended install would quietly reconfigure
itself back to whatever was on disk. Guarding on an empty value does not
work either: server-id and bind carry template Defaults, so db_get
returns "localhost" for a question nobody answered. The test is the
`seen` flag, which is the actual question being asked.

Purge keeps the database, knowingly departing from the expectation that
purge removes everything. Manifests are the output of real CV compute on
media the operator may no longer have, and §8 says federation is
explicitly not a backup. Destroying that during an `apt purge` is not a
trade worth making for tidiness; postrm names the path instead.

The nginx example is documentation, not installed configuration. The
proxy usually runs on a different host from the server, so a file
dropped into this machine's nginx would be in the wrong place — and §8
leaves the edge to the operator deliberately.

Verified by running it, not by reading it: a full lifecycle in a
bookworm container — build, preseeded install, mode-600 env file, key
absent from debconf's database afterwards, `systemd-analyze verify` on
the unit, the installed binary answering /health and /ready, reconfigure
preserving both the key and an unmanaged setting, and purge leaving the
database. It failed on the seeding bug above the first time, which is
why that guard exists. CI runs the same checks against every build.

TRACES: DR-015, DR-016 | PR-004
2026-09-06 09:58:47 +02:00

JRay public server

A community manifest exchange for JRay. Jellyfin servers running the JRay plugin pull actor-timeline manifests ("Jmanifests") for titles they own instead of running the CV pipeline locally, and optionally contribute the manifests they generate back.

See SPEC.md for the design. Section references throughout the code point at it. If you are considering running an instance, read docs/legal-posture.md first: it states what an instance holds, what it structurally cannot do, and the operator checklist.

Licensing — two licences, do not conflate them

Licence Where
Code GPL-3.0-or-later LICENSE, declared in Cargo.toml
Contributed manifests CC0 1.0 Universal LICENSE-DATA, specified in SPEC.md §5b

Both files ship with any distribution. They answer different questions, and a package carrying only LICENSE leaves the one that matters for federation unanswered.

The data licence is the one that matters operationally, because manifests are what replicate between instances (§9a) — a code licence grants nothing over them. POST /tokens returns the licence and its terms with every issued token, so the grant is one contributors actually make rather than one the server announces.

The grant covers the manifest only — timings, identifiers, audio signature. It does not, and cannot, license the underlying work.

The community instance is https://jray.tourolle.paris. The JRay plugin ships with it pre-configured but disabled — §9 requires that no traffic leave an installation until an admin opts in, so the default entry exists to save the admin from typing a URL, not to enable sharing on their behalf. Set JRAY_SERVER_ID=jray.tourolle.paris when deploying that instance: it becomes the origin stamped on manifests it first accepts (§9a) and the salt for report IP hashes.

Status

First implementation pass: the core vertical slice, reconciled against the system spec.

Per-requirement status is in docs/requirements.md — 32 requirements (UR-001..018, DR-001..014), each tracing up to an SR-nnn or PR-nnn. UR-015..018 are the pending SR-003 schema bump and are marked Planned rather than omitted.

Implemented:

  • §2 Jmanifest format and series bundles
  • §3 cut matching — exact / runtime / loose tiers
  • §4 the API surface, less POST /manifests/search
  • §5 rate limiting, in-process fixed-window counters
  • §5a trust model — anonymous bearer tokens, closed-vocabulary storage, automatic revocation
  • §6 upload validation, all four stages
  • §7 relational storage, no JSON blobs on the write path
  • §8 Rust + Axum + SQLite, single serialized writer, in-process job queue
  • §9a content addressing (content_id), computed on upload
  • §9a federation — change feed, fetch by content_id, batch have, peer directory, capabilities, and a pull worker that re-derives judgement rather than inheriting it: every pulled manifest runs the full §6 validation and this server's own cast check, and the body is verified to hash to the content_id requested before it is stored

Schema version 2 (SR-003). jmanifest_version moved to 2 in lockstep with the truth file's schema_version — breaking changes are batched and ship together across all three repos. It is a flag day: version 1 is rejected outright rather than carried alongside, because a v1 read path would be the one nobody exercises and so the one that rots.

Reconciled with the system spec (see docs/requirements.md for the detail):

  • anneal_sec removed. Withdrawn upstream by AR-012/AR-013 — presence now follows track extent, so a track survives its own gaps and there is nothing to anneal. Its successor extinction_sec and the new gallery_scope are accepted, stored, and (for scope) used in §7 ranking. A manifest still carrying anneal_sec is now a hard 400, not silently ignored: it was produced by a pipeline whose window semantics differ from what this server assumes.
  • Windows carry belief and route. scenes are objects rather than float pairs: { "start", "end", "belief", "route" }, where belief is the posterior that justified the claim and route is live/deferred/pooled. Both are excluded from content_id — belief is a producer-side estimate that may differ between pipeline versions for identical timings, so hashing it would give two servers different ids for the same content. src/content_id.rs is unchanged by the bump and its golden vector still passes, which is the evidence rather than the claim.
  • Audio signature: the 120 s rule now matches both producers. An earlier draft of §3 allowed a shortened window for items under 150 s; that conflicted with scene-actor-extraction IR-007 and was the weaker rule, since a caller-varying length is the property SR-004 forbids. Items under 120 s now send no signature at all.

Deferred:

  • §3 audio signatures — the field is accepted, validated and stored, and content_id already excludes it, but audio-tier matching and POST /manifests/search are not wired up. This follows §3's own recommended sequencing: ship the plugin-side computation first, let signatures accumulate, then enable matching once coverage is useful. (Federation landed — see below.)

Running

cargo run

Configuration is entirely environment variables:

Variable Default Purpose
JRAY_BIND 127.0.0.1:8080 Listen address. Terminate TLS at the operator's proxy (§8)
JRAY_DB jray.db SQLite path. WAL mode, created on first run
JRAY_TMDB_API_KEY — Hard dependency for UR-3. Without it, uploads stay pending and are never listed
JRAY_TMDB_BASE_URL https://api.themoviedb.org/3 Override for testing
JRAY_TRUSTED_PROXIES — Comma-separated proxy IPs whose X-Forwarded-For is honoured. Not default-on: §5 rate limiting and report attribution key on client IP, so a spoofable header defeats both
JRAY_SERVER_ID localhost This server's identity, used as manifest origin and as the report IP-hash salt
JRAY_REQUEST_TIMEOUT_SEC 30 Request timeout so a slow bundle query fails fast
JRAY_JOB_BATCH 8 Cast-check jobs leased per worker tick
JRAY_JOB_POLL_SEC 5 Worker poll interval
JRAY_LOG info tracing filter

Contributing requires a token (§5a) — an anonymous bearer capability, not an account. Self-issue one:

curl -sX POST -H 'content-type: application/json' -d '{}' \
  http://127.0.0.1:8080/api/v1/tokens

Deployment

Install from the apt repository

sudo install -d -m0755 /etc/apt/keyrings
curl -fsSL https://gitea.tourolle.paris/api/packages/dtourolle/debian/repository.key \
  | sudo gpg --dearmor -o /etc/apt/keyrings/gitea-dtourolle.gpg
echo "deb [signed-by=/etc/apt/keyrings/gitea-dtourolle.gpg] \
https://gitea.tourolle.paris/api/packages/dtourolle/debian stable main" \
  | sudo tee /etc/apt/sources.list.d/jray-server.list
sudo apt update && sudo apt install jray-server

The install asks for the settings that matter — public hostname, listen address, trusted proxies, TMDB key, contact — because two of them fail silently when unset rather than loudly (see §8, DR-016). Change them later with:

sudo dpkg-reconfigure jray-server

The package ships one static binary, the systemd unit, and an example nginx site at /usr/share/doc/jray-server/examples/nginx-jray-server.conf. The example is not installed into nginx: the proxy usually runs on a different host, so copy it to whichever machine that is.

Prefer not to add a third-party apt source? Every tagged release also carries the .deb as an asset — sudo dpkg -i jray-server_*.deb does the same thing, minus upgrades.

Notes

§8's deployment notes are requirements, not suggestions:

  • Enforce the body cap at both the proxy and the app. client_max_body_size (nginx) / request_body max_size (Caddy) should match §6 stage 1, so oversized uploads are dropped at the edge and never occupy an application worker. The app must also be safe when run without a proxy, which it is.
  • Set JRAY_TRUSTED_PROXIES to the proxy's address, or X-Forwarded-For is ignored and every client behind it shares one rate-limit bucket.
  • Back up the SQLite file with VACUUM INTO or the backup API — never a plain file copy of a live WAL database. Manifests represent real CV compute.

Tests

cargo test                      # 212 tests
cargo deny check                # advisories, licences, bans, sources
scripts/traceability-gate.sh    # requirement coverage

The traceability gate needs the shared tooling submodule:

git submodule update --init --recursive

It reports coverage against docs/requirements.md, flags orphan tags (an ID no register defines), and fails on a >100% ratio — the signal that the computation itself is broken. Currently 25/32 (78%); the untraced remainder is UR-007, which is plugin-side.

Unit tests per module, plus two integration suites:

  • tests/api.rs — end-to-end through the real router: status codes, headers, and the properties that only hold if the layers compose correctly (per-route body caps, rate-limit surfaces, the strict schema actually reaching uploads).
  • tests/injection.rs — that hostile input cannot escape its layer: SQL payloads in query parameters, path segments, JSON bodies, bearer tokens and report notes; JSON structure abuse; CRLF header injection; path traversal; and Unicode tricks against the §5a character class.

Security-relevant properties are asserted rather than assumed — movie/jellyfin_id rejection, the §5a character class defeating base64/hex smuggling, per-route body caps, a lying Content-Length not bypassing the cap, and a forged X-Forwarded-For not resetting a rate-limit budget.

On injection specifically. Two independent defences apply, and they fail differently, so both are tested:

  1. Parameterised queries. Every value reaches SQLite through params![]. The only format!-built SQL interpolates two compile-time constants (a column list and a status literal) — no runtime input ever becomes SQL syntax. This is what actually prevents injection.
  2. Closed-vocabulary validation. Identifiers are regex-constrained and free text is limited to a closed character class, so most payloads never reach the query layer at all.

The injection suite would still pass on defence 1 alone, which is deliberate: if validation were ever loosened, the tests should not silently start depending on it.

Writing that suite found two real gaps, both since fixed:

  • Compatibility homoglyphs (𝐒𝐭𝐞𝐯𝐞, Actor) passed the §5a class. They are letters by Unicode category and NFC does not fold them — only NFKC would. Beyond name spoofing, a fullwidth-digit alphabet would have reopened the encoding channel the "no digits" rule exists to close.
  • A one-frame audio_signature was accepted on a feature-length manifest, making the field the variable-length container §3 explicitly forbids. The length floor is now derived from the declared runtime, keeping §3's genuine short-item exception without trusting the client's length.

Two tests worth knowing about:

  • content_id::tests::golden_vector_hash_is_stable locks the §9a canonical form. The JRay plugin must reproduce it byte-identically; §8 notes the extraction side is Python, so this can no longer be one shared implementation and must be cross-tested instead. GOLDEN_VECTORS is that fixture, and its hash was verified against an independent Python implementation.
  • The integration tests use an on-disk temporary database, not :memory:, because §8's topology is one writer connection plus a read pool — in-memory SQLite is per-connection, so the readers would see an empty database.

CI and container image

.gitea/workflows/ci.yml runs on Gitea Actions (and unmodified on GitHub Actions), gated fastest-first: fmt → clippy → test → cargo deny → static musl build. The dependency audit also runs weekly, since advisories appear without any code changing.

The Dockerfile is the optional convenience, not the intended deployment path — §8 is explicit that neither Docker nor Compose should be required. It builds against musl and runs from scratch as uid 65534: 8.7 MB, no libc, no shell, no package manager. rusqlite bundles SQLite and reqwest uses rustls, so there is nothing left to link.

docker build -t jray-server .
docker run --rm -p 8080:8080 -v jray-data:/data -e JRAY_TMDB_API_KEY=... jray-server

Two things that are easy to get wrong, so they are handled explicitly:

  • Static linkage is asserted with file, not ldd. The musl target produces a static-PIE, and ldd prints the musl loader for one — an ldd-based check reports a perfectly static binary as dynamic.
  • /data is staged with the runtime uid's ownership. Docker seeds a named volume from the image directory, ownership included, so a non-root server can create the database on first run. A bind mount is not seeded this way — the host directory keeps its own ownership, so chown 65534:65534 it first or the server exits with "unable to open database file".

Two tests are worth knowing about:

  • content_id::tests::golden_vector_hash_is_stable locks the §9a canonical form. The JRay plugin must reproduce it byte-identically; §8 notes the extraction side is Python, so this can no longer be one shared implementation and must be cross-tested instead. GOLDEN_VECTORS is that fixture, and its hash was verified against an independent Python implementation.
  • The integration tests use an on-disk temporary database, not :memory:, because §8's topology is one writer connection plus a read pool — in-memory SQLite is per-connection, so the readers would see an empty database.

Known gaps

  • The §6 stage-3 thresholds are still §10's guesses. §10 (5) is explicit that running the check over the 331 real corpus files would give the true distribution of honest-upload match ratios, and is "the single cheapest way to de-risk UR-3 and UR-5". The scoring logic is deliberately pure functions in castcheck.rs so that retuning is a test-data exercise, not a code change.
  • POST /manifests/{id}/report records reports but nothing consumes them yet. §5a's divergence detection and the operator kill switch are not implemented; delisting is currently a manual UPDATE, which §5a does note is the intended shape ("one UPDATE", not a moderation queue).
  • No admin surface. Revocation is automatic (§5a), but an operator has no endpoint for the deliberate kill-switch case.
S
Description
No description provided
Readme GPL-3.0
1.1 MiB
Languages
Rust 98.6%
Dockerfile 1%
Shell 0.4%