Repository navigation
Dependency majors, the upstream review's fixes, known-device sign-in and a security-scan pass - #40
Merged
Merged
Conversation
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>
`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 `"`, 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
packageManagerpins, and CI reads it from there.@theme inlineblock. 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.Fixes from the review of upstream trackarr (21 commits)
.torrentfiles, 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.left=0got 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.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:
rediss://password; a"in a Telegram link closed itshref; nine log calls put their values in the format string.X-Content-Type-Options: nosniffthemselves, rather than relying on the shipped Caddyfile.65532:65532), whichrunAsNonRootcan check.envand creates the release withghinstead of a third-party action.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
-race.helm lintand every assertion ofcheck-render.sh, with default and production values.nosniffchecked on the live tracker and relay.docker.ymland the release job never run on a pull request.release-tag.ymlchanged here, so its first run will be the release itself.🤖 Generated with Claude Code