Completes UR-009. The register recorded the signature as stored; it was not. `ingest` validated `cut.audio_signature` and wrote NULL, so every served manifest came back without one — which also meant the plugin's own alignment (jRay JR-047, Done) had nothing to align against and could never run. That is the failure mode a status field is least able to catch: every validation test passed and the feature delivered nothing. Now stored, coarse-indexed and served back byte-identically, with a held manifest adopting an incoming signature it lacked (§9a). `audio`-tier matching runs on every read endpoint via an `audio_signature` parameter, and `POST /manifests/search` answers the unknown-providence case with a runtime prefilter, a bounded scan and honest truncation reporting. Three rules §3 did not previously state, now normative: - **±1 frame of slack in the score.** scene-actor-extraction VR-014 measured the exact-frame rule demoting 27 of 40 correctly aligned releases to `loose`, because the two windows are cut on their own file's frame grid and those grids do not coincide. With ±1 frame all 40 reach `audio` (worst 0.906) and the strongest false match is unmoved at 0.16. - **The offset has two terms.** Both windows are anchored at their own file's runtime/2, so the slide alone is wrong by half the runtime difference on every shifted release. A signature without a runtime therefore cannot align, and is refused by name rather than answered at a lower tier. - **A signature verdict is final**, including its refusals. Falling back to the runtime tier after the audio declined would let a coincidence overturn direct evidence, inverting the ordering the tier table exists to state. The slide precomputes each frame's neighbourhood as a 32-bit bin set rather than re-deriving it across 1201 slides — 3.3 ms to 1.1 ms per candidate, with a test asserting exact equivalence to the rule written the obvious way. The 1000-candidate search cap follows from that measurement as a ~1.1 s ceiling per request, not a round number. jRay's matcher still implements the pre-slack rule and will label some alignments `loose` that this server calls `audio`. Nothing misaligns — JR-047 makes the local answer win — but that register now carries the follow-up. TRACES: UR-008, UR-009 | SR-003
277 lines
14 KiB
Markdown
277 lines
14 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. If you are considering **running an instance**, read
|
|
[`docs/legal-posture.md`](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`](LICENSE), declared in `Cargo.toml` |
|
|
| **Contributed manifests** | **CC0 1.0 Universal** | [`LICENSE-DATA`](LICENSE-DATA), specified in [SPEC.md](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](../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
|
|
- §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.
|
|
|
|
- **Audio signatures are complete (UR-009).** The field is accepted, validated,
|
|
stored and **served back**, `content_id` still excludes it, `audio`-tier
|
|
matching runs on every read endpoint, and `POST /manifests/search` answers
|
|
the unknown-providence case. §3's scoring rule gained ±1 frame of tolerance
|
|
on `scene-actor-extraction` VR-014's measurement — the exact-frame rule
|
|
demoted correctly aligned releases to `loose` because the two windows' frame
|
|
grids do not coincide.
|
|
|
|
The signature stays optional throughout: a manifest without one is matched by
|
|
the runtime tiers exactly as before. This is an enhancement, and §3 requires
|
|
that it never be able to break a fetch.
|
|
|
|
(Federation landed — see below.)
|
|
|
|
## 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_AUDIO_SEARCH` | `1` | `POST /manifests/search`. §3 makes it optional for a server to implement because it is the most expensive read surface; set `0` to withdraw it, and `GET /federation/capabilities` stops advertising it. `audio`-tier matching on the ordinary reads is unaffected |
|
|
| `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 # 212 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 **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.
|
|
|
|
```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.
|