Skip to content

Dependency majors, the upstream review's fixes, known-device sign-in and a security-scan pass - #40

Merged
Dim145 merged 46 commits into
mainfrom
chore/deps-2026-10-majors
Oct 25, 2026
Merged

Dim145 merged 46 commits into
mainfrom
chore/deps-2026-10-majors

Conversation

@Dim145

@Dim145 Dim145 commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Four pieces of work, each reviewed and verified on its own before being merged here: 46 commits on top of 0.37.4. One commit per change throughout, so the history reads as the list below.

Dependencies, majors included (10 commits)

  • Node 26 and pnpm 12 in every image, CI job and script. Node 25 stopped shipping corepack, so the Dockerfiles install pnpm with npm at the version packageManager pins, and CI reads it from there.
  • Go 1.27 for the tracker and the relay, with pgx 5.11 and go-redis 9.23. No code change was needed.
  • API: @simplewebauthn/server 14 (14.0.2 is a security release on certificate revocation lists), vitest 5, prom-client replaced by @prometheus-io/client, drizzle-orm 0.45.4 and drizzle-kit 0.31.11.
  • Web: TypeScript 6, vitest 5, @nuxt/fonts 1, VueUse 15, and Tailwind CSS 4 through its Vite plugin, the design tokens moved into an @theme inline block. Measured against the Tailwind 3 build: the demo stack's 55 pages in both themes, side by side, plus keyboard focus and hover on nine pages. The one visible change kept: date inputs are 2 px shorter and now match the text inputs beside them.
  • Compose and charts: PgBouncer 1.26.1 (security releases, two of whose fixes reach this stack), Redis 8.10.2, Caddy 2.11.7, RustFS 1.0.3; the CloudNativePG, Valkey and RustFS subcharts; the pinned actions.

Fixes from the review of upstream trackarr (21 commits)

  • Exposure: a member's upload list served the raw .torrent files, passkeys included; any member could delete or overwrite another member's peer entry; members could comment on and favorite torrents they cannot see; the proxy logs kept who downloaded and announced what; dormant accounts kept their verifier in clear.
  • Sign-in: it banned whole addresses (one stranger behind a carrier NAT locked everyone out) and told which usernames exist. It now has its own per-address bucket and a per-account lockout. Passkey 2FA sign-ins and bans ignored the session epoch.
  • Tracker: seed time was credited at half the real rate; left=0 got around the ratio gate and earned seed bonus; a Redis outage refused every announce; peers per member were unbounded and a bad passkey was costly.
  • API: a panic restore could become impossible to complete; hit-and-runs were flagged only when a moderator asked; the API gave up on Redis for good after an 11-second outage; bonus events lapsed whenever nobody visited the site.
  • Deploy and web: default installs advertised a UDP tracker nothing served; the site rules were editable and shown nowhere; what upstream left behind.
  • Then a second read of those fixes (five holes), the guides brought in line with the code, and an integration test that failed on every re-run.

Known-device sign-in (2 commits)

The per-account lockout let anyone who knows a username keep its owner out with one failure every ninety seconds. A browser that completes a sign-in now gets a known-device cookie (trackarr_kd, httpOnly, 180 days), and on that browser the account's count does not apply. The cookie is stateless, an HMAC over the account, its session epoch and its stored verifier, so "sign out everywhere", a ban or a password change voids every one at once. Also fixed on the way: three pages read an API error's details one level too high.

Security scan (10 commits)

semgrep, trivy, gitleaks, gosec, hadolint, actionlint, zizmor, govulncheck and osv-scanner, run in containers, now report nothing on this tree. The real findings:

  • Redis connection errors printed a rediss:// password; a " in a Telegram link closed its href; nine log calls put their values in the format string.
  • The tracker and the relay set X-Content-Type-Options: nosniff themselves, rather than relying on the shipped Caddyfile.
  • The api and web images name their user by number (65532:65532), which runAsNonRoot can check.
  • The release job reads its values from env and creates the release with gh instead of a third-party action.
  • The chart gains externalRedis.tls (a Redis reached over TLS) and resources for Valkey's init container.

Every other finding is recorded where its tool reads it, with the reason beside it: inline annotations, .trivyignore.yaml (scoped by path), osv-scanner.toml, .gitleaksignore, .semgrepignore. The pass also found two greens that were not checks: the published gosec image analysed nothing of a go1.27 module, and trivy skipped the Helm chart whenever its subcharts were not vendored.

And one docs commit: the roadmap still said there was no per-account lockout.

Verified, on the final tree

  • Typecheck; the API's 630 unit and 486 integration tests; the web's 1,245 unit tests.
  • The tracker and the relay: gofmt, go vet, and their tests under -race.
  • helm lint and every assertion of check-render.sh, with default and production values.
  • The six images build; the e2e suite passes (558 checks over 14 scenarios), with nosniff checked on the live tracker and relay.

docker.yml and the release job never run on a pull request. release-tag.yml changed here, so its first run will be the release itself.

🤖 Generated with Claude Code

Dim145 and others added 30 commits October 24, 2026 23:49
  node (images, CI)        24          -> 26        Active LTS from 2026-10-28
  distroless nodejs        24-debian13 -> 26-debian13
  pnpm                     11.22.0     -> 12.15.0   the Rust rewrite
  @types/node              24.13.3     -> 26.6.5    follows the runtime
  tsx                      4.23.13     -> 4.23.15

Node 25 stopped shipping corepack, so neither node:26-alpine nor setup-node
has one. The Dockerfiles install pnpm with npm, at the version the root
package.json pins; CI reads that version out of `packageManager`, which
stays the one place it is written. `--allow-scripts=pnpm` names the one
install script we let run — pnpm's own, which only hard-links its native
binary — because npm 11.20 warns about unlisted scripts and is moving to
blocking them.

pnpm 12 rejects `confirmModulesPurge` as "a pnpm v11 setting"
(ERR_PNPM_UNRECOGNIZED_WORKSPACE_SETTINGS): with the version pinned, an
unknown key in pnpm-workspace.yaml is an error now. It also has no prompt
left for the setting to answer — it rebuilt node_modules after a hoisting
change with stdin closed. The lockfile gains a leading YAML document,
`packageManagerDependencies`, that pins pnpm and its per-platform binaries;
a frozen install refuses a lockfile whose pin does not match. Re-resolving
with pnpm 12 rewrote some peer suffixes and moved giget (a devtools
dependency) from 3.3.1 to 3.2.0.

Node 25 also turned Web Storage on. On a server that is one `sessionStorage`
shared by every request the process serves, plus a warning whenever
something probes `localStorage`. Nothing of ours uses either server-side,
but the SSR process renders for every visitor, so the api and web images
add `--no-experimental-webstorage` to NODE_OPTIONS: the globals Node 24 did
not have, it does not have either.

@types/node 26 aligns WebCrypto's BufferSource with lib.dom and refuses a
view that could sit on a SharedArrayBuffer; the two digest helpers in
didProof.test.ts now take `Uint8Array<ArrayBuffer>`, which is what
canonicalUtf8 already returns. The Nitro bundles target node26.

Measured: on node:26-alpine with pnpm 12.15.0, `pnpm typecheck` passes,
and so do the API (586) and web (954) unit suites; the API image builds and
its runtime reports v26.12.1 with the flag set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  drizzle-orm  0.45.2  -> 0.45.4   packages/db and apps/api, kept in step
  drizzle-kit  0.31.10 -> 0.31.11

0.45.4 fixes postgres-js ignoring an explicit `.prepare(name)`. Nothing here
calls it — the client prepares everything through `prepare: true` — so the
fix changes no query we send. 0.45.3 only adds a Netlify driver.
drizzle-kit 0.31.11 makes `drizzle-kit check` warn about migrations it
would skip in silence, the trap behind the hand-written 0071-0073.

Checked against a throwaway Postgres 18.6: `check:schema-parity` passes all
three checks (generate produces nothing, 79 journal entries, the chain
applies — 208 tables, 1719 columns, 589 indexes), `drizzle-kit check` is
clean, and the typecheck and API unit suite pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lient in the API

  @simplewebauthn/server  13.3.3  -> 14.0.3
  prom-client             15.1.3  -> @prometheus-io/client 0.16.1
  vitest                  4.1.12  -> 5.0.5
  nitropack               2.13.4  -> 2.13.5
  marked                  18.0.10 -> 18.1.1
  pino                    10.3.1  -> 10.4.0
  pino-pretty             13.1.3  -> 13.2.0
  zod                     4.6.6   -> 4.6.7
  uuid                    14.0.2  -> 14.0.3
  @iconify/utils          3.1.4   -> 3.1.7

@simplewebauthn/server 14's one breaking change is a Node 22 floor. 14.0.2
is a security release (GHSA-2g3p-m8c9-hhwh, GHSA-j3h4-m3m2-7p7j: CRLs are
now only trusted when they chain to an RP-chosen anchor). 14.0 also puts
ML-DSA-44 first in the default algorithm list; we pass no list, so
registration offers it, and an authenticator that does not know it picks
the next one.

prom-client is deprecated in favour of @prometheus-io/client — the same
code, now a Prometheus subproject. The switch had been put off on
2026-09-02 because 0.16.1 was six days old; utils/metrics.ts is the only
caller and only the import moves. Its 0.16 breaking changes (MetricType as
a string union, AggregatorRegistry renamed, cluster IPC renamed) touch
nothing here; it adds nodejs_eventloop_utilization_{summary,histogram} to
the default metrics, and doc/reference/metrics.md says so.

vitest 5: the API config already sets `clearMocks: true` (now the default),
and every vi.mock / vi.hoisted call is at module level, where 5.0 still
allows it.

h3 stays on 1.15.11: npm's latest is 2.x, but nitropack 2.13.5 depends on
h3 ^1.15.11, and the Nitro that speaks h3 2 is still a beta (3.0.x-beta).

Measured: typecheck, 586 unit tests, and the integration suite — 32 files,
471 tests, including the 7 S3 tests — against Postgres 18.6, Redis 8.10.2
and RustFS 1.0.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  go (go.mod, images, CI)  1.26    -> 1.27     both modules
  github.com/jackc/pgx/v5  5.10.0  -> 5.11.0   tracker
  go-redis/v9              9.22.0  -> 9.23.0   tracker and relay
  miniredis/v2             2.38.0  -> 2.40.0   tracker tests
  golang.org/x/{sys,sync,text}, puddle, gopher-lua, go.uber.org/atomic: latest

Go 1.27 needed no code change: gofmt (whose alignment rules moved) leaves
both trees untouched, and go vet's new printf check for `%w` on a pointer
error finds nothing. encoding/json is now backed by v2, timers stay
synchronous as they have been since the go.mod line passed 1.23.

pgx 5.11 parses connection strings the way libpq does (a literal `+`,
last repeated key wins, bracketed IPv6, empty port means 5432) and adds
`Rows.TypeMap`; our DATABASE_URL forms are unaffected and nothing here
implements pgx.Rows. It also hardens decoders and redacts passwords from
parse errors.

go-redis 9.23 gives every client a dedicated pipeline pool. It is capped at
10 connections, never pre-dials, and spills to the main pool when full, so
the tracker can hold at most ten more Redis connections; `?pipeline_pool_size=`
in REDIS_URL tunes it. The 3 s timeouts the tracker pins are untouched.

Measured on golang:1.27: gofmt, go vet, go test and go test -race pass for
both modules, govulncheck finds no vulnerabilities, and both images build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he web app

  typescript               5.9.3   -> 6.0.3    the last release with a JS API
  vue-tsc                  3.3.11  -> 3.3.13
  vitest                   4.1.12  -> 5.0.5
  @nuxt/fonts              0.14.0  -> 1.1.0
  @nuxt/icon               2.5.1   -> 2.6.0
  @vueuse/core             14.4.0  -> 15.0.0
  @simplewebauthn/browser  13.3.0  -> 14.0.0
  @tiptap/*                3.31.0  -> 3.32.0
  pinia, @pinia/nuxt       4.0.3, 1.0.2 -> 4.1.0, 1.0.3
  chart.js                 4.5.1   -> 4.6.1
  isomorphic-dompurify     4.1.0   -> 4.6.0    DOMPurify 3.4.17
  marked, mediainfo.js, @iconify-json/simple-icons: latest patch

TypeScript stops at 6.0. vue-tsc 3.3.13 still needs the JavaScript
compiler API, which TypeScript 7 no longer ships; what it learned is to
follow a `typescript` aliased to @typescript/typescript6 down to the tsc 6
inside it. @vue/language-core 4.0.0-alpha.0 wants typescript >=7.1.0-beta.
`nuxt typecheck` passes on 6.0.3, and an error planted in a .vue file on
purpose is reported (TS2322), so the pass is real. pnpm-workspace.yaml
records why the two TypeScripts stay apart.

@nuxt/fonts 1.0 serves the faces from /_nuxt/fonts/ instead of /_fonts/.
The static image's `^~ /_nuxt/` block already gives them a year as
immutable, so its `/_fonts/` block goes; the CSP needs nothing, since
'self' covers both. Measured on the running stack: the woff2 files answer
200 with `max-age=31536000, immutable` in both shapes, and Inter and
JetBrains Mono load.

Reading the module also showed that the Docker build cache for the faces
never worked. @nuxt/fonts keeps it under Nuxt's rootDir
(apps/web/node_modules/.cache/nuxt/fonts, 3 MB) — 0.14 got the same path
from the working directory pnpm runs the script in — while both
Dockerfiles mounted /app/node_modules/.cache/nuxt, which nothing writes
to. The mount now targets the real path; the static build that followed an
SSR build downloaded nothing.

VueUse 15 drops templateRef, Node 20 and the timer options replaced by
`scheduler`; our one import, watchDebounced with `{ debounce }`, is
unchanged. @simplewebauthn/browser 14's only breaking change is the
runtime floor. tiptap 3.32's @tiptap/pm now requires prosemirror-view
^1.42.6 and prosemirror-model ^1.25.12 on its own, so the two overrides
that held those floors for 3.31 are gone; the resolution is identical
without them, one copy of each. vue-router, unhead and devalue@5 stay, as
nuxt 4.6.2 and @nuxtjs/i18n 10.6.0 have not moved.

Not here: Tailwind CSS 4. @nuxtjs/tailwindcss 6.14.0 is the module's latest
and only speaks Tailwind 3.

Measured: typecheck, 954 unit tests, both image builds, and the e2e suite
(543 checks, 0 failures) against both front shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  @tailwindcss/vite, tailwindcss  4.3.0  -> 4.3.3
  vue                             3.5.42 -> 3.5.44 (spec; 3.5.44 was already locked)
  overrides: esbuild ^0.28.2, postcss ^8.5.31

VitePress stays on 1.6.4, its latest release (2.0 is an alpha), and its
vite override stays on 7, now at its last release, 7.3.7. Vite 8 was
tried: VitePress 1.6.4's plugin assigns to the bundle object, which
rolldown refuses ("not supported by Rolldown"), and the build produced no
page at all.

Tailwind 4.3.3 also stops calling module.register(), which Node 26 has
deprecated (DEP0205): the warning the 4.3.0 build printed is gone.

`allowScripts: { esbuild: true }` names the one install script the site
needs, as allowBuilds does for the workspace. npm 11.20 warns about
unlisted scripts and is moving to blocking them; the entry is unpinned so
an esbuild bump does not bring the warning back.

Measured on node:26-alpine: `npm ci` and `npm run build` pass, 60 pages,
`npm outdated` is empty and `npm audit` finds nothing; the image builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….3, Dockerfile syntax 1.28

  edoburu/pgbouncer   v1.25.2-p0 -> v1.26.1-p0   compose
  redis               8.10.1     -> 8.10.2       compose, e2e, CI, scripts
  caddy               2.11.4     -> 2.11.7       compose
  rustfs/rustfs       1.0.0-rc.3 -> 1.0.3        API CI
  docker/dockerfile   1.7        -> 1.28         the four Node Dockerfiles
  postgres            18.6       (unchanged: 19 is a release candidate)

PgBouncer 1.26.0 and 1.26.1 are security releases. Two reach this stack:
CVE-2026-107350 lets a user who knows one SCRAM password in auth_file log
in as another SCRAM user — our pooler is scram-sha-256 over a userlist —
and CVE-2026-6668 loops forever on a packet before authentication, at the
default max_packet_size. 1.26 also tracks search_path and
default_transaction_read_only by default in transaction pooling and drops
online restart, which nothing here uses. The image's entrypoint is byte-
for-byte the one of v1.25.2-p0. Tested against Postgres 18.6 with the
compose file's own environment: SCRAM login and 400 executions of four
prepared statements interleaved over ten connections, transactions
included, with no error.

Redis 8.10.2 is a security release too (transactions could still reach
keys whose ACL had been revoked while queued).

Caddy 2.11.6 changed defaults: 16 KiB request headers, 1-minute idle
read/write timeouts (pauses between writes, as in SSE, do not count; both
our streams beat every 30 s), header names with a dot dropped, and some
silent configs made errors. The Caddyfile validates on 2.11.7 and adapts
to byte-identical JSON on 2.11.4 and 2.11.7. `encode` now streams SSE
instead of buffering it.

RustFS 1.0.3 ran the API integration suite's 7 object-storage tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  cluster (CloudNativePG)  0.8.1  -> 0.9.0
  cloudnative-pg           0.29.0 -> 0.29.1   operator 1.30.1
  valkey                   0.11.0 -> 0.12.0   Valkey 9.1.1 -> 9.1.2
  rustfs                   0.12.0 -> 1.0.3    RustFS 1.0.0-beta.12 -> 1.0.3

Every change in the subcharts' default values is an addition. Rendered
with the defaults, with S3 + RustFS and with the operator enabled, the
manifests differ only in random secrets, image tags and the operator's
CRDs. The RustFS chart 0.12 was deploying a beta; 1.0.3 is the release the
API CI already tests against.

check-render.sh had one assertion failing before this change, with the old
subcharts too: it expected rendering S3 + RustFS without credentials to be
refused. It dates from before 3eba6d5, which made the chart generate the
pair and hand it over by Secret name — rendering now succeeds by design.
The assertion checks that instead: the generated Secret exists and every
secretKeyRef resolves. All render checks pass on the new subcharts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  actions/setup-node           7.0.0 -> 7.1.0
  docker/setup-buildx-action   4.3.0 -> 4.4.1
  docker/build-push-action     7.3.0 -> 7.4.0
  actions/deploy-pages         5.0.0 -> 5.0.1
  softprops/action-gh-release  3.x   -> 3.0.3

Still pinned by commit SHA, the tag in the trailing comment. None of the
release notes lists a breaking change; build-push 7.4.0 fixes a workflow
command injection through its metadata logs.

Left out on purpose: docker/login-action 4.7.0, published 2026-10-23.
dependabot.yml gives actions a 7-day cooldown, and this one holds the
registry credentials.

actionlint and zizmor (medium and above) find nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  tailwindcss          3.4.19 (via @nuxtjs/tailwindcss 6.14.0) -> 4.3.3
  @tailwindcss/vite    -> 4.3.3, replacing the Nuxt module
  tailwind.config.ts   -> the `@theme inline` block at the top of main.css

@nuxtjs/tailwindcss 6.14.0 is the module's latest release and pins
tailwindcss ~3.4.17, so Tailwind 4 comes in through its own Vite plugin, and
main.css is loaded through `css:` like any stylesheet.

The design tokens share Tailwind 4's theme namespace (`--font-sans`,
`--radius-md`, `--ease-standard`, `--shadow-*`, 20 names in all). The
official upgrade tool turns that into `@theme { --radius-md: var(--radius-md) }`
and rewrites prose inside comments, so its output was not used as is.
Instead:

  - `@theme inline` translates the config key by key, with Tailwind 3's key
    names. Utilities carry the value written there — `rounded-sm` is still
    `var(--radius-xs)`, bare `rounded` still `var(--radius-sm)` — so no class
    in a template changes meaning.
  - The tokens are unlayered and what Tailwind emits lives in `@layer theme`,
    so wherever a name collides, the token wins the cascade; operator themes
    from /api/theme.css are unlayered too.
  - `@layer components` and `@layer utilities` stay native cascade layers.
    Nothing applies a variant to those classes; the one `@apply` of a custom
    class (`.eyebrow` inside `.eyebrow-mono`) became a grouped selector.
  - Three Tailwind 3 defaults are restored in `@layer base`, because elements
    relying on them would change look without a class changing: gray-200
    for a bare `border`, gray-400 placeholders, the pointer on buttons.
  - `hover:` keeps Tailwind 3's behaviour (`@custom-variant hover (&:hover)`):
    Tailwind 4 drops it on touch screens, which would leave hover-revealed
    controls unreachable there.
  - Tailwind 4 redrew its default palette in oklch, more saturated (sky-400
    loses 56 of red, amber-400 36 of blue). The nine shades in use — eight in
    templates, gray-400 for placeholders — keep Tailwind 3's values in a
    plain `@theme` block; deleting it adopts the new palette.
  - `bg-linear-to-r` blends in oklab where `bg-gradient-to-r` blended in
    sRGB, so the two Torznab gauges say `/srgb`.
  - Removed utilities renamed, 49 occurrences in 16 components, two of them
    comments: `flex-shrink-0` -> `shrink-0` (37), `outline-none` ->
    `outline-hidden` (5), `backdrop-blur-sm` -> `backdrop-blur-xs` (4),
    `bg-gradient-to-r` -> `bg-linear-to-r` (2), `flex-grow` -> `grow` (1).
    The two components that `@apply` in their own <style> get
    `@reference "../assets/css/main.css"`.
  - focusRing.test.ts now catches both of Tailwind 4's ring-killers:
    `outline-hidden` (the old `outline-none`) and the new `outline-none`.

One visible change is kept: date and datetime-local inputs are 2 px shorter,
time inputs 1.15 px, because Tailwind 4's preflight drops the padding Chrome
puts around their fields. They now match the text inputs beside them, which
Tailwind 3 overshot by 2 px; the filter row of /admin/audit sat 2 px low.

Measured against the Tailwind 3 build of the same tree:

  - The e2e demo stack's 55 pages in both themes, captured on a Tailwind 3
    and a Tailwind 4 front running side by side, seconds apart: beyond the
    date inputs, only a 1/255 rounding on translucent fills, which Tailwind 4
    composes with color-mix().
  - Keyboard focus and hover on nine pages (357 focus stops, 287 hovered
    elements): the same rendered colours, outlines and shadows.
  - Every palette class the templates use, the bare border, the placeholder
    and the gauges' gradient render the same pixels in both themes.
  - Class names in the shipped CSS: 5617 in both builds. Only in Tailwind 3:
    the six removed utilities and six words its scanner lifted from
    JavaScript or CSS values (`if (!input)`, `text-transform: capitalize`).
    Only in Tailwind 4: four new names, three scanner words (`h-264`,
    `h-265`, `@container`), and `.h-section`, `.h-sub` and `.truncate-hash`,
    which main.css defines and no template uses: Tailwind 3 dropped them,
    Tailwind 4 ships native layers as written.
  - The entry stylesheet grows from 10.4 to 12.8 kB gzipped: Tailwind 4's
    @Property registrations and the fallbacks it writes beside each
    color-mix(). Component stylesheets are unchanged but for Vue scope ids,
    and TagInput's, whose `@apply` compiles to Tailwind 4's output. Vendor
    prefixes are identical (24 -webkit-backdrop-filter, 17
    -webkit-user-select, 11 -webkit-mask).
  - The typecheck, the 954 unit tests and the e2e suite (543 checks) pass;
    both images build.

Browser floor: Tailwind 4 needs Safari 16.4, Chrome 111, Firefox 128.

Also: with @nuxtjs/tailwindcss gone, neither postcss-selector-parser branch
is in the tree any more (cssnano 9 no longer needs it); the two floors stay
as guards, and their note says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…included

GET /api/users/:id/uploads loaded its torrents with no projection and
spread each row into the response, `torrent_data` included: any
signed-in member could read the raw .torrent of another member's
accepted, non-anonymous uploads, and the uploader and staff could read
all of them. Until d5d50b7 the upload kept `announce` and
`announce-list`, so a file made with the uploader's personal announce
URL — the normal case — carried their passkey, and nothing ever cleaned
the rows stored before that. The route no longer loads the column, and
neither does the Torznab search, which loaded a whole file per result
without reading it.

Cleaning those rows exposed a second fault. bencode@4 decodes every
dictionary key to a string and re-spells in hex any key that is not
valid UTF-8; encoding then writes the hex. The keys of a BEP 52
`piece layers` dict are raw 32-byte Merkle roots, so every v2 or hybrid
torrent that went through the upload, and again through each download,
came out with its piece layers re-keyed — and a v2 client cannot verify
a single piece without them.

Nothing decodes a .torrent to edit it any more. bencodeScan.ts walks
the structure without interpreting it (the scanner bittorrentV2.ts
already used to locate `info`, moved out), and torrentBytes.ts splices
byte ranges, copying everything it does not edit: the upload's
normalisation (private flag forced, uploader fields dropped), each
member's copy at download, and the duplicate check, which now hashes
the normalised file. A torrent uploaded without the private flag is
stored with it, under another infohash, so hashing the raw bytes
answered "not on the tracker" for a release that was.

The rows already stored are walked once by a resumable sweep
(plugins/backfill-torrent-files.ts, 200 rows a minute under a
cross-replica lock): uploader fields dropped, and a 64-character hex key
turned back into the 32 bytes it spells when it names a `pieces root` of
the same file. The `info` dict must come out byte-identical or the row
is left alone, so no infohash moves under a swarm.

Both download routes now take their announce URLs from one builder
(utils/announceUrls.ts), which reads TRACKER_UDP_ENABLED exactly as the
tracker does. A Torznab grab gets the same tiers as a click on the
site, where it used to get `announce` alone.

torrentBytes.test.ts (18 tests) covers the editor, and
torrentFileCleanup.itest.ts the sweep against Postgres.

Review of upstream trackarr, October 2026: B1 and B18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Panic mode encrypts users, torrents, posts and comments in place, and
leaves the open sessions alone so staff keep a way in. Outside the
panic routes, only the federation gate read `panic_state`, so those
sessions went on uploading, registering members, posting and resetting
keys, and every such row landed in clear in a database announced as
encrypted. The restore decrypted strictly: one clear value threw, the
restore answered 500 and kept its salt, and the next attempt failed on
every row the first had already decrypted. One write during the panic,
or one interrupted restore, and no restore could finish without SQL by
hand.

The restore keeps a value that does not decrypt under the verified key
(`decryptFieldOrKeep`): the password is checked before the key is
derived, so such a value is clear text, not a wrong key. The `.torrent`
payload follows the same rule (`decryptTorrentDataOrKeep` keeps the
stored bytes when they already form a bencoded dict with `info`): it
was overwritten with null whenever it did not decrypt, so a retry wiped
the file of every torrent the previous attempt had restored. And a
torrent whose PANIC_META does not decrypt is left untouched, rather than
rewritten without the size and category the retry needs.

The API is also read-only for the whole panic (middleware/panic-lock.ts):
every POST, PUT, PATCH and DELETE answers 503 `panic-lock`, except the
panic routes themselves and logging out. `panicActive()` is
`is_encrypted || phase != 'idle'`, cached five seconds in Redis and
cleared by the panic routes as they change the state; `is_encrypted`
alone stays false for the whole encrypt pass, and an interrupted pass can
stay there. The federation gate reads the same answer — the mint loop
could sign and publish records of torrents already encrypted — and so do
the catalogue sweeps, which would walk past encrypted rows and never
come back for them: content roots, content signatures (stamped empty for
good), series positions (the cursor marked done) and the `.torrent`
cleanup. `GET /api/admin/panic` reports the phase. The integration setup
truncates `panic_state`: a test that left the instance in panic turned
every later file read-only.

Checked on a production build of the API against Postgres 18.6: a
restore stopped by a row whose metadata does not decrypt, then retried
once the row is put back, finishes with both `.torrent` files
byte-identical. With `torrent_data` decrypted as before, the retry
answers 500 again and leaves both files NULL. panic.test.ts,
federationPanic.itest.ts and torrentFileCleanup.itest.ts cover the
primitives, the gate and the sweep.

Review of upstream trackarr, October 2026: B2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3e6d848 stopped a hundred peer_ids from each crediting the same stretch
of time to one `hnr_tracking` row: it capped `elapsed` at
`minAnnounceInterval`, 900 s, behind a dedup window of the same length.
But clients re-announce every `announceInterval`, 1800 s, so each
announce credited 900 of them: half the time actually seeded, and every
hit-and-run requirement doubled.

How far an account has been credited on a torrent is now a mark in
Redis (`seedtime:<torrent>:<user>`), claimed by a Lua script
(`peers.ClaimSeedTime`): an announce credits from the mark, or from its
own baseline if that is later, up to now — at most two announce
intervals — and moves the mark to now. The stretches are disjoint
whatever the number of peer_ids or replicas, so the total never exceeds
real time, and a lone seeder who follows the interval is credited all
of it. `dedup.Release`, which handed the window back when the write
failed, goes with the window: a failed write now loses that one
interval, counted in `seedTimeDropped`, and the next announce credits
from the mark as usual.

Measured end to end on Postgres and Redis 8.10: 1802 s credited for
thirty minutes of seeding (900 before), 3 s for a second peer_id over
the same half hour, 3600 s for a baseline three hours old. Four Go
tests.

Review of upstream trackarr, October 2026: B3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`checkAndMarkHnrs()` is the only code that sets `is_hnr`, and only
`GET /api/admin/hnr?check=true` called it — already before the
containers were split. The guide described a loop. On an instance
nobody moderated by hand, nobody was ever flagged.

plugins/hnr-sweep.ts runs it every ten minutes, first two minutes after
boot, under a cross-replica lock so each member is notified once. It
also requires `downloaded > 0` now: the row is created when the member
clicks Download, and one who never started the torrent took nothing
from the swarm. This lands after the seed-time fix on purpose: at half
rate, an automatic sweep would have flagged honest seeders.

hnrSweep.itest.ts checks against Postgres what gets flagged, and that
nothing does while hit-and-run is disabled.

Review of upstream trackarr, October 2026: B4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In production `retryStrategy` returned null at the eleventh attempt,
about eleven seconds in, and a non-number tells ioredis to close the
connection for good. Nothing called `connect()` again: an outage longer
than that — a Redis container recreated for an upgrade, an OOM kill, a
slow AOF load — left the API without Redis until it was restarted.
Sign-in challenges live in Redis, so nobody could sign in;
`/api/health` stayed 503, and under Helm every pod sat out of the
Service with a liveness probe that never restarts it.

It now retries for as long as it takes, backing off to one attempt
every 3 s, and logs at the tenth attempt and every hundredth after. A
`commandTimeout` of 5 s bounds a command on a socket that is open but
no longer answers, as the tracker's 3 s does.

Checked by stopping Redis under a running API: it reconnects when Redis
comes back.

Review of upstream trackarr, October 2026: B5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The minimum-ratio check only runs for a client still downloading
(`left > 0`). A member under the ratio who announced `left=0` — a
modified client, a proxy rewriting the query — was still handed the
whole swarm, seeders included, to download from. A seeder has nothing
to fetch from another seeder, so a seeder is now told about leechers
only (`peersForAnnouncer`), as other trackers do; the counts still
describe the whole swarm.

The seed bonus had the other half. The API's collector paid every peer
entry in Redis marked as a seeder, with no freshness check, and an
entry lives 24 h (`TRACKER_PEER_TTL`): one `left=0` announce a day on a
torrent nobody seeds was paid as a full day of seeding, rarity and
first-seeder multipliers included, and the shop turns points into
upload credit. It now pays only peers seen within the hour, the
tracker's own notion of "in the swarm". Requiring a completed download
as well was left out on purpose: it would stop paying cross-seeders.

The seed-bonus guide placed the payout on the announce path; it is the
collector, once an hour.

Checked end to end on a running tracker and API.

Review of upstream trackarr, October 2026: B6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eer entry

Peer entries were read, written and deleted by (info_hash, peer_id)
alone, and a peer_id is no secret: every client in the swarm reads it
in the BitTorrent handshake. Any member could announce `stopped` under
someone else's peer_id and delete their baseline, so the victim's next
announce credited nothing, as often as they liked — or overwrite it
with their own counters and address.

An announce naming a peer_id held by another member is now answered,
but writes, removes and credits nothing. An entry with no owner
predates `userId` and is taken over as before. (The review commit at
the end of this series replaces the refusal with a field per member: a
refusal let a stranger squat a peer_id its owner had just released.)

Review of upstream trackarr, October 2026: B7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s exist

Sign-in shared `RATE_LIMITS.auth` — five requests per five minutes,
progressive — with 2FA, registration and the password change. At the
sixth request from an anonymous caller the address went on the
blacklist, five minutes doubling up to a day, and the security
middleware then refused every request from it, signed-in members
included: behind a VPN exit or a carrier NAT, one stranger mistyping a
password locked everyone out of the site. And no counter looked at the
account, so guessing spread over many addresses met nothing.

- Sign-in has its own bucket, `signin`: ten per five minutes, not
  progressive. The challenge route drops its progressive penalty too.
- Failed sign-ins are counted per username, ten in fifteen minutes,
  whether the account exists or not, so a lockout never tells; a
  successful sign-in clears the count. The cost, written down in the
  security guide: anyone can hold an account out of password sign-in
  for the window. Its open sessions are untouched.
- Address-based limits count an IPv6 address by its /64
  (`rateLimitSubject`): a host handed a /64 picked a fresh address per
  request and reset every counter, the blacklist with them.
- An unknown username's challenge was stored under `login:fake:*`, a
  key the login never reads, so it was answered "Invalid or expired
  challenge" where a wrong proof got "Invalid credentials": the roster
  the deterministic fake salt was there to hide. It is stored under the
  same key as a real challenge now, and refused the same way.
- The login and password-change challenges are consumed with GETDEL:
  two concurrent requests could each read one before either deleted it.
- The IP-ban answer no longer quotes the stored reason, which names the
  banned member, to whoever shares the address.

rateLimitSubject.test.ts covers the /64 folding, signinLockout.itest.ts
the per-account counter.

Review of upstream trackarr, October 2026: B8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`passkey-verify.post.ts` opened the session without `sessionEpoch`,
which the password and TOTP paths set, so the session read as epoch 0.
"Sign out everywhere" is what raises the epoch: once a member had used
it, every sign-in through a passkey second factor produced a cookie
revoked on its first request.

A ban did not raise it either. The ban gate refuses each request while
the ban lasts, but a cookie nobody presented in the meantime — a copy
from a shared machine, a device left offline — worked again the moment
the ban was lifted or ran out. The ban now raises the epoch and drops
the cached roles that carry it.

Three smaller gaps of the same family:
- "Sign out everywhere" revokes the trusted devices too, as a password
  change already did: a device let through 2FA once kept skipping it.
- `/api/auth/status` compares the epoch, so a revoked session no longer
  shows as signed in while every action fails with 401.
- The announce passkey is read from the account, not from the sealed
  cookie, by the download route and `/api/auth/passkey`, and the status
  route refreshes the cookie's copy: after a rotation on one device, the
  others wrote the dead key into every `.torrent`.

Review of upstream trackarr, October 2026: B9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tracker reads the running event from `bonus:active`, a snapshot
written with a five-minute TTL. Nothing re-stamped it on a schedule:
only admin edits, the freeleech pool's own transitions, and a read that
found it expired — `/api/bonus/active` from a browser, a Torznab
search, an IRC announce. Five quiet minutes, a night on a small
instance, and the tracker went back to 1×/1× in the middle of a
freeleech; and a scheduled event only began at the first page view
after its start, its start notification with it.

plugins/bonus-event-sync.ts recomputes the snapshot every minute under
a cross-replica lock, the first time five seconds after boot.
`syncActiveSnapshot` is idempotent and its start broadcast is guarded
per event.

Review of upstream trackarr, October 2026: B10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed peer write or swarm read answered "Internal tracker error",
and `/health` answered 503 without Redis, which took every replica out
of the Service. The high-availability guide said the opposite:
announces answer, credit stops.

Without Redis an announce is now answered in degraded mode: no swarm,
an interval of five minutes (one minute minimum) so clients come back
soon after Redis does, and the rest of the announce as before — the
byte credit already fell back to a direct write. `/health` fails only
without Postgres and reports `degraded` without Redis, so the Helm
readiness probe keeps a replica in the Service through a Redis outage.

A first version did that and still measured badly. The HTTP response
wrote the constants 1800/900 and ignored the outcome's interval — only
UDP carried it — and every Redis command paid about two seconds of
go-redis retries (five dials, then the command retried three times), so
announces took five seconds and some failed on their deadline. The
interval now goes through `buildAnnounceResponse`, and a circuit
breaker on the client (peers/breaker.go) fails commands at once while
Redis is out of reach. It opens on a refused connection or a host that
does not resolve, or after three timeouts in a row with no reply in
between; one probe goes through each second, and any reply closes it.
A command issued inside an admitted probe — the connection handshake —
is let through, or the breaker never closed again, which its test
found. The dedup fallback's warning is sampled.

Measured with Redis stopped: 2 s for the first announce, then 31 ms;
120 concurrent announces, p99 148 ms. With Redis frozen: three
announces fail, then 35 ms. Back to normal as soon as Redis returns.
Kept on purpose: the tracker still refuses to start without Redis, so a
wrong REDIS_URL shows.

Review of upstream trackarr, October 2026: B11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tracker serves UDP only when TRACKER_UDP_ENABLED is on, and it is
off by default in Compose and in the Helm chart. The API never received
the variable and read it as on when absent, while the UDP URL always
had a default (`udp://localhost:6969/announce` in Compose, the
configmap's in Helm, port 8081 in .env.example). Every `.torrent`
therefore carried a second tier, `udp://…/announce/<passkey>`, that
nothing answered — a client that prefers UDP backs off the working HTTP
tracker — and the admin and public stats showed UDP as up.

Compose and Helm now hand the API the tracker's TRACKER_UDP_ENABLED,
off by default, and the default UDP URL is empty. A WebSocket URL was
configured and displayed with no service behind it: it is gone from the
configuration, the admin endpoints panel, the translations,
.env.example and the home page's default copy. The guides follow.

`helm lint`, `helm template` and `docker compose config` pass.

Review of upstream trackarr, October 2026: B12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading a torrent's comments goes through the catalogue's visibility
(`assertVisibleTorrent`); posting one only checked that the torrent
existed. Any member could comment on a pending, inactive or
adult-hidden torrent — notifying its uploader — and the 200 against the
404 confirmed that it existed. Favoriting had the same hole, and the
favorites list filtered by member only: knowing an info hash was enough
to read back a hidden torrent's name, size, category and external ids.

Both writes go through `assertVisibleTorrent` now, and the list applies
the catalogue's visibility conditions, to its count as well. It also
selects only the columns it renders, where it loaded whole rows, raw
`.torrent` included.

Review of upstream trackarr, October 2026: B13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reference Caddyfile already dropped passkeys and keys from what it
logs, but kept the client address beside the rest of the URI.
access.log kept thirty days of addresses next to download URLs, each
carrying its info hash; tracker.log, five files of 100 MB with no age
limit, kept for every announce the address, info hash, peer_id and
counters — the swarm membership the application only ever stores as a
daily-salted hash — and panic mode does not reach either file.

access.log now masks the address to its /16 (IPv4) or /32 (IPv6),
still enough for abuse work, and drops the headers that repeat it
behind a CDN. tracker.log drops the address altogether, along with
`peer_id` and `key`, which tie one client's announces together across
addresses, and `ip`, `ipv4` and `ipv6`, addresses the client
volunteers.

`caddy validate` passes, and a canary request confirmed what gets
written.

Review of upstream trackarr, October 2026: B14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, dead code

- The footer linked, hard-coded, to the upstream author's personal
  site, the upstream repository and its Discord: every instance of this
  fork advertised them, with no setting to change it. One link remains,
  to this project's source.
- `welcome_message` was editable in Admin → Branding and promised to
  members by the branding guide, and nothing served it.
  `GET /api/welcome` (signed-in members) does, and the home page shows
  it as a card a member can dismiss, per browser and per version of the
  text. It is rendered with the editor's rich profile, and focus moves
  to `#main` when the close button held it.
- `status_badge_text` only appeared in the admin preview. The home
  page's live pill shows it now; its old English default, which the
  admin form saved back on any branding change, reads as unset, so the
  pill keeps its translated "Live".
- The Torznab guide had operators paste their passkey into Prowlarr,
  through a menu that no longer exists. It describes the RSS key, which
  keeps working when passkey reads are turned off.
- `runtimeConfig.databaseUrl`, `redisUrl` and `sessionPassword` were
  never read, and being evaluated at build time they would have baked
  whatever those variables held during a build into the bundle.
- `create-torrent` and its types were declared and never imported; the
  lockfile drops them.
- The missing-secret error pointed at `./scripts/generate-secrets.sh`,
  which does not exist.

The welcome card was checked in a browser on a production build, phone
width included.

Review of upstream trackarr, October 2026: B15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r smaller gaps

- Login verifiers and TOTP seeds stored before encryption at rest, or
  readable only under CREDENTIAL_ENCRYPTION_KEY_PREVIOUS, moved to the
  current key when their owner next logged in, and not before. A
  dormant account kept its verifier in clear, and the verifier is the
  credential: a database dump alone signed in as that member. A sweep
  (plugins/backfill-credential-rewrap.ts) re-encrypts them, 500
  accounts a minute under a cross-replica lock, each write conditional
  on the value it read. Its cursor is keyed on the current key, so a
  rotation starts a new walk, and it stands still during a panic, whose
  ciphertext has the same shape.
- `NUXT_SESSION_SECRET_FILE` and the other `*_FILE` secrets passed the
  boot validation and were then ignored by the session, the credential
  and channel keys and the login's fake-salt key, which read the plain
  variable. plugins/00.0-secret-files.ts reads each one at boot, before
  any other plugin, and exposes it under its plain name. A session
  secret under 32 characters is refused at boot, where it used to fail
  every sign-in.
- `rateLimit()` repeated the blacklist check without the
  internal-origin exemption the security middleware has, so a burst of
  anonymous server renders could blacklist the web container's own
  address.
- The Redis healthcheck handed the password to redis-cli with `-a`,
  which shows in `ps` on the host every thirty seconds; it goes through
  REDISCLI_AUTH now. (The server's own `--requirepass` does not show:
  Redis rewrites its process title.)
- The browser gave the registration proof of work 60 s. At about
  175 000 WebCrypto hashes a second, measured in a desktop Chromium,
  the server's 20 bits take about 6 s there and several times that on a
  phone, and the count is geometric: at 30 000 a second, about one
  registration in six failed. The budget is four minutes, inside the
  challenge's five-minute life.

Left for later: hashes for the static image's inline scripts in its
CSP, a list of trusted proxies for TRUST_PROXY, a staff reset of a
member's keys, a configurable proof-of-work difficulty.

credentialRewrap.itest.ts covers the sweep against Postgres.

Review of upstream trackarr, October 2026: B16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ur smaller gaps

- Nothing bounded a member's entries on a torrent: one passkey
  announcing random peer_ids grew the swarm's hash without end, each
  write pushing its 24 h TTL further, and every announce on that
  torrent read the whole of it. A member holds at most five live
  entries per torrent now, and a new one evicts the oldest rather than
  being refused: the usual way past the line is innocent, qBittorrent
  picking a new peer_id every session.
- A passkey that matches no account cost a Redis read and a Postgres
  query each time, so a scanner was a database load generator. The
  passkey's shape is checked before any lookup, and an address — an
  IPv6 /64 — that fails 120 times in a minute is answered from memory
  for the rest of it, on /announce over HTTP and UDP and on /scrape. An
  unknown torrent under a valid passkey does not count: after a purge,
  one client restart announces hundreds, and a whole seedbox host would
  have been locked out.
- An announce has a five-second deadline, connection-pool waits
  included. Over HTTP the request context had none, and a stuck query
  held its pooled connection with nothing able to cancel it.
- Anti-cheat flags are deduplicated per member, torrent and kind over
  the minimum announce interval, fifteen minutes, where it was per peer
  over 2 s: a client announcing every few seconds, or rotating its
  peer_id, added a row each time.
- ANTICHEAT_MAX_BYTES_PER_SECOND, documented for a long time, is read
  at start-up.
- The minimum ratio is not enforced while a site-wide event counts no
  download at all (0×): nothing moves a ratio then, and refusing
  members for theirs kept out the very members the event was for.

Left for later: refusing credit when the only leecher is the member
themself, events with no end date, tracker metrics.

Each measured end to end on a running tracker; lookupguard_test.go
covers the guard and the passkey shape.

Review of upstream trackarr, October 2026: B17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What reading the guides against the code found, beyond the pages each
fix above already corrected:

- zero-knowledge-auth.md gave PBKDF2 100 000 iterations; the browser
  runs 600 000.
- hit-and-run.md said a first `started` announce credits the client's
  declared totals. The tracker refuses to: that is how a client looping
  `started` and `stopped` would mint upload.
- anti-cheat.md put `unknown_client` at medium severity; it is low.
- scaling.md called the stats batching "not done yet" — it is done —
  and gave the per-peer cap as 80 MiB/s where it is 1 GiB/s.
- backup-restore.md left out CREDENTIAL_ENCRYPTION_KEY and underplayed
  losing NUXT_SESSION_SECRET: unless the dedicated keys are set, it
  also protects the verifiers, the TOTP seeds and the federation
  private key. It said nothing of the uploads volume, the S3 bucket, or
  backups taken before a panic.
- reference/env.md marked ADMIN_API_KEY required, for a gate no route
  uses, and said Caddy does not route /scrape; it does, on the
  tracker's subdomain, passkey required. reference/api.md denied that
  /scrape exists at all.
- panic-mode.md listed what stays in clear, incompletely, and three of
  the columns it listed are encrypted at rest, under keys from the
  environment rather than the panic password. The guide and the
  inventory the encrypt route returns now agree, with a third list for
  those three.

Not addressed: Sentinel, which the high-availability guide recommends
and no client supports, and multi-host Postgres, not verified.

Review of upstream trackarr, October 2026: section 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admin → Branding edits `site_rules`, and the form describes them as
displayed to users, but no route served them and no page showed them —
the same fault as the welcome note, a few commits earlier.

`GET /api/rules` serves them, publicly like `/api/privacy`: someone
holding an invitation reads the rules before deciding to register, and
nothing in them is about a member. The `/rules` page renders them with
the editor's rich profile, signed out included, and `/api/branding`
carries `hasRules` so the footer links to the page only when there is
something to read.

Checked in a browser on a production build, empty rules included.

Review of upstream trackarr, October 2026: B19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The whole series read again, as an outside reviewer would. Six points;
five are fixed here.

## A peer_id squat

Refusing an announce under a peer_id held by another member left a
squat: when the owner announced `stopped`, a stranger could take the
freed peer_id, and the owner coming back under it — libtorrent keeps
one per session — was refused for as long as the stranger re-announced.
And the dedup keys, made of the peer_id alone, let a stranger
announcing under someone else's peer_id hold that member's 2-second
window shut.

A member whose peer_id another member holds now gets a field of their
own, `peer_id:user_id` (`peers.Slot`, one HMGET), and stays on it for
as long as it lives. The write script refuses, atomically, to overwrite
another member's entry, and the member is part of the dedup and credit
keys. Measured end to end: the squat costs the owner nothing (1803 s
credited on their own field), and the stranger's announces no longer
close their window. Four Go tests.

## Key order at upload

Decode then encode sorted every dictionary as a side effect. Without
it, a `.torrent` from a careless encoder, keys out of order, was stored
as it came, under another infohash than the same release uploaded
before, and the duplicate check missed it. The upload sorts keys by
their raw bytes at every depth now, values copied verbatim, so a file
that is already canonical comes out byte for byte; a repeated key keeps
its last value, as the decoder did. Three tests.

## Integers the scanner accepted

An integer was skipped to the next `e`, so `ixyze` passed. Digits only
now, with an optional sign; leading zeros stay tolerated, so a file
stored years ago remains downloadable. One test.

## Refusal timing

A username with no account and a wrong proof got the same status and
message but not the same work — the second reads the account, decrypts
its verifier and hashes — and the milliseconds between them enumerated
members. Every refusal of credentials now ends no sooner than 300 ms
after the request arrived; measured, 310 to 320 ms either way.

## The WS tile

The protocol matrix still listed `ws`, always off, for a transport that
never existed: gone from the admin and public stats, the home page and
the admin panel. The debug page's tracker status, which hard-coded UDP
as off, reads the real flag.

## Left as it is

The per-account sign-in counter lets anyone hold an account out of
password sign-in for fifteen minutes. Recognising a device that already
signed in would answer it, and needs a mechanism this site does not
have: the login log's IP hash changes salt every day. The trade-off is
written down in the security guide.

Review of upstream trackarr, October 2026: section 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dim145 and others added 16 commits October 25, 2026 01:08
`revoked_identities` holds DIDs, not foreign keys, so no cascade from
the per-test TRUNCATE reaches it, and the succession tests reuse fixed
DIDs: a run left its revocations behind, and the next run found an
alias too many. Green on a fresh database, red on every re-run. The
setup truncates it with the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-2026-10-majors

The upstream review's 21 fixes, on top of the dependency upgrades.

pnpm-lock.yaml was the one conflict: this branch's lockfile is pnpm 12's,
and the other side removed create-torrent and @types/create-torrent from
the API. This side's lockfile was kept and re-resolved with pnpm 12.15.0
against the merged manifests. The result only drops create-torrent, its
types and the six packages nothing else needed: the same 64 lines the
other side removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ign-in lockout

Ten failed sign-ins in fifteen minutes hold a username out of password
sign-in from every address, the right password included. That stops
guessing spread over many addresses, and it let anyone who knows a username
keep its owner out with one failure every ninety seconds.

A browser that completes a sign-in (password and second factor) or a
registration now gets a known-device cookie (`trackarr_kd`, httpOnly,
SameSite=Lax, 180 days). On that browser the account's count does not
apply: the browser answers to its own, and ten failures there revoke its id
for good. The lockout now shuts an account to new devices only; guessing
gains nothing, since a guesser never holds a valid cookie.

The cookie skips nothing — not the password, not the second factor, not the
per-address limit — unlike the opt-in trusted-device cookie, which skips
the second factor. It is stateless: an HMAC, keyed from
NUXT_SESSION_SECRET, over the account id, `users.session_epoch`, a hash of
the stored verifier, a random device id and the issue time. So "sign out
everywhere", a ban or a password change void every one at once. Redis keeps
only a failure count per device and the revoked ids. A known browser's
success leaves the account's count alone: those failures came from
somewhere else.

The lockout's 429 now carries `data.reason: 'account-locked'`, and the
sign-in page tells it apart from the per-address limit: it says that a
browser used before can still get in.

Tests: 7 unit (format, binding, age, tampering), 4 integration (against the
account row and Redis), and an e2e scenario of 15 checks (issue, lockout,
the known browser getting through, revocation after ten failures, "sign out
everywhere"). Typecheck, the 630 API and 956 web unit tests, the 486
integration tests and the e2e suite (558 checks) pass. Checked in a
browser: the locked account's message on the sign-in page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`createError({ data })` answers `{ statusCode, statusMessage, message,
data }`, and `$fetch` hands that body back as `err.data`: whatever a route
put in `data` is at `err.data.data`. Three pages read `err.data.<field>`
and never found it:

  - torrents: saving a search past the cap, or with no criterion, said
    "the search could not be saved" instead of naming the cap or the
    missing criterion;
  - settings: "sign out everywhere" on a session already revoked ended on
    the re-authentication prompt instead of the sign-in page;
  - admin upload rules: the fields the server refused were never outlined.

errorDataPath.test.ts sweeps app/ for a caught error's `.data.` followed by
anything but a field of the error body, and flags all four lines as they
were. Checked in a browser: the cap message, and a revoked session landing
on /auth/login?revoked=all with its request answered 401.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Known-device cookies (a member's own browsers are exempt from the
per-account sign-in lockout) and the fix for three pages that read an
API error's details one level too high.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Redis client masks the credentials of its URL before it logs a
connection error, but the pattern only knew `redis://`. A Redis reached
over TLS (`rediss://`, what managed offers hand out) had its password
written to the log whole, on every connection error.

The mask now covers both schemes and keeps the one it matched. Found while
triaging the scanner's note on the plain `redis://` fallback a few lines
above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Telegram channel renders a notification as HTML and puts its link in
`<a href="…">`, but the escaper only handled `&`, `<` and `>`. A `"` in the
link closed the attribute early and left Telegram to parse the rest of the
URL as markup.

`"` now becomes `&quot;`, one of the four named entities Telegram's HTML
mode accepts. The apostrophe needs nothing: the only attribute is
double-quoted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… string

Nine console calls put their values into the first argument through a
template literal. Node reads that first argument as a format string: a
`%s`, `%d` or `%j` arriving in an interpolated value would consume the
next argument, the error, and the line would come out without it. None of
these values is likely to carry one (the paths are encoded, the rest are
counts or configuration), so this is about the shape rather than a failure
seen: a fixed format, and the values after it.

Ban expiry, the metrics listener, panic restore, the IGDB, Open Library,
Google Books and TMDb fetchers, and the secret reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tten

The helper came with the IMDb / TMDb / TVDB tagging (2837897) and nothing
ever called it: normalizeTmdbId and normalizeTvdbId each carry their own
patterns. It was also the one place that compiled a regex from its
arguments, which is what the scanner pointed at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sniff

The tracker answers in bencode served as text/plain and its health check in
JSON; the relay streams text/event-stream and serves plain text. The
shipped Caddyfile adds nosniff on the way out, but an ingress in front of
the Helm chart does not, and a browser left to sniff a bencoded failure
reason or peer list could take it for HTML.

Both now set the header themselves, around their whole mux. The tracker's
test goes through Routes(): a scrape without a passkey is refused before
the database or Redis is touched, with a plain Write. A 404 would have
proved nothing, http.Error setting nosniff on its own; the test fails with
the wrapper removed. On the e2e stack, /announce, /scrape, /health and the
relay's /healthz and /metrics all carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both said `USER nonroot`, the distroless base's name for 65532. The kubelet
cannot check a name against `runAsNonRoot: true`, so a manifest that asks
for it without a `runAsUser` gets the container refused ("image has
non-numeric user"). The Helm chart sets runAsUser and was never affected;
anything else was. The tracker and relay images already said 65532:65532,
and these two now do too: both images report it, and their process runs as
uid and gid 65532.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lease with gh

`${{ steps.version.outputs.version }}` sat inside three `run:` scripts, and
an expression there is pasted into the script text before the shell reads
it. The version step already refuses anything but digits and dots, so this
was a second wall missing rather than an open door. The scripts now read
`$VERSION` from `env`.

The release itself came from softprops/action-gh-release, a third-party
action on the one job that holds a write token. `gh`, already on the
runner, does the same: `--notes` with the PR body is prepended to the
generated notes as the action's `body` was, and `--verify-tag` refuses to
run against a tag the step above did not push. actionlint (with its
shellcheck pass) and zizmor are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chart always built `redis://host:port` for an external Redis. A managed
Redis that only takes TLS (most do) refused the connection, and one that
still accepted plain TCP carried the password and every key in clear.
`externalRedis.tls: true` hands the apps `rediss://`, which the API, the
tracker and the relay all speak. Off by default: a plain Redis on the same
private network is the common case, and its URL does not change.

check-render.sh asserts both sides and the README lists the value with the
other external Redis settings. helm lint and every render check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bundled Valkey's init container writes valkey.conf and the ACL file
before the server starts, and had no resources of its own, so it ran
BestEffort. In a namespace whose ResourceQuota covers memory, a pod with
such a container is rejected at admission unless a LimitRange fills the
gap. The subchart takes `initResources`: 10m and 16Mi requested, a 64Mi
limit. The real script, rendered from the chart, peaks at 14.6 MiB under
that limit.

Nothing had flagged it because trivy never looked at the chart: it skips a
chart whose charts/*.tgz are missing, as in any copy made from git, and
says so only in a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds it

semgrep (two passes of rule packs), trivy (vulnerabilities, secrets and
misconfigurations, the Helm chart included), gitleaks (every commit),
gosec, hadolint, actionlint, zizmor, govulncheck and osv-scanner report
nothing on this tree. What they found that is not a defect is written down
where each tool reads it, with the reason beside it:

  - inline: `nosemgrep` on 17 v-html (every one behind DOMPurify), on
    escapers complete for their output, on regexes built from constants or
    written by staff, on ports meant to be published and on connection
    strings of local or private networks; `#nosec` on bounded integer
    conversions and the loopback health checks; zizmor ignores on the pnpm
    install lines and on the `./` reusable workflow (actionlint rejects
    `$/`);
  - Dockerfiles: Chainguard's free tier publishes no versioned tags, so the
    spa and doc images keep `:latest` on its nginx, and both now say
    `HEALTHCHECK NONE` rather than leave it unsaid;
  - .trivyignore.yaml, loaded through trivy.yaml, scopes each exception to
    its paths: no CPU limit in the chart (deliberate, now said above
    web.resources), the render-time namespace, the registry allow-list, the
    name REDIS_KEY_PREFIX, Valkey's subchart, and node-forge (no fix
    exists, dev server only, lapses on 2027-01-31);
  - osv-scanner.toml: the same node-forge entry and a false positive on
    nuxt 4.6.2; .gitleaksignore: 29 fixtures and look-alikes;
    .semgrepignore: the default list, which the file replaces, plus
    .gitignore and prose.

Two greens of earlier passes were not checks: the published gosec image
analyses 0 lines of a go1.27 module and exits 0 (gosec built with Go 1.27
covers 7,700 lines), and trivy skipped the chart.

On this tree: typecheck, the 630 API and 1,245 web unit tests, the 486
integration tests, the tracker and relay under -race, helm lint and the
render checks, the six images, and the e2e suite (558 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The login-history entry was written when a guess spread over many addresses
met nothing at the time, and said so. The per-account sign-in lockout
(5e454ac) changed that and the guides were brought in line (c80889b), but
not this sentence.

The Kubernetes guide sends readers to `externalRedis` for a Redis of their
own and now names `externalRedis.tls` with it, as the chart's README does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Dim145
Dim145 merged commit 7bedbe8 into main Oct 25, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant