Files
JRay-public-server/README.md
T
dtourolleandClaude Opus 5 a1e789a6fe
CI / fmt, clippy, test (push) Failing after 1m21s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 24s
Traceability: vendor the shared gate, annotate the source
Adds jray-project as a submodule at scripts/vendor/jray-project, so this repo
runs the same extractor as every other component rather than its own copy, and
gains the system spec that defines the PR/SR requirements its register traces
up to.

scripts/traceability-gate.sh is a thin wrapper holding only what is specific to
this repo: UR/DR prefixes, .rs sources, and REPO_ROOT — which the shared gate
cannot infer once vendored, since its default resolves to the submodule itself.
Each override fails silently in a way that looks like "no work done" rather
than "misconfigured", so the wrapper documents why each is needed.

Annotates 35 units with TRACES tags, on the code that decides rather than every
helper it calls. Coverage is 23/32 (71.9%) with no orphan tags. The nine
untraced are genuinely unimplemented: UR-007 is plugin-side, UR-008 is
federation, and UR-015..018 are the pending SR-003 schema bump.

The gate caught a real error in the first pass: several tags separated IDs of
different types with commas. A comma joins IDs within one type; a pipe
separates types. Fixed, and the diagnostics are now clean.

MIN_COVERAGE stays 0 deliberately. The gate still fails on orphan tags, a >100%
ratio, a register parsing to nothing, or an empty source scan — raise the
threshold as a ratchet once the remaining work lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:27:16 +02:00

234 lines
12 KiB
Markdown

# 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](SPEC.md) for the design. Section references throughout the code
point at it.
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](../SPEC.md).
Per-requirement status is in [`docs/requirements.md`](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
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.
- **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.
- §9a federation endpoints (`/federation/*`) and the pull worker. The schema
columns (`content_id`, `origin`, `ingested_from`, `peers`) are in place, and
`ingest::persist` is already the shared path a pull would reuse.
## Running
```sh
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:
```sh
curl -sX POST -H 'content-type: application/json' -d '{}' \
http://127.0.0.1:8080/api/v1/tokens
```
## Deployment
§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
```sh
cargo test # 189 tests
cargo deny check # advisories, licences, bans, sources
scripts/traceability-gate.sh # requirement coverage
```
The traceability gate needs the shared tooling submodule:
```sh
git submodule update --init --recursive
```
It reports coverage against [`docs/requirements.md`](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 **23/32 (71.9%)**; the
untraced nine are UR-007 (plugin-side), UR-008 (federation) and UR-015..018 (the
pending SR-003 bump), none of which is implemented yet.
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.
```sh
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.