Audit pass over the four apps: BEP 52, GDPR, PWA — and what probing the running stack found - #28
Merged
Merged
Conversation
…hat were a lie Three attributes the spec has always had room for, and one number that was simply wrong. `minimumratio` and `minimumseedtime` exist in Torznab for exactly this: a tracker stating its seeding requirements so a client can honour them by itself. We enforce both already — a minimum ratio at announce time, a required seed time as a hit-and-run sanction — and told nobody in advance. A member found out when the gate closed or when the sanction landed. The same two numbers, sent through the channel Sonarr and Radarr already read, turn hit-and-run from a trap into a contract. Both are omitted rather than sent as `0` when the site imposes neither. A stated zero and an absent value mean the same thing to a client, and only one of them can be misread as "seed for zero seconds". `minimumseedtime` is sent only when hit-and-run is actually switched on. The required seed time has a value either way — 86 400 by default — but announcing a rule the site will never enforce would have clients seed against something that does not exist. `infohash` is the third, and it costs nothing: it is the `guid` of every local item already. Prowlarr reads it to match a release against torrents a client already holds, without fetching the `.torrent` first. The volume factors are the correction. They were hard-coded to `1` with a note about freeleech "enhancement" — but a site-wide bonus event IS that, and it was already being applied on the announce hot path. So during a freeleech the feed told every *Arr client "normal rates" while the tracker counted nothing. They now read the running event: `0` and `2` under a freeleech with double upload, `1` otherwise. Per-torrent multipliers still do not exist, and this does not invent them; it reflects what the site is doing right now, which is the part that was wrong. All three site-wide values are resolved once per request rather than inside the item map — one cached read instead of `limit` of them. Mirrored (federated) items carry the hash and neither obligation. A release announced to another instance answers to that instance's rules, and guessing would tell a client to honour a rule from the wrong site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A v2 or hybrid torrent has two infohashes, and the announce path knew one. BEP 52 kept SHA-256 for content addressing but the tracker protocol has no room for 32 bytes, so a v2 client announces the SHA-256 truncated to 20. A hybrid torrent carries both formats and a v2-capable client joins BOTH swarms — two announces, two hashes, one release. The lookup was `WHERE info_hash = $1` and nothing else, so the second one found no row. What that produced: a torrent that worked, and beside it an announce erroring every interval; and a swarm cut in half, because v1-only peers and v2-capable peers were keyed apart in Redis and could not be handed each other's addresses. Both halves were seeding the same bytes and neither could see the other. `ResolveAnnouncedTorrent` tries v1 first and falls back to the v2 form only on a miss. The v1 arm is the unique-index hit it always was and pays nothing for any of this; the fallback is a partial expression index over `left(info_hash_v2, 40)`, which is exactly what a client sends. A v2 announce costs two lookups where a v1 announce costs one, which is the right way round — the rare case pays. It returns the CANONICAL v1 hash, and `ProcessAnnounce` reassigns `infoHashHex` to it. That one substitution is what merges the swarm: every keyed operation downstream — dedup window, peer set, completed counter, seed time, anti-cheat — reads that variable and therefore agrees. Both transports share the processor, so UDP gets it for free. `/scrape` needed its own answer. It had cost zero queries: each hash read straight out of Redis, up to 64 per request. Resolving all of them would be a denial of service handed out for free; resolving none would leave a v2 client scraping zeroes forever, since the swarm now lives under the other key. So only hashes Redis has never heard of are resolved, at most 8 per request, and past that budget the answer is what it was before. The spec does not say how a client should announce when it joins both swarms, so the choice is stated rather than assumed: nothing deduplicates a peer that announces both under two different peer_ids. libtorrent reuses one, so the Redis key collapses the pair by itself and the common case is exact. A client that rotated its id is counted twice — the same as a member running two clients today, bounded by the same per-announce cap and the same heuristics. Deduplicating by (user, torrent) would mean rebuilding the peer store around a different key, which is far more than this warrants. And a latent bug this turned into a live one. `infoHashV2` was hashed over a RE-ENCODE of the decoded info dict. The note above it said that was affordable because nothing matched on the value — true when it was written, false the moment the announce path did. A re-encode equals the real infohash for a canonical torrent and diverges for one whose keys are unsorted or whose paths are not valid UTF-8, which is the failure nobody would ever find: one member, one unusual client, one hybrid torrent announcing into a swarm that does not exist, on a site where every other hybrid torrent works. So the bytes are located instead. `infoDictRange` walks bencode structure without interpreting a single value — it only needs to know where each one ends — and returns the half-open range of the top-level `info`. Bounded by the buffer on every path, so a truncated or hostile file cannot make it loop; anything it cannot walk is unaddressable, which is the same answer those files already got. Existing rows were written under the old rule and there is no way to tell the wrong ones from the right ones by looking. The backfill cursor is versioned instead, which sends the sweep over the catalogue once more, re-deriving from the `.torrent` bytes still stored. Cheaper than a data migration and it cannot lock the table: the same capped, locked, resumable walk that filled the columns in the first place. Verified against a real Postgres: the planner picks the partial index, a v2 announce resolves to the canonical hash, and a v1-only torrent stays out of reach of that arm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hard half of a PWA was already here and had been for a year: a service worker, shipped as the Web Push receiver. What was missing was a manifest, and a reason for the worker to exist before somebody turned notifications on. The manifest is a route, not a file in `public/`, for the same reason the theme stylesheet is one. Everything in it — name, subtitle, the two colours, the icon — is operator-configurable, and a static `manifest.webmanifest` in the bundle would bake one instance's branding into every instance's image: an operator who renamed their tracker would still be installed as "Trackarr", in Trackarr's colours. It also has to work in both shapes `apps/web` ships in, and `app.head` is the only place that reaches the HTML of both. A manifest's `scope` is resolved against its own URL but, unlike a service worker's, is not confined to its directory — so one served from `/api/` can legitimately claim `/`. The note is in the route, because the next person to read it will assume otherwise. The service worker is now registered on page load rather than when a member enables push. A browser decides installability AT page load, so a site whose worker only appears after a settings toggle is not installable for anyone who never visits that toggle. `register()` is idempotent, so this and `useWebPush()` can both call it. It gained a `fetch` listener that handles nothing. Chrome will not offer to install a site whose worker has none, and the check is for the listener's existence rather than for what it does — so it never calls `respondWith`, and the browser goes to the network exactly as it would with no worker at all. What it is NOT is an offline cache, and that is a decision. Every page here is a live view of a swarm: seeder counts, ratios, a moderation queue, an inbox. A cache-first worker would serve yesterday's numbers with no way for the reader to tell, and on a private tracker the wrong ratio is not a cosmetic problem. Then the icon, which is where the honest work is. `sizes` is a CLAIM, and browsers act on the claim rather than on the file: Chrome installs a site only when its manifest declares an icon of at least 512x512. Declaring that over a 64-pixel logo buys an install prompt and a blurry home-screen icon — worse than no prompt. So the size is read from the image's own header at upload time, where the bytes are already in hand. `imageDimensions` walks PNG (IHDR at a fixed offset), GIF, all three WEBP sub-formats, and JPEG, which is the only one needing a real walk since its frame header sits behind however much metadata precedes it. It returns null for an SVG — which has no intrinsic pixel size — and for anything it does not know, and `manifestIconSizes` turns null into `any`. A non-square image is `any` too: `sizes` names squares, and a 800x200 banner is not an 800-pixel icon. Measured at upload rather than at render time on purpose: the file lives behind a storage backend that may be S3, and re-fetching it on a route the browser polls would be a network round trip per request for a number that cannot change after the upload. No default 512-pixel icon is shipped to paper over the gap. An invented icon saying "Trackarr" on somebody else's tracker is not an improvement, and the guide says plainly what to upload to make Chrome offer the install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Erasure has been here since the account page existed. Access and portability had not — which is the odd half to be missing, because the hard part was already done. Deciding what counts as a member's data is exactly what the erasure had to settle, and thirteen `/api/me/*` routes were already reading most of it a page at a time. What did not exist was one request that returns the record AS a record. So `exportAccount` is written as a mirror of `eraseAccount`, table for table, read instead of written. Not a second inventory of the same thing: two independently-maintained lists drift, and the direction they drift in is "the export forgot something the erasure knew about". A personal table added later belongs in both, and the note at the top of each says so. One JSON document. Art. 20 asks for a structured, commonly used, machine-readable format and JSON is all three, without adding a zip writer to a distroless image for the sake of a folder structure nobody needs. Indented, which doubles the bytes and is the difference between a file a person can read and one they have to run through a formatter first — which is the whole point of a right of access. Guarded like the erasure: a live session plus a fresh login. This endpoint answers with a person's entire history in one response, which makes it the most valuable single request on the site to a borrowed session. Rate limited on the mutation bucket despite being a GET — it reads twenty-odd tables and costs nothing like a page fetch. Every collection is capped at 5 000 and declares its own true total, so a large account gets a bounded document that SAYS it is bounded. One that silently stopped at the cap would be worse than one that refused: the reader would take it for the whole record. Four things are left out, and the file lists all four with its reasons, because an export whose omissions are undocumented is indistinguishable from an incomplete one. Other members' identities — a follower list, who used an invite, the other side of a conversation, who reported this account. Theirs, not this account's; counted where a count means something. Credentials, including this account's own. Notification channels carry a webhook URL or a chat token, encrypted at rest with a key this has no business decrypting with. A live token in a Downloads folder is a worse place for it than the database. Channel types and state are exported; the credential is not. Private message bodies. A conversation belongs to both parties, and an encrypted one cannot be read server-side at all — the key never leaves the members' browsers. Metadata counts only. Anti-cheat findings. Art. 15 is not absolute; it yields where disclosure would prejudice the detection of abuse. Handing somebody the heuristics that flagged them is a recipe for evading the next check, and an operator asked for them by a member or a regulator can produce them from the console. One judgement call worth naming: the snatch list is exported even when `hideDownloadHistory` is on. That toggle keeps the list out of a browser session, so a stolen cookie cannot enumerate it, and this route is behind a step-up. Refusing a member their own record here would be the toggle working against the person it protects. The button sits in its own Settings section, ahead of the danger zone and not inside it: downloading a copy of your own record is not a destructive act and should not be dressed as one. Fetched as a blob rather than opened as a navigation, so the step-up's 401 lands in the page as "log in again" instead of replacing Settings with raw JSON. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This instance keeps a shelf of thematic ledgers, each good inside its own lane. Freeleech-pool contributions are append-only. Every bonus credit has a row. A moderated upload carries its discussion. A withdrawn report leaves a tombstone. The one route that reads private mail logs itself — 8e41cb2, three commits ago, which is the same need answered for the fourth time. What none of them answers is the question asked across the site: who banned this member, who changed that setting, who touched federation, who fired panic mode, and when. That gap sits badly beside everything else here. Members' IPs are hashed under a rotating salt, passwords never leave the browser, the database can be encrypted in an emergency, an account can be erased on request — and nothing could say which moderator took which decision. Protection ran one way. It also meant a compromised staff account left no trace. One row per mutating request to `/api/admin/**` or `/api/mod/**`, written by hooks rather than by each route. There are 339 operations in the generated spec and dozens of them are staff mutations; a convention that every one must remember to log itself holds until the next route. Here coverage is structural — a route added tomorrow is audited before anybody writes a line for it. What a route CAN do is sharpen its entry, and the ones that move the most name themselves: ban, unban, role, panic, settings. Reads are not recorded, and neither are member-facing writes. A register of authority records decisions; recording who looked at which page would make it a record of everybody's activity instead — the opposite of what the privacy toggles elsewhere exist to protect. Two hooks, not one, and the second is there because an end-to-end run found the first was not enough. `afterResponse` fires for a request the handler answered; it does NOT fire for one that threw. So the first version recorded every successful ban and silently dropped every refusal — the exact rows worth having, since a run of 403s from one account is the pattern this table exists to surface. `error` covers those, `record` is idempotent, and the actor is stamped in `requireAuthSession` rather than in the staff gates so a member who aims at an admin route and takes a 403 is recorded with their name on it. The same run found the action key eating identifiers: `admin.federation.peers.does-not-exist.delete`, because a slug-shaped peer id looks exactly like a sub-resource name and no shape heuristic can tell them apart. h3 knows which segments matched a route parameter, so those are removed by value; the shape rules stay as a fallback. Request bodies are never captured. They carry passwords, panic passwords, channel tokens and 2FA secrets, and a log that swallowed them would be a credential store with a listing page. A route wanting a diff passes the fields it means — and the settings route, whose body is wide and grows with every feature, records which settings were touched and never their values. Query strings are stripped from the path for the same reason. Append-only. No edit path, no per-row delete, no "mark as reviewed": a register whose entries can be amended by the people it registers is not a register. Rows leave only through the retention sweep — a year by default, because the question an audit log answers tends to be asked late, and `0` means keep them. The period is published on `/privacy` beside every other one, since a member who was banned is the subject of one of these rows and the period is theirs to know. The control sits on the register's own page: a setting reachable only through a SQL prompt is one only its author can change. Admins only, and moderators deliberately not, though moderators fill it. Knowing exactly what a colleague can see about you changes what you do in front of them, and the value of the register is that it is read by the people accountable for the console rather than by everyone holding a key. The actor is denormalised — name and role copied at write time — because the point of the row is what was true WHEN it happened. A moderator later promoted, renamed or erased must not retroactively rewrite the record. On erasure the pointer goes and the name stays, on exactly the rule the ticket scrubbing already follows: an act taken under authority with no author is indefensible, and an ex-moderator must not be able to un-sign their own decisions by closing their account. Done by hand, because the row in `users` survives an erasure and no ON DELETE ever fires. The address is hashed like every other IP here, which means two rows are comparable only within a day. Enough for "this admin's session came from somewhere else than the rest of today's actions", not enough for a history — the trade the rest of the codebase already makes, stated rather than discovered. What it cannot see, and the guide says so: post-panic recovery. After panic mode there is no session, so there is no account to name. That endpoint is rate-limited and globally capped instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The v0.35.x entry, plus a correction that had been sitting there for two releases: "Private Messages" and "Theme System" were still listed under **Later** while both were live — messaging landed in 0.34.0 and the theme system before it. A reader coming to the project from this page would under-read it by two whole features, which is the opposite of what a roadmap is for. Both now have entries under Released, where the code is. "Collages / Collections" was the other stale line, and it was stale by being two things. A **group** is releases of the same work, keyed on an external metadata id — already in progress, and the cross-seed content signatures already answer "these are the same work". A **collection** is an editorial grouping somebody curates. One is identity, the other is taste, and a single bullet covering both is why the line survived three releases without anybody being able to say whether it was done. Only the second is still open, and it now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The multiplier system was site-wide and only site-wide. An operator could
declare a freeleech for everybody or for nobody — there was no way to
revive one rare release, no way to double the upload on an internal, no
way to put anything at the top of a listing. It is the most visible thing
missing from the operator's side, and the pipe for it already existed:
Torznab has been emitting `downloadvolumefactor` per item since the feed
shipped, and those numbers could only ever describe the whole page.
Four columns on `torrents`: two multipliers, an expiry, a pinned flag.
Basis points ×100, the same units `bonus_events` uses, because one unit
system beats two. Two multipliers plus one expiry rather than a family of
booleans — one pair of columns expresses freeleech, silverleech,
double-upload and every combination, where three booleans can contradict
each other and need a precedence rule nobody remembers.
The decision worth arguing about is how a torrent buff meets a running
site-wide event, and the answer is NOT the product. Multiplying a
site-wide freeleech (download 0) by a per-torrent double-upload (upload
200) gives 0 and 400 — the freeleech silently doubling an upload bonus
nobody granted. `bonus.Best` takes the better of each axis
independently: lower download wins, higher upload wins. That keeps every
buff meaning what its operator set it to, and buys a property worth
having — a buff can only ever help a member relative to the site-wide
state, so granting a freeleech never requires checking what else is
running first. The rule is asserted against the same table of cases on
both sides of the wire.
Expiry is neutralised in the announce query itself:
CASE WHEN multipliers_until IS NULL OR multipliers_until > now()
THEN download_multiplier ELSE 100 END
So a buff ends the moment its timestamp passes, whether or not any job
noticed, and the hot path carries no clock logic. There is nothing to
schedule and nothing to forget to run.
It costs nothing on the announce. The values ride on the row step 3 had
to read anyway, so a buffed torrent and an unbuffed one are the same
number of queries. `ResolveAnnouncedTorrent` grew a return type rather
than a fourth out-parameter, which is also what stopped the scrape path
from silently keeping the old shape.
Pinning does NOT go in the ORDER BY, which is the obvious implementation
and the wrong one: `is_sticky DESC` in front of the sort key stops every
existing single-column index from serving it, so a catalogue that sorted
by date off an index starts doing a full sort on every page. Pinned rows
come back as a separate capped block on page 1, under the same filters as
the flow, and are held OUT of the flow on every page — so a release
appears exactly once and the page count describes what can actually be
scrolled through. Five of them, because a first screen that is all pins
is a page with no listing on it.
Two gates, and the split is not bureaucracy. Pinning moves a release up a
page; setting its download multiplier to 0 mints upload credit out of
nothing. Any moderator may do the first, only an admin the second — the
same line the ban route already draws. Both land in the audit log with
before-and-after values, which is what makes an economic power auditable
rather than merely restricted.
Deliberately no notification to the uploader. It would need a 51st type
across six files, it would fire on every adjustment including the ones
that take a buff away, and the badge on the page already says what is
true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BEP 21 has been in the specs since 2008 and this tracker treated its signal as an unknown token. A client that holds every piece it asked for — but not the whole torrent, because the member deselected files — sends `left=0` with `event=paused`. That is not an exotic case: qBittorrent does it whenever somebody downloads part of a multi-file torrent, and there is an eight-year-old issue on their tracker about the sites that answer such an announce with a failure. We never failed it, which is already better than several trackers in the wild. What we did was worse in a quieter way: `event=paused` fell into the `default` arm, became `EventNone`, and `IsSeeder()` reads `left == 0` — so the peer was counted as a **seed**. Every torrent whose only remaining peers were partial seeds looked healthier than it was, and the rarity input the bonus rules read was wrong in the same direction. So `paused` becomes a first-class event and `IsSeeder()` gains a second condition. The peer stays in the swarm — it has real pieces and other peers should be given its address — and counts as a leecher, so the seeder count means what a member reads it to mean. It also stops banking seed time, and that is the one judgement call here. Hit-and-run asks a member to seed what they took; somebody holding a deselected subset cannot satisfy that however long they stay connected, so crediting them towards a requirement they cannot meet would be generous in a way that makes the requirement meaningless. They keep serving what they have. HTTP only, and the enum says so: BEP 15 numbers its events 0..3 and has no code for this, so a UDP announce can never carry it. One existing test used `paused` as its example of an unknown token — which is exactly the bug, written down as an assertion. It now uses a token no BEP defines, and the behaviour it was actually testing (record it, treat it as a periodic announce) is unchanged. And the non-compact response, while the guide was open: `compact=0` is ignored and always has been. That is not going to change — every client written this century requests compact and most cannot parse anything else — but "why does compact=0 do nothing" is a reasonable question to ask once, so it is now answered in writing rather than in a source comment nobody reading the docs will find. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A few years in, a catalogue accumulates problems the moderation pipeline has no vocabulary for. It could say a release was rejected. It could not say a release was fine and is simply no longer the one to take, could not do anything with a swarm that had gone to zero, and could not tell a member which of the torrents in their client this site still carries. ## Superseding `superseded_by_id`, and the important part is what it does NOT do: the older release stays listed, stays downloadable, keeps its swarm, and its snatchers keep their hit-and-run obligations. People are seeding it, and pulling it out from under them would turn a tidy-up into a hit and run of the operator's own making. What changes is that both pages say so — a banner on the older one pointing at the replacement, a list on the newer one of what it replaced. Informational rather than a warning, because the file is still perfectly good; colouring it red would say otherwise. Four guards, and each is a specific failure rather than defensive padding. Self-reference would render a page pointing at itself and make the chain walk non-terminating; a cycle is the same thing the long way round; a target that is not accepted-and-active sends members to a page they may not be allowed to read; and a target that is itself superseded makes the pointer a dead end the member has to walk by hand. The walk is bounded at 32 hops anyway, because a cycle that arrived through a restore or a hand-edited row must not hang a request. `ON DELETE set null` rather than cascade: deleting the newer release must not delete the older one it replaced. ## Reseed requests A torrent at zero seeders was a silent dead end — the page showed a zero and nothing followed from it. Yet the site has always known exactly who could fix it: `hnr_tracking` holds one row per (member, torrent) forever, written both by the tracker on first completion and by the API the moment somebody clicks download, unconditionally. So the zero leads somewhere now. One request per torrent per day SITE-WIDE, not per member: the people who need protecting are the recipients, and ten members each asking once is ten notifications for one problem. `SET NX` on a Redis key with a TTL — no column, no sweep, and it expires by itself. Capped at 200 recipients, most recent snatchers first, because they are the likeliest to still hold the files. Superseded releases refuse: asking members to resurrect one works against a decision staff already took. Erased and banned accounts are skipped. Members who hide their download history are NOT — that preference governs who can enumerate their snatch list, and one notification about one torrent enumerates nothing. It does tell them the site remembers, which the guide says out loud. ## Unregistered infohashes The question a client cannot answer on its own: an announce failing because the tracker is down looks identical to one failing because the release was deleted, so the usual answer is to leave dead entries in place forever. One request over up to 256 hashes sorts them into `active`, `superseded` (with the replacement's hash), `pending` and `unregistered`. Rejected and inactive rows deliberately answer `unregistered`. Both the detail endpoint and the duplicate preflight are careful not to confirm that a rejected hash exists — it would make either an oracle for enumerating what moderation turned down — and an endpoint taking 256 hashes at a time is the last place to open that door. It is also the entry point automated cross-seeding needed: a script that knows which of its local torrents this site does not have is a script that knows what to upload. ## One extraction `withConcurrency` moved out of `followerFanout` into `utils/fanout`. The reseed ping is its second caller, and the reason it exists is worth having in front of whoever adds a third: each task is a database insert plus a Redis publish plus possibly an outbound HTTP request, so an unbounded `Promise.all` over recipients is a denial of service you write yourself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A member pastes their RSS URL into a service that indexes releases for them. They have just handed over the credential that announces on their behalf — because it is the same string. And the only way to take it back was to rotate the passkey, which breaks every torrent in their client at the same moment. So nobody did, and the key stayed out there. `rss_key` and `api_key` join `passkey`. The passkey remains the announce credential and only that; it is still the write key the tracker uses to book bytes, and none of that moved. What changed is that the two things a member hands to software are no longer the thing that speaks for them, and each can be revoked for its own reason without touching a single torrent. Nullable, minted on first read rather than at registration. A member who never wires up a feed reader should not be carrying two live secrets they have never seen, and a column that is null until somebody asks is a column an attacker cannot use. Postgres allows many NULLs under a unique index, so the constraint still holds for the ones that exist. Two things a survey of the passkey's blast radius turned up before any of this was written, both of which would have been quiet failures: **Panic mode encrypts `users.passkey` in place.** A new secret column that it did not know about would leave a "the database is encrypted" that is only mostly true — two live credentials in plaintext inside it. Both new columns are in the encrypt and restore paths. **The passkey is sealed into the session cookie**, and `GET /api/auth/passkey` reads it from there rather than from the row. A read key put in the cookie would survive its own rotation for up to seven days, which is not a revoked key. They are read from the row, on demand. The read surfaces now share one resolver. They did not before, and the difference was invisible until you hit it: the Torznab gate shape-checked the key and lowercased it, `requireSessionOrApikey` did neither, so a key stored lowercase but supplied uppercase authenticated on one surface and failed on the other. `requireReadAccess` resolves a session, then the surface's OWN key — an RSS key must not open the programmatic API, or the split has bought nothing — then the announce passkey while an operator still allows it. The old helper had exactly three callers, all migrated, so it is gone rather than left as a second way in. That last step is a migration path and is off nobody's critical path to remove: `legacy_passkey_read_access` defaults to TRUE, because every feed URL a member has configured anywhere carries the passkey and turning it off on upgrade day would break all of them at once — precisely the breakage this change exists to prevent. The Credentials card tells members to move over and says why. An operator flips it when they have. Caddy's access log strips `apikey` and `rsskey` now, alongside `passkey`. Before this, `apikey` WAS the passkey, so the existing scrub covered it by accident; generalising the parameter without generalising the scrub would have started writing live keys to disk. Erasure clears all three. Verified end to end on a throwaway stack: rotating a key makes the previous value answer 401 on the very next request, because there is no cache in front of these — which is the property that makes "revoke" mean revoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Members already do this. They run autobrr against an IRC channel, or poll an RSS feed, and race the upload. Both need something running somewhere, which means the feature is available to whoever has a seedbox and to nobody else — and most members do not. A stored filter, evaluated when an upload is accepted, delivered through the notification fan-out that already exists. The filter's vocabulary is the catalogue's own and deliberately no wider: free text, one category, tags (all of which must match — the listing's AND semantics), and the three media ids. A filter the catalogue cannot express could not be created from a "save this search" button, and one that matched on something the listing cannot show would be impossible to preview. Resolution and source are tags here because they are tags there, derived from the release name at upload. Three details about the text matching, each of which would be a wrong notification rather than a wrong page: The live search appends a prefix marker to the last term, because the member is still typing and `crown` should reach `crownfall` while the query bar has focus. A saved alert is settled intent. `toExactTsQuery` drops it, and the normalised form is stored so the comparison is one SQL expression rather than a re-parse per filter. It matches the release NAME only. The catalogue can also read descriptions and NFOs, which helps a reader casting about; an alert firing on a word buried in an NFO is a notification nobody can account for. The typo-tolerant fallback is not carried over. It exists to rescue an empty results page, costs about ten times a plain match, and on a per-torrent test would produce nothing but false positives. The evaluation is inverted: one query asks Postgres which stored filters match this torrent, rather than replaying each filter's catalogue query. So a thousand members with twenty filters each is one indexed query per upload, not twenty thousand. The structured half — category, tags, ids — is plain data already in memory and is filtered there rather than turned into a join. Two hook sites, the same two the follower fan-out uses: the moderation transition, which already computes the exact `pending → accepted` edge, and the auto-accepted upload path. Placed AFTER the tags are attached in the second one — evaluating at the insert would silently miss every tag-based filter. What it will never do. It will not push adult content to somebody who turned the setting off, checked against the live preference rather than the one in force when the filter was saved — the one rule here whose absence would be actively harmful rather than merely wrong, so it is applied to the recipient and applied last. It will not name an anonymous uploader; the notification says what appeared and not who put it there. It will not tell you about your own upload. `saved_search_max_per_user` (20) bounds the whole feature, since the cost is the number of armed filters across the site. A ceiling somebody chose beats one that emerges at three in the morning, and the fan-out logs its own duration past half a second so a sweep getting slow shows up in the operator's logs rather than in complaints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On an invite-only tracker, account sharing is the offence that matters most, and there was no evidence for it. `trusted_devices` and `webauthn_credentials` existed; neither could answer "where has this account been used from in the last month". One row per attempt to open a session, from all four paths that open one — the zero-knowledge login, TOTP, a recovery code, a WebAuthn assertion, plus registration, which opens a session like a login does and whose absence would make every history start at the second visit. Failures are rows too, and they are the more useful half. There is **no per-account lockout** on this site: throttling is entirely per IP, so an attempt spread across addresses meets nothing at all. A run of refusals against one account, from addresses that differ, is a shape worth recognising — and until now it left no trace whatsoever. A wrong second factor after a correct password is recorded separately, because that is the shape of a stolen password rather than of a forgotten one. The method is recorded, and the distinction between `password` and `trusted-device` is deliberate: a member reading their own history should be able to see which sessions never met their second factor. Two audiences, one table. Members read their own from Settings; staff read a member's from that member's profile page, which is where the question comes up — a moderator looking at somebody they suspect, not somebody hunting through a console. The staff view computes the count of distinct addresses **on the most recent day only**, because that is the only window in which the number means anything. Which is the limit worth stating rather than burying. The address goes through the same daily-rotating salt as every other IP here, so two rows compare only within one day; two different hashes a week apart may well be the same address. Both views say so, because a moderator drawing a conclusion from hashes a month apart would be drawing it from noise. While checking that: the README's claim that no raw IP is persisted is not quite true, and this change does not make it less true. `users.lastIp` holds one, for the IP-ban check. This table adds no second exception. Retention is 90 days by default, published on `/privacy` beside every other period — shorter than the audit log's year because this is a high-volume table and the questions it answers are about the recent past. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An account gets banned for cheating and the first question is who vouched for it, because whoever did is either careless or complicit and their other invitees are worth a look. Every tracker that has run on invites for two decades has this page. The data was already here — `invitations.created_by` and `invitations.used_by` — and the two pages that read it each rendered exactly one generation. So this is not new bookkeeping, it is a walk over records the site has kept all along. Two directions, two shapes. Upwards is a chain, nearest generation first, so "who is behind this member" reads top to bottom. Downwards is a tree indented by generation. One recursive CTE each, both bounded. The bounds are the design, not a safety net bolted on: ten generations, four hundred members total. A prolific inviter three generations down is thousands of rows, and an unbounded recursive walk over a social graph is a query nobody intended to write. The page says when it truncated, which matters more here than in most listings — a genealogy that silently stops reads as a chain that ends, and a moderator would draw exactly the wrong conclusion from it. Same reason the two ways a chain can end are told apart. `root` means nobody invited this member: they are the first account, or they registered while registration was open. Those two are indistinguishable in the data, and printing `root` rather than an empty list is what stops a reader assuming the record is broken. A depth limit is labelled as a depth limit. An erased account renders as a tombstone rather than a link. Erasure scrubs the username and leaves the invitation rows intact — the edges survive perfectly, which is what a genealogy needs, and the name behind them is gone, which is what erasure promised. Neither half is negotiable, so the page shows the shape and not the person. Bans are marked, since a cluster of them under one inviter is the entire point of looking. Admin only, and audited by the hook like every other staff route. This is the social graph of the whole site in one view; moderators get the one-generation view on a profile, which is what a moderator's question actually needs. One partial index, on `used_by where not null`. The upward walk hits that column once per generation and most invitation rows are unredeemed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Prowlarr and Jackett only know indexers they have a Cardigann YAML for. Trackarr is not in their catalogues, so every member wanting the tracker in *arr had to hand-write one, and a self-hosted tracker has no upstream maintainer to write it for them. Generated, not shipped as a static file, and my own note on this was wrong. A Cardigann definition carries the indexer's category map, and here that map is per instance: an operator renames, adds and deletes categories from the admin console. A file in the repository would be a map of *my* test instance, wrong for every deployment on the day it was committed, and wrong in the worst way — releases silently landing in the wrong *arr category rather than an error anyone can see. So `/api/torznab/cardigann.yml` reads the live categories and emits a definition for this instance, at this instance's URL, with this instance's name. The member downloads it from their Settings page, next to the RSS key — under that key specifically, since a Cardigann definition takes the read key and offering it beside the API key would suggest the two are interchangeable. Details that are load-bearing, learned from Prowlarr's own schema rather than guessed: `id` is the identity key. Prowlarr matches a definition to a configured indexer by `id`, not by filename, so it is derived from the instance host — two Trackarr instances in one Prowlarr must not collide, and a rename of the file must not orphan a working indexer. `legacylinks` is absent on purpose. It exists to migrate an indexer that changed domain, and a wrong entry hijacks whatever is at that URL. The definition declares `type: private` and a single `apikey` setting. Prowlarr then handles the key as a secret rather than a query parameter to log — which is the difference between a member's read key living in Prowlarr's vault and living in its logs. Categories are mapped to Newznab ids by the same table the feed already uses, so a category the feed can serve is a category the definition can name. Unmapped ones are omitted rather than guessed at: an omitted category is a release *arr does not import, and a wrongly mapped one is a release it imports as the wrong thing. Rate-limited and served to authenticated members only. The categories are not secret, but the file names the instance and its URL, and an invite-only tracker does not publish either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`/api/admin/audit` called `querySchema.parse()` on the query string directly, so a malformed `page` or an unknown `status` came back as an unhandled ZodError — a 500, and a line in the error log, for a request that was simply wrong. `validateQuery` is the wrapper the rest of the API uses; it turns the same failure into a 400 with the offending field named. Nothing else changes. Found while checking the routes added on this branch for the same mistake — they had it too, and were fixed before they were committed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An administrator blocks a member's Torznab access. The member opens their own settings page, resets their passkey, and the feed answers them again. Not evaded — erased. The entry is stored under `sha256(passkey)` truncated, so changing the passkey moves the index out from under it, and `/api/admin/torznab/blacklist` then lists nothing at all. Measured on a throwaway stack: refused before the rotation, served after, and the operator's own list of blocked members empty. Three routes write `users.passkey`, and looking at the other two was more useful than fixing the one: `admin/torznab/users/:id/reset` had the identical hole, which is worse than it sounds — reset sits beside block on the same page, so the button an operator presses to deal with a leaked key silently undid the one they pressed to punish. Lifting a block is its own route, deliberately. `auth/passkey` did carry the block, from `user.passkey` — the session's copy. A session opened before a rotation elsewhere holds a dead value, and the carry would then write the entry under a hash nobody presents. That failure looks exactly like success and frees the member. It reads the row now. The carry-over lives in `utils/torznabStats` rather than in each route, because the requirement is invisible from inside a rotation handler: the block is in Redis, under a key the route never mentions. `carryTorznabBlock` before the row changes, `retireTorznabPasskey` after — in that order no instant has a live passkey that is unblocked, which the other order cannot say. The entry is copied verbatim, so the console keeps the original reason and date; a carried block claiming to have been applied just now would erase the only record of when it really was. It is also the one function in that module that does NOT swallow a Redis failure. The rest fail open on purpose: enforcement that cannot reach Redis lets a poll through and bites again when Redis returns. A carry-over that failed open would lose the block permanently, so the rotation is refused instead — 503, and the passkey unchanged. Verified with Redis stopped. Panic mode is excluded, and not as an exception: it encrypts and decrypts the same credential for every account, so the index leaves and comes back with it, and writing a block under the ciphertext's hash would be wrong. The test is structural, over the route sources, because that is the shape of the bug: nothing in a handler that mints a passkey hints that something in Redis depends on it. It fails on the fourth rotation route, the one nobody has written yet. Mutation-tested, and the first version of it was worthless — an unused import satisfies a name match, and `indexOf` answers -1, which is less than every offset in the file, so the ordering assertion passed loudest on a route that did neither. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…into The gate itself already existed, and this is the part worth writing down: a theme editor that measures contrast, warns rather than refuses, and reasons about WCAG 2.x versus APCA shipped three days before I went looking for it. What was wrong was its coverage. Ten pairs were declared. `.input` sets `background-color: rgb(var(--bg-elevated))` with `color: rgb(var(--fg-default))` and a `--fg-faint` placeholder, and neither of those surfaces was among them — so the text a member types, on every form on the site, was measured against nothing. `--bg-inset` was in the same position: 36 components paint on it, and in the LIGHT theme it is the darkest surface rather than a middle one, which makes it the worst case there rather than a case already bracketed. Adding the pairs made the two SHIPPED themes fail, which is the argument for them: dark fg-subtle 4.00:1 on a field, 4.23:1 on a card, 4.40:1 on a panel light fg-subtle 4.35:1 on an inset panel light fg-faint 4.35:1 on an inset panel `--fg-subtle` had been set to 121 to clear 4.55:1 on `--bg-base`, and 121 is below AA on every other surface it is painted on. The same file already records this lesson twice — `--fg-faint` shipped at 1.84:1, light's `--fg-subtle` at 2.08:1 — and both times the fix was to measure the pair somebody had declared. The third time, the pair was the one nobody had. So: 127 in dark, 111 in light, both a few points and imperceptible, both verified against all twenty pairs. Changed in `main.css` AND in the copy inside `packages/shared`, which an existing test compares — a duplicate that can drift is a duplicate that will. The status colours are now checked as TEXT, which they are: `text-error` appears in 18 components, `text-warning` in 9, `text-accent` in 9. They pass in both built-ins; the pairs exist so an operator's theme cannot quietly fail them. `--info` is deliberately absent — it has no text use at all, and a pair nothing renders is a pair that will be wrong without anyone noticing. And the editor now shows the whole measurement rather than only the failures. 4.6:1 and 12:1 both look like silence when a gate speaks up only when it is unhappy, and one of them breaks on the next nudge. Twenty rows, each with its ratio, its requirement, and a sample drawn in the two colours being measured; the count in the header (`20/20`) is the summary. The pair labels are translated through a key derived from the two token names, because the English in the schema is a code comment that happened to be rendered — the warning line above still interpolates it, which is how a French console came to read "muted labels on cards". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every number on this page was already in the database and visible only to administrators. A member saw four counters on the homepage — releases, peers, seeders, volume — and nothing else: not how the catalogue is shaped, not whether the site is growing, not what everybody is grabbing. Trackers that publish this keep people, because the numbers are the community looking at itself. ## What is deliberately not on it No per-member volume. No ratio board, no "top downloaders", no terabytes beside a username anywhere. Those are the numbers a tracker leaderboard is traditionally built from, and the reason this one is not is narrow and checkable: there is no setting on this site by which a member could decline to appear in one. Publishing it would publish, for every member, a figure they never agreed to publish — and unlike the adult filter or anonymous uploads, they would have no way to opt out. The board here ranks by NUMBER of live releases, which is already listed on each member's profile, so counting them discloses nothing new. The note under the board says so, because an absence a reader cannot explain looks like an oversight. A member who uploads anonymously is not in the board at all. `anonymous_uploads` conceals a name on every surface that attributes a release; a leaderboard would be the one surface that undid it. Their releases still count in the totals. The adult tree is filtered out of every list that names a release, for whoever has not opted in — one predicate, `visibleTorrents`, shared by all of them, for the same reason `redactUploader` is one function: five hand-written copies of "what a member may see" will disagree, and the failure is a leak rather than a wrong total. ## Two figures that are not the same, and both are here `bytesAdded` is the size of what was catalogued. It is exact. `trafficBytes` is what actually moved, taken as the difference between the first and last hourly snapshot in the window — and it is approximate BY CONSTRUCTION. The counter behind it is `SUM(users.uploaded)`, which DROPS when an account is erased or a moderator resets a cheater. So it is a floor, it is null for a window with no snapshots, and the page says both. Presenting it as a total would be the lie. Per-day movement is clamped at zero for the same reason. "-4.2 TB of traffic on Tuesday" is not a fact a reader can do anything with except try to explain it. ## The derivations are pure, and tested `site_stats` is hourly cumulative counters, so every daily figure is derived: one point per day (the LAST reading, not an average — the mean of a rising counter is a value it held at no moment), and deltas between consecutive points. A day with no snapshot is absent rather than zero, because an instance that was down for six hours must not draw a cliff on a counter that never moved. Those four functions have failure modes that look like data rather than like bugs, which is why they are pure and have thirteen tests: the gap, the backwards counter, the first day with no predecessor, the year the site did not exist for. ## Charts without a charting library The CSP rules out a CDN, and the shape needed is a polyline and some rectangles. Under every chart is a collapsed table of the same array — a picture is neither accessible nor verifiable — and each carries a summary that names the peak for a bar chart and the endpoints for a trend, translated like every other string, because an aria-label is read aloud. Years run January to January in UTC. The members of one tracker are spread across every time zone, so a year anchored on the server's offset would be an arbitrary choice presented as a fact. The selector only offers years the instance has snapshots for: a review of a year the site did not exist for is an empty page, and an empty page reads as broken. Verified against a seeded catalogue whose answers were known — the anonymous uploader absent from the board and present in the totals, the adult and pending releases absent from every list, a deliberate counter drop clamped to zero, a malformed year answered with 400 rather than 500, and all three endpoints refusing an anonymous caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…reath autobrr and autodl-irssi are how releases are actually raced, and both speak IRC. The mechanism is deliberately archaic — a bot says one line per accepted upload, a client matches it against filters and grabs — and that is exactly why it is universal. An RSS feed is polled once a minute; a channel message arrives when the release does. `grep -ril '\birc\b' apps/ doc/` returned nothing before this. ## The decision the roadmap left open It worried about freezing a public format. The answer is not to freeze it: the announce line is a template in settings, and **the regular expression handed to members is derived from that template**. Every tracker that ships a hand-written definition beside a configurable format eventually ships two things that disagree, and the failure is silent — the channel keeps announcing, the definition keeps not matching, and members conclude the tracker is broken. `announcePattern(template)` makes that impossible by construction, and it settles the versioning question too: the template lives in the database, so a new default in a release never changes what a running instance emits. `/api/irc/autobrr.yml` therefore generates the whole definition, the way the Cardigann one does and one step further: that one is per-instance because the categories are, this one because the FORMAT is. The file carries a `tests` block — a rendered sample and the values it should yield — so it arrives with a proof that it parses this instance's own format. The test in this commit is the round trip, run against the real shipped pattern converted to JavaScript syntax rather than rewritten in it: render a line, parse it back, check every capture. It covers a reordered template, a repeated token (Go's regexp rejects a duplicate group name, so the second use is a back-reference), an operator's typo, and the values that break naive patterns. ## Two omissions, both on purpose The upload multiplier is printed for people and mapped to nothing, because autobrr has no field for it. The seeding requirement is not in the line at all: `minimumRatio` and `minimumSeedTime` are NOT in autobrr's `MapVars` — read from its source, after its documentation suggested otherwise — and it reads both from the Torznab feed this site already serves. A field no tool can consume is noise in a format that has to stay parseable for years. No key travels in the line. Everybody in the channel sees every line, so a personalised download URL would hand every member the credentials of one; the client appends its own read key, which is what the generated `downloadurl` template is for. ## What the bot will never do It never reads a command from the channel. The only inputs it acts on are PING and the numerics that tell it whether it is connected — a bot taking orders from a channel would be a remote control for the tracker, gated on IRC's idea of identity, and IRC does not have one. It never joins a second channel and never speaks to a user. Values are sanitised before they reach a line, and that is the injection boundary of the feature rather than a nicety: IRC frames commands with CRLF, so a release name carrying `\r\n` does not corrupt the line, it ENDS it, and the rest becomes a command the bot appears to have sent. Release names are member-supplied. ## One bot, however many API instances A Redis lease, renewed every fifteen seconds, elects one connection. Three instances would otherwise mean three bots and every release announced three times — to autobrr, which would grab it three times. Announcing is never on an upload's path: `announceRelease` resolves what it needs, hands a line to a paced, bounded queue, and returns. A channel that is down cannot slow a moderator's queue. Past the cap the OLDEST lines drop, because on an announce channel a stale release is worth less than a fresh one. Adult releases are off by default, and that is a judgement rather than a convenience: a channel is one stream with no per-member preferences in it. Members can turn adult content off on the site and nobody can turn it off in a channel, so the operator decides once, and the safe direction is the one that does not put titles in front of people who turned them off. An anonymous uploader is never named, by the same rule the catalogue and the feeds use. Verified against a real ircd: the bot joins, an accepted release is announced with the right size and buff, an approved ADULT release produces nothing, the anonymous member's release says `anonymous`, and the regex from the file the server handed a member parses the lines a witness in the channel actually heard. Two instances announced once; the survivor of a `kill -9` on the leader took the channel over and announced again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ders
The whole point of splitting the credentials was that a member could hand a
feed key to a third party without handing over the key that announces for
them. Every search response undid it:
downloadUrl: …/api/torznab/download?id=<hash>&apikey=${user.passkey}
`user` is resolved BY the presented key — the RSS key, usually — and then the
enclosure was built from `passkey`, the announce credential, on every item. So
a member who did exactly what the new guide told them, and put their RSS key in
a shared Prowlarr, published their announce passkey to whoever else uses it.
One GET, no interaction, and rotating the RSS key changed nothing because the
passkey in those URLs still worked.
`authenticateTorznab` now reports the credential the caller actually presented,
and anything echoing a key into a response uses that. Verified against a live
stack: with the RSS key, every enclosure carries the RSS key and the passkey
appears nowhere in the response.
## What a read key still gets you, said out loud
It cannot announce. It CAN download a `.torrent`, and a `.torrent` carries the
member's announce URL — that is how BitTorrent works. So a read key is safe in a
client you control and is not safe in the hands of somebody you would not trust
with your passkey. The guide says that now instead of implying the opposite, and
the generated Cardigann definition's help text says it too rather than "it
cannot announce, so it is safe to paste here".
## Three more in the same area
`/api/torznab/cardigann.yml` was **unauthenticated** while the commit that added
it claimed members only — an anonymous caller got the site name and the whole
category taxonomy from an invite-only tracker. Gated, like the autobrr sibling
that got it right.
Its `id` was derived from the site name, which defaults to `TRACKARR`, so every
instance nobody renamed produced the same identity and filename — two of them in
one Prowlarr overwrite each other, exactly what the commit message claimed to
have avoided. Derived from the host now. The key field is `type: password`
rather than `text`, so Prowlarr treats it as a secret.
`ensureKey` had a docstring describing a null-guarded write and no guard: two
concurrent first reads both minted, the last write won the row, and each request
returned its own value — one member walking away with a key the row does not
carry. The guard is real now, and the loser re-reads.
`retireTorznabPasskey` deleted the old block entry unconditionally, which lost a
block an administrator wrote DURING a rotation. It only deletes what the
carry-over actually moved.
`authenticateTorznab` also never checked `deleted_at`, the only read surface that
did not. Latent — erasure clears the read keys — but it was the one door left
open for a future path that scrubs a name without scrubbing keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…left
Six defects, and two of them meant the feature did not work at the scale it was
written for. Each was measured before and after.
## The announce never left the instance that served the upload
`announceRelease` opened with `if (!client) return`, and `client` is only
non-null on the instance holding the Redis lease. But it runs in the process that
served the HTTP request — so on three instances roughly two thirds of accepted
releases were dropped, silently. The fix for announcing three times had produced
announcing once in three.
The line is now rendered where the release was accepted and published on Redis;
the lease holder is subscribed and says it. One path for every line, including
the leader's own, and the admin test button travels the same way — so testing
from a console served by a non-leader works instead of refusing.
Verified: four releases approved across two instances, all four in the channel.
## The queue paced nothing
`pump()` wrote synchronously whenever the queue was empty, which is every
announce, because each one drains it. Ten uploads accepted in the same second
went out inside a millisecond of each other; `QUEUE_CAP` and the drop-oldest
logic were dead code. An ircd answers a burst like that with a kill for excess
flood, and the tracker then says nothing at all until the next reconcile.
The interval is measured from the last line actually sent. Verified: four
approvals in 158 ms produced lines at +1501, +1500, +1499 ms.
## The pattern we hand autobrr could not compile
A repeated token emitted `(?P=name)`. Go's RE2 has **no backreference
construct** — measured against Go 1.26, which is what autobrr compiles it with —
while it accepts a duplicate group name, the opposite of what the comment
claimed. The round-trip check could not see it because `toJsRegExp` rewrote it to
`\k<name>`, which JavaScript does support, so the template saved clean and the
definition was rejected wholesale by autobrr. A test asserted the broken output.
It is a non-capturing repeat now, and the test says why.
## A long name made the line unparseable
Truncation cut the tail of the finished line, and the tail is `:: {url} ::
{infoHash}` — which the pattern anchors on. Any release named past about 170
characters was announced in a form no client could match, and the upload route
allows 256. The name absorbs the overflow now, so the hash always survives.
## A colon in a tag name did the same
`tags` was matched with `[^:]*?` while `tags.name` is free text — only the slug
is charset-restricted. One member creating `quality:high` broke every release
carrying that tag, for ever. Fixed in the token rather than by stripping colons
from values: I tried that first and a probe against a real ircd showed it turning
`https://` into `https-//` in the link field.
## And the operator's secrets
`perform` holds a NickServ IDENTIFY line by design, and it was returned verbatim
by the GET, rendered into a textarea and embedded in the page's server-rendered
payload — while the module's own contract is that a secret is never re-emitted.
The console now shows how many lines are stored and never the lines.
Also: a template can no longer contain a control character (a newline broke or
injected into the generated YAML, and every validation gate passed on it); two
adjacent free-text fields are refused (four of them cost 20 seconds of blocked
event loop in the round-trip check); the lease token is unique per acquisition
like every other lock here; a dead socket is noticed instead of being reported as
`ready` for ever; SASL PLAIN is chunked at 400 bytes; and a release is announced
once rather than once per approval — an ordinary edit sends a torrent back
through moderation, and re-approving it was putting the same line in the channel
again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## The opt-in was read from a field nothing writes Both routes did `!!user.showAdultContent` on the session object, and **no login path puts that flag in the sealed cookie** — every other route that needs it reads the row, with a comment saying why. So the value was always `undefined`, always false: a member who had opted in never saw their own categories on this page, and the `all` half of the cache key was unreachable code. Fail-closed, so nothing leaked — but the feature was half missing, and a claim that held by accident is not a claim. Read from the row now, deliberately not from the cookie: a seven-day session would keep serving an adult view for a week after somebody turned it off. Verified: two members, one opted in, two different views, cached apart. ## `hide_download_history` was a door this page walked around `/api/me/downloads` refuses the snatch list to the authenticated caller when that flag is set, and states the threat model: a stolen session must not be able to enumerate it. A year of downloaded bytes and a grab count come from the same table on the same precondition, and this route served them. It honours the flag now; the upload figures, which come from `torrents`, stay. ## Three figures that were wrong rather than missing **A gap in the snapshots was attributed to the day it ended.** `dailyPoints` omits a day with no snapshot — correct — and `dailyDeltas` then differenced consecutive points regardless of the distance between them. Five days of outage landed on the day the collector came back, which made "the busiest day of the year" the day after the longest outage, every time, with a bar that flattened the rest of the chart. Deltas across a gap are skipped. **The day label was not UTC.** `created_at` is `timestamp without time zone`, postgres.js hands a zone-less value to `new Date()`, and the shipped compose file sets `TZ=Europe/Paris` — so bucketing in JavaScript cut the days at 22:00 UTC while calling them UTC dates, and a New Year's Eve upload could land in the previous year's review. Postgres formats the day now. **Bar charts were anchored at the series minimum** because they reused the line chart's scale. A 90-day traffic series between 900 GiB and 1 TiB drew the quietest day as a hairline and the busiest as a full bar — an 11 % spread rendered as a factor of a hundred, and the quietest period indistinguishable from one with nothing in it. Bars start at zero; the cumulative counters keep their minimum-anchored scale, which is what makes a slope readable. ## And three smaller ones The year's `snatches` counted `hnr_tracking` ROWS, and a row is written when a member downloads the `.torrent` — so it counted metainfo downloads while the figure beside it in the header counted real completions. Two quantities, one word. It counts completions now. Every uploader on the board linked to `/users/<username>`, and the profile page routes on the id: ten rows, ten 404s. `sum(seeders)::int` and friends: `sum()` of an integer is a bigint, and `completed` is cumulative, so a catalogue past 2.1 billion total completions would have thrown `integer out of range` and taken the whole page with it. The year parameter accepted 2000-2100 — 101 cache keys against a 40-entry cache, so a caller walking the range missed every time, and each miss is four range scans over `torrents`. It is bounded by the years the instance actually has, the lesson its sibling route had already learned about the window parameter. The growth series come from the operator's whole-catalogue snapshot and can legitimately exceed the filtered figures above them; the panel says so rather than leaving a reader to notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eleven findings across the four features that hold somebody's record. The two that a member could exercise came first. ## Any member could bury the audit register `auditActor` is set by plain authentication, and a staff gate throws its 403 BEFORE the route's own rate limit — so a member looping `PUT /api/admin/settings` wrote one row per request, bounded only by the global DDoS floor. Around 750 000 rows a day, into an append-only table with a year of retention and no per-row delete: the flood buries the exact pattern the register exists to show, and an administrator cannot clean it out. A refusal from somebody who is not staff is now throttled to one row per minute per actor and action. The first attempt — the line that matters — is always recorded. Verified: twelve refused admin writes from a member, one row. ## An unauthenticated stranger drove the account-sharing metric A failed login records the target's account and the CALLER's address hash, by design: that is the shape this table exists to show. But the moderator's "distinct addresses today" counted every outcome, so twenty bad logins from twenty addresses — no password needed — read as "20 different addresses on the most recent day". On an invite-only tracker that number is the evidence for the one bannable offence, and it was writable by anybody. Successes only now, and counted in SQL rather than from the returned page: taken from the page, a member signing in fifty times from one address pushed the others off it and the count read `1`, so the subject could suppress it at will. ## The hash was a confirmation oracle `sha256(secret:day:ip)` is the same value in a member's own view and in a moderator's view of that member. A moderator suspecting an account is shared with somebody they can reach could sign in from that address, read their own hash, and compare. Both views now show an ordinal that means something only inside one response — which is the only property either of them ever needed. ## The audit register missed the staff powers that live on member routes `isAuditable` matched `/api/admin/` and `/api/mod/` only, so a moderator deleting a torrent, a comment, a forum post or a message left no row — "who did what, across the whole console" excluded a list of things staff do every day. Those paths are audited when the actor is staff, which keeps the register from becoming a log of everybody's activity. Route parameters are stripped by position rather than by value: a member named `mutes` turned `mod.room.mutes.delete` into `mod.room.delete`, and usernames have no reserved list. Reading the invite tree — the social graph of the whole site, in one response — is audited explicitly, because reads are not logged by default and the commit that added it claimed otherwise. ## The export was not honest, and the erasure was not complete Nine collections reported `items.length` as their own total, so `truncated` was permanently false: a 5000-row prefix presented itself as the whole record, which this file's own header calls worse than refusing to export. Real counts now. Notification payloads were exported verbatim, and they carry `actorUsername` (the moderator who banned this account), `inviteeUsername` (the member who used its invite) and 200 characters of a staff message — the two categories the file explicitly promises to withhold, handed over in bulk. Filtered by whitelist, and `notIncluded` says so. Saved searches and the login history were in neither the export nor the erasure, on a branch whose point is GDPR. Both are the member's own data, and neither cascade fires — the `users` row survives an erasure by design — so an erased account kept its stored filters matching uploads and writing notifications to a tombstone, and its login history stayed attributed and readable through the moderator view. Both are exported and both are deleted now, along with the Torznab access block and request log keyed on the passkey the erasure rotates away. ## And four smaller ones A failed recovery code and a failed passkey assertion left no trace, in a table whose stated point is that a failure is the interesting half — so an account protected only by a passkey showed no failures at all. The saved-search cap was read-then-insert; it is one statement now, which matters because that cap is the only bound on what the fan-out costs the whole site. The fan-out had no try/catch, unlike the sibling it was modelled on, so a failure surfaced as an anonymous unhandled rejection. `login_event_retention_days` and `saved_search_max_per_user` had getters, defaults, documented meanings — and no writer anywhere. The retention period published on `/privacy` could only be changed with a SQL prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… oracles ## /scrape had no passkey and now had a database behind it BEP 52 support added a resolve to the scrape path so a v2 client scraping under the truncated hash finds its swarm. `/scrape` takes no credential — by protocol — and on a miss the resolve is two index probes, up to eight hashes per request. So an endpoint that used to cost zero database work became sixteen round-trips per request from anybody on the internet, against a pool of twenty connections shared with the announce path. A few thousand requests a second of random hashes stops the tracker answering for everyone. A hash that did not resolve is remembered in Redis for five minutes. The negative answer is the cheap half: it is what a flood is made of, it cannot hide a real torrent (only misses are cached), and Redis is already on this path for the peer counts. Verified: the tracker builds, vets and passes its tests. ## Two endpoints confirmed what moderation had turned down `GET /api/torrents/:hash/supersessions` filtered its RELATIONS by visibility and not the source row, so a rejected hash answered 200 with empty relations while an unknown hash answered 404 — a sorting oracle over the moderation queue, on 100 requests a minute. Its own docstring claimed the opposite, and the detail endpoint flat-404s precisely to avoid this. `POST /api/torrents/unregistered` named the replacement of a superseded release without checking that the replacement is still visible. Nothing clears the pointer when a target is later rejected, so the endpoint whose docstring calls itself "the last place to open that door" handed out the hash and the name of a rejected release. Both filtered now, both verified against a live stack. Neither applied the caller's adult-content setting either; both do, and `unregistered` now spends a per-account budget counted in HASHES rather than leaning on a per-IP request limit that allowed 3.7 million probes a day from one address. ## The economics were read through two clocks `multipliers_until` was `timestamp without time zone`. The Go announce compares it with `now()` in a UTC session — correct — while postgres.js hands the zone-less value to `new Date()`, which reads it in the API container's zone, and the shipped compose file sets `TZ=Europe/Paris`. For a window as wide as the offset, the tracker charged a freeleech the feed had already declared expired — and the admin form re-serialised the skewed value, walking every expiry two hours earlier on each save. It is `timestamptz` now, converted explicitly from UTC, and all three readers agree. The migration also adds what the review found missing: a CHECK bounding both multipliers at the database level (the Go bonus code validates the values it reads from Redis for exactly this reason and passes the per-torrent ones straight through), an index on `torrents.created_at` and on `hnr_tracking.downloaded_at`/`completed_at` (the year-in-review queries were scanning), the two ranking indexes on `torrent_stats`, a partial index on `is_sticky` (page one asked for pinned torrents with nothing to answer from), and `ON DELETE set null` instead of `cascade` on a saved search's category — deleting a category should narrow a member's filter, not delete it. ## And two more A supersede cycle was reachable by two moderators acting at once: each read the other row as a chain head, each walk terminated, both writes committed. The target is checked inside the write predicate now, and a refused write is a 409 rather than a silent success. Reseed requests notified the OLDEST 200 snatchers — the docstring, and the commit message, both said newest, and newest is the point: those are the people who might still hold the files. One missing `desc()`, and a daily lock that meant nobody could try again that day. `infoDictRange` recursed without a depth bound; fifteen thousand nested containers in a hostile `.torrent` reached the member as a 500 rather than as a refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… nothing
`.tool-btn` and `.field-label` existed only inside other files' `<style scoped>`
blocks. A scoped rule compiles to `.tool-btn[data-v-…]`, so the three files that
merely *used* the class — `alerts.vue`, `torrents/index.vue`,
`admin/audit.vue` — got nothing: Tailwind's preflight strips a button's
background, border and padding, so what shipped there was an unboxed 16px glyph
with no hover, no disabled state and no focus surface. Eleven controls and six
micro-labels, across three pages, looked broken rather than styled.
Worse on `/alerts`, where `--danger` was never defined anywhere at all: the
irreversible action and the safe one beside it were distinguishable only by the
shape of their icon.
Promoted to `@layer components` with the modifiers that were missing entirely
(`--danger`, `--text`, `--sm`). 2.25rem square is 36px, which clears the 24px
minimum of WCAG 2.2 SC 2.5.8 with room for the border. The two scoped copies stay
where they are: `<style scoped>` is unlayered, so it still wins over a layer
regardless of specificity, and those pages keep rendering exactly as they do.
Two more from the same pass:
`.input:focus { outline: none }` outranked the bare `:focus-visible` rule further
up the sheet, so tabbing through a form of eight fields signalled position with a
1px border tint and nothing else. Restated at equal specificity, later, so the
keyboard gets its ring back and the mouse keeps the quiet border.
`NotificationToast` is the only channel every confirmation on this site travels
through — a rotated key, a saved search, a reseed request, and every error
including the ones a route hands back verbatim — and it had no `aria-live`. A
screen reader heard nothing after pressing any of them.
…slot Two pieces of arithmetic that both answered the wrong question, extracted and tested before the surfaces that consume them are touched. **`formatAge` on a future date returned "just now".** The diff goes negative, the first branch catches it, and every date ahead of now came back as the phrase for "a moment ago". That is how a live freeleech with three days left announced itself on the busiest strip of a torrent page as "FREELEECH until just now" — a member reads that as an expired promotion and does not download. Guarded, and given the forward-looking companion it always needed: `formatUntil` returns "in 3 days" / "dans 3 jours" from `Intl.RelativeTimeFormat`, and nothing once the deadline has passed, so a caller stops drawing a badge instead of drawing a lie in it. **`formatAgo`**, same idea backwards, for the callers that interpolate an age into a translated sentence. `formatAge` returns `3d ago` in English always, which survives in a bare table cell and does not survive inside a message: `alerts` rendered "last 3d ago ago", and in French "la dernière il y a 3d ago". Because the direction is part of what `Intl` returns, the surrounding message must not add its own. **The bar geometry floored a bar's width at 0.4 viewBox units.** At the 365-day window a slot is 0.274 units wide, so every bar was 46 % wider than the space it had and painted over its neighbour: the traffic chart rendered as one solid rectangle, and a member read a year of perfectly uniform traffic where the data said otherwise. The floor is gone — sub-pixel is at least honest — and past 120 points the series is summed into weeks instead, which is a chart rather than a smudge. Lifted out of the component because a component test would need a DOM and a mount, while these are numbers. Both defects are pinned by tests, and both tests were mutation-checked: reinstating the 0.4 floor fails two of them, removing the future-date guard fails another.
…verstated growth A page of figures whose largest object was its own `h1`: 40px against 26px figures, so a reader arriving to ask "how is the site doing" read a label before they read an answer. The title is on the house scale now and the figures are the biggest thing on the page, which is the right way round for an instrument. The history panel was making claims it could not support: * **The cumulative charts had no y-axis at all.** They are anchored at the series minimum on purpose — the slope is the story and a zero-based axis flattens it — but shipped with nothing on the scale, next to a zero-anchored bar chart. A member saw a steep climb with no way to know the axis started at 5,412. Both ends of the scale are now printed on the plot. * **"Releases" named two different numbers 600px apart** — the filtered figure at the top and the whole catalogue in a chart caption — reconciled by an 11px footnote explaining that one "sits above" the other, which the French rendered as a claim about physical position. The chart series is now "Catalogue, all statuses" and the note is one clause. * **The window switcher governs one section of six**, and nothing said so: a member who picked "30 days" and watched the rankings not move concluded the page was stuck, or worse, that the list *was* 30-day data. Both boards say "all time" in their titles. Layout defects that were reproducible at a fixed width: * `.st-panel-head` is `space-between` with two children, so a bare title and its note flung the note to the far edge — a narrow column of explanatory text some 770px from the heading it belonged to, on three of six panels at 1440px. The copy explaining each chart read as unrelated marginalia. * The lead figures drew their hairlines by letting a coloured parent show through a 1px gap, which also showed through any cell the grid did not fill. Five figures are only even at five columns; every width below 748px put a solid grey rectangle where a figure should be, and on a phone it sat above the fold. And the numbers themselves: * No delta on any lead figure, while `deltas` was already on the wire — so the page's headline question was answered only by eyeballing an unlabelled slope. * Category bars normalised to the largest category, which makes the top one a full bar on every instance; a reader reads a bar as share-of-whole, and the tell was that the panel note had to teach them otherwise. Share of the catalogue now, so two categories can be compared by eye. * The bar track was `--bg-inset`: in the light theme that is 245 on a 255 panel, a 4 % step, so the proportion had no visible denominator. * `--online` green on every seeder row, which `main.css` reserves for status badges and explicitly not for chrome, and which implied one ranking value was "good" and the one beside it neutral. * Day labels were raw `YYYY-MM-DD` on a page whose month axis was localised — and the ISO string is what the chart's `aria-label` reads aloud, digit by digit. Plus the house rules this page was breaking: `.eyebrow` used for three `h3`s, making them smaller than the body copy they introduced, which `main.css` forbids in capitals; `aria-pressed` on mutually exclusive options, so a reader heard "30 days, not pressed" for every window they had not chosen; a scroll container the keyboard could reach but not scroll; three disclosures with the same name in one panel; a 20px target on each of them; and `999px` where `--radius-pill` exists. The member's own year was the quietest thing on a page whose argument is that the numbers are the community looking at itself — a 1px rule inside the year panel, under a heading smaller than its own body text. It is a section now, at the same weight as the rest.
… page had no door `notify` was in the payload, in the interface, and rendered nowhere. So a saved search that will tell you when something matches looked identical to one that will not, on a page whose own lede promises the notification. Each row now says which it is, in words and with a different icon, so nothing rests on telling green from grey. **The page was unreachable.** The only two ways in were the command palette and a link on the catalogue that appears solely while a saveable search is on screen — so a member who saved one, got the toast, and came back later had nowhere to go. Added to the account menu and the mobile menu, beside `/favorites`, its closest sibling. **Three chips were dead UI, and one search could not be saved at all.** The route has accepted `imdbId`/`tmdbId`/`tvdbId` since it was written and this page renders a chip for each, but the only thing that creates a saved search never sent them — so browsing one film's id and asking to be told about it, which is exactly the watch worth saving, was refused by `canSaveSearch`. **Deleting asked nothing.** One click on an unlabelled glyph permanently removed a standing instruction, on a page that offers no way to recreate one: the criteria live on the catalogue, so getting it back means rebuilding the search. Meanwhile the credentials card asks for confirmation on a rotation its own CSS comment calls routine and reversible. Two 4xx bodies from `saved-searches` were English sentences written for a log — "You can keep up to 20 saved searches. Delete one first." — and were being echoed straight into the page. The route now carries a `reason` and the browser picks its own translated sentence. Rest of the pass: `Matched 1 times`; `last 3d ago ago`, and its French variant carrying an English unit; a fallback label that made every tag-only search "Untitled search" forever, on a read-and-delete page; `title` as the only accessible name on both row controls; the destructive control adjacent to the safe one with no separation; a free-text chip with no bound, which overflowed the card at 390px; a 34rem breakpoint where the house uses 45rem, so at 560px the chips were still crammed against the controls; 3rem of dead space above the eyebrow where the sibling pages start at the top of the column; and two hardcoded `letter-spacing` values that a theme's `--tracking-scale` could not reach.
`rotateReadKey` guards its assignment on `readKeys` being loaded, and `readKeys` is `null` until a reveal or a copy fills it. So rotating without revealing first skipped the guard, discarded `res.key` — and then flipped the card to visible, rendered a literal `…`, and fired a green "Key rotated." The member had just invalidated whatever Prowlarr or Sonarr was using, with the replacement neither on screen nor copyable, and nothing saying so. Only a page reload recovered it. **The four Copy buttons arrived disabled.** Values are lazy, so `passkey` and `k.value` are empty on load and every button was greyed at 50 % with `cursor: not-allowed` — while `copy()` and `copyReadKey()` both fetch the value themselves. The safest and most common interaction, copying an announce URL into a client without ever putting the secret on screen, was the one the card refused, with no explanation of why the control was dead. **Three hardcoded colours broke the light theme.** The contrast gate added on this branch measures token *pairs*; it cannot see a hex literal in a component's scoped CSS, so these sailed past it. `#f5c518` on a near-white surface measures **1.64:1** — and that was the colour of "Never share these credentials", the most safety-critical sentence on the page rendered as the least readable one. `#6cd161` on the key value came to roughly 2:1, `#34d4d8` to 1.9:1. Measured after the swap to `--online` / `--warning` / `--info`: 4.81, 5.02 and 5.93:1. (There are 271 more such literals elsewhere in the frontend; they are a separate sweep, and extending the gate to refuse them presumes it done.) **The legacy-passkey banner appeared only after the member had already found the thing it points at.** It was keyed on `readKeys`, which is populated by the reveal and copy handlers, so the warning telling a member their announce passkey still works on feeds — and that they should migrate — showed up only once they revealed or copied an RSS key. The member who most needs it, still using their passkey in Prowlarr and never opening the RSS card, never saw it. The flag is a site setting with no secret in it and now rides on `/api/me`; fetching `/api/me/keys` eagerly instead would have minted a read key for every member who opens their profile, which is the decision that route exists to avoid. Also: `readKeysBusy` was a boolean, so rotating one read key froze the other's control; the Prowlarr download row inherited the "never share this" amber through a `:not()` selector, spending the page's one alarm colour on a file download; the four cards were `<article>`s with no heading and no accessible name, so a screen reader navigating by landmark heard "article" four times; the reveal toggles carried no `aria-pressed`; and ten icon-only controls were named by `title` alone, which is the last resort in accessible-name computation and invisible on touch.
The profile counted `uploaderId = target` with no other predicate, while `users/[id]/uploads.get.ts` applies three for an ordinary reader: accepted rows only, no adult categories unless the viewer opted in, and NOTHING AT ALL when the target has anonymous uploads on. The page therefore printed "12 uploads" directly above a list saying this member publishes anonymously — and leaked the pending and rejected counts to any authenticated member along the way. The count stays whole for the member themselves and for staff, who already see the full list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`lock` and `pin` read a bare body and dereferenced it. A PUT with no body, or with a JSON `null`, threw a TypeError — a 500 with a stack trace where a 400 says what is wrong. They were the only two left in the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…p reset
`utils/twoFactor.ts` only writes the single-use Redis key when the caller passes
`{ userId }` — that was the fix behind finding M3. Three of the four call sites
pass it. `totp/enable` did not, so a captured code stayed replayable there. The
window is narrow (the route answers 409 the moment 2FA is on, so it only exists
during enrolment); the asymmetry inside a security guard is the finding.
`me/index.patch` set `loggedInAt: Date.now()` when refreshing the session, so
editing a bio reset the login moment. Inert today — the `requireFreshAuth`
window lives in Redis keyed on the h3 session id, and nothing reads this field —
but a trap laid for whoever comes to rely on it, at which point an idle request
would make an old session look fresh. Refreshing the session is for the navbar
and the theme; the login time has no business in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The middleware skips the ban gate for all of `/api/auth/**` — it has to, login and registration are anonymous — but the skip also covers the account routes that live under that prefix. And `requireUserSession` does not read the ban status, unlike `requireAuthSession`. A banned member therefore kept rotating their passkey and changing their password: the front door shut, the service door open. Measured 200 before the ban and 403 after, on all three. Fixed per route rather than by narrowing the middleware's skip: the skip is right for what it covers, and listing routes one by one would make an authorization guard depend on a list somebody has to maintain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntouched `is_active` is described in `apps/tracker/db/queries/torrents.sql` as an operator switch, and the Go tracker refuses to announce an inactive release. On the API side RSS, Torznab, federation, groups and the statistics all honour it. It was absent from the three surfaces that matter for a takedown: the web catalogue, the detail page, and the `.torrent` download. So an operator flipping the flag by hand for a DMCA request watched the release vanish everywhere EXCEPT where people find it and where they fetch it. Nothing in the codebase writes `false` today, which is what kept it inert — a trap waiting for the first time anyone used it. Staff and the uploader still see the row: they already see pending uploads, and hiding a release from the person who has to deal with it helps nobody. Measured: 404 on all three for a member, 200 for staff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The session cookie is sealed and STATELESS: seven days, `isAdmin` and the passkey baked in at login, no server-side record. Nothing could invalidate it. `auth/password.put.ts` documents that changing the password leaves sessions open, and signing out only clears the cookie in the browser doing it. A stolen cookie was therefore seven days of ordinary access, and the only recourse was to ban the account or rotate `NUXT_SESSION_SECRET` — which signs out EVERY member for one member's problem. `users.session_epoch` is that record, in one integer. The session carries the epoch it was issued with, and `requireUserSession` compares the two on the live read it was already doing for roles — so revocation costs no extra query, cache included. An integer rather than a timestamp: two revocations in the same millisecond are still two revocations. A cookie with no epoch counts as `0`, so sessions already open when this deploys survive until the first revocation. Installing the means to sign someone out must not sign everyone out. The caller's own session goes too. That is deliberate: without a device register, "sign out everywhere" cannot know which device to spare — and it is the right answer anyway when you do not know which one is compromised. `requireFreshAuth` guards it, like the passkey reset: an action that repairs a stolen session must not be reachable BY the stolen session without its holder proving who they are again. Measured end to end: two sessions open, revoke from one, both answer 401 with `reason: session-revoked`, and a fresh login works immediately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nalled nobody Two faults in the same shutdown-and-reconnect story. `Subscribe` and `Unsubscribe` were called OUTSIDE the lock that decides them, so the two could invert: the last reader of a channel leaves (`Remove` drops the key under the lock), a new reader arrives (`Add` sees `!existed`, puts the key back and marks the channel fresh), then `Subscribe` reaches Redis BEFORE `Unsubscribe`. go-redis 9.22 keeps no reference count — `Unsubscribe` is a plain delete — so the channel ends up present in the map and unsubscribed in Redis. The failure is STICKY: the key exists, so no later `Add` will ever mark it fresh again. On `messaging:room:general`, shared by everyone, one unlucky reconnect kills the room's live delivery for the whole node, silently, until every reader leaves. `subMu` is taken before `mu` on both paths and released after the Redis call, so a decision and its command are indivisible with respect to each other. `mu` itself is free during the round trip, so dispatch never waits. The lock order is constant, and dispatch takes only `mu`. `pubsub` is now an interface for one reason: an invariant nobody can observe is an invariant nobody can defend. The regression test injects a recorder and asserts that the last command for a channel matches the map. It took three tries to write — the first two passed against the broken code, because an instant fake closes the race window and because asserting only on the final state cannot see "present and unsubscribed" once everything has been removed. It now keeps one reader alive, and fails on the unfixed code with exactly that message. The drain was the second: `http.Server.Shutdown` does NOT cancel `r.Context()`, and the SSE loops wait on that, on `conn.Done()`, or on a heartbeat — nothing told them to leave. The ten seconds elapsed with nobody moving, `Shutdown` returned `DeadlineExceeded` into a `_ =`, and `main` returned and cut twenty thousand streams at once: the exact reconnect storm the comment says it wants to avoid. `Hub.Drain` closes each connection so every stream ends normally. `golang.org/x/sys` goes 0.30.0 to 0.44.0 while here. The advisory is a Windows path govulncheck confirms is unreachable from a Linux container, but the module had drifted far behind the tracker's because nothing ever ran `go mod tidy` against it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A publicly reachable HTTP service that verifies HMAC-signed bearer tokens and fans out SSE to every connected member, with nothing enforcing that it still compiled, that its tests passed, or that it was free of known vulnerabilities. `go build` inside its Dockerfile is not a gate — it runs after a release is already published. The gap had a visible cost: `go.mod` sat on `golang.org/x/sys v0.30.0` long after the tracker had moved to v0.47.0, because no job ever ran `go mod tidy` or a vulnerability scan against it. Hence the `go mod tidy is up to date` step, which fails on any drift. A copy of `tracker-ci.yml` rather than a shared reusable workflow, on purpose: the two modules will pin different Go minors over time and have different test shapes, and one file per module is what makes a red job point straight at its module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`storeBounded` incremented on every `Store`, including for a key already there, and `invalidateCounts` — called by `Set` and `Remove`, so on every announce — deleted entries without decrementing. The counter drifted towards 50 000 by counting THROUGHPUT, then triggered a full wipe of both caches. On a tracker at a thousand announces a second that wipe came round every few minutes, and each one costs a burst of `HGETALL` across every live swarm. The ceiling was turning steady load into periodic spikes. `LoadOrStore` semantics tell it whether the key was new, which is the only thing that justifies incrementing, and `deleteCounted` gives the slot back — the other half of the drift. The tests fail on the old code with "une clé réécrite 150000 fois donne un compteur de 50000, attendu 1", and still assert the ceiling fires on genuine growth so the fix cannot become a leak. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…de removed `TakeCreditBudget` drew the tokens, and only afterwards did `CheckAndMark` decide whether the delta would be booked at all. A dual-stack client — the very case dedup exists for — burned its budget twice for one credit, and the tokens are never returned. The error leaned the safe way (under-credit, never over) and the one-minute reserve absorbs it for an honest seeder, but the order was backwards. The dedup decision now comes first, and the clamp keeps its place ahead of the bonus multipliers. The HnR writers took a passkey and re-resolved it through `FindUserAndTorrentByPasskeyAndHash`, a `users × torrents` join, while the caller already held both ids. That is the fault `IncrementUserStats` documents having fixed by addressing rows by id: a passkey rotated between the cached resolution and the write touches NO row, and the credit vanishes without a word. One avoidable query per completed announce and per seed-time credit, gone with it. Dead code staticcheck had been reporting: `checkLocal`, `checkRedis`, `serverError`, `portStr`, `errInvalidConnectionID`, and three unused test helpers. `cache_test.go` keeps its determinism check but binds the two calls to variables. The assertion was NOT tautological — calling a function twice and comparing does test determinism, including if `passkeyKey` ever started salting — but staticcheck assumes purity and flagged it. Naming the results says the intent to both the reader and the analyser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e log HTTP scrape requires a passkey, and says why: without one, a list of public info hashes reconstructs the catalogue. UDP scrape cannot require one. BEP 15 defines a scrape request as a connection id, an action, a transaction id and a run of info hashes — there is no field for authentication data, and the BEP 41 options extension that carries the passkey on announce does not apply here. Demanding a passkey would break UDP scrape for every conforming client rather than protect anything. So the asymmetry is in the protocols, not in this tracker, and what was missing was the choice. `TRACKER_UDP_SCRAPE_ENABLED` closes scrape while leaving announce working. Default true, so an existing deployment behaves the same after an upgrade. Silence rather than an error when closed: answering an unverified source is the reflector `handleConnect` already refuses to be, and BEP 15 expects a client with no answer to redo its `connect`. What it costs to leave open is real but bounded, and the guide now says so: a caller learns swarm SIZES for hashes they already know, never peer addresses, and only after a `connect` round trip from their own address. The rejected-announce log line is sampled and no longer carries an address. It wrote one line per datagram, while the three counters above it write one in ten thousand precisely because "a log line per datagram is a disk-exhaustion primitive" — this path had forgotten it, and an invalid passkey or a low ratio repeats on every announce the client makes. The HTTP path writes nothing on failure and hashes the address when it logs; the reason alone is what an operator correlates on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The table said "Returns `200 { ok: true }`". It returns the status, the uptime,
and per-component latency for Postgres and Redis — and it is public and
unauthenticated, which three guide pages tell operators to curl.
Left public deliberately: it is documented that way and orchestrators are
pointed at it. But a page that understates what an endpoint reveals is how an
operator ends up exposing something they never agreed to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ributes `ALLOWED_URI_REGEXP` accepted `//evil.tld/login`: the relative-path branch `[#/]` saw the first slash. The link survived whole, and since the hook only adds `rel="noopener noreferrer"` and `target="_blank"` on `^https?://`, it got neither. A member writing `[voir la fiche](//evil.tld/login)` in any description produced a link that reads as internal, opens in the SAME tab, sends the full `Referer` and leaves `window.opener` reachable. `utils/safePath.ts` documents exactly this danger for notification links; it had never been applied here. The same expression was eating attributes that are not URLs. DOMPurify applies `ALLOWED_URI_REGEXP` to EVERY attribute outside its `URI_SAFE_ATTRIBUTES` list, so `width="320"` — which starts with neither `http`, nor `mailto`, nor `#`, nor `/` — was dropped. Measured before the fix: <img src="…" width="320" height="180"> -> <img src="…"> <td colspan="2" align="center"> -> <td> <p dir="rtl" lang="fr"> -> <p> Which made `[img=320x180]` and `[img width=75]` — documented in `editorFormats.ts`, used by cast thumbnails — size nothing at all, and dropped `lang` and `dir` from a bilingual site. None of those attributes can carry a script; declaring them URI-safe only lifts a check that had no business running. The rich profile now DERIVES from the strict one instead of being a twin literal. Two near-identical literals is what let them drift: the relative-path branch was added to both, but every later fix had to be made twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`event.path.includes('..')` is blind to percent-encoding, while the WHATWG URL
parser folds `%2e%2e`, `%2E%2E` and `.%2e` into real double-dot segments.
Measured: `/api/%2e%2e/uploads/x` passed the guard and reached
`http://api:4000/uploads/x`, with the session cookie attached.
The reach was bounded — the API mounts only `/api` and `/uploads`, and
`/uploads` is directly reachable anyway — but a guard written on purpose that
does not do what it says is worse than no guard: it gives the certainty of
being covered.
It now normalises with the same parser that will do the folding downstream, and
requires the result to still be under `/api/`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…across hydration `useNotifications` guarded its lifecycle with a MODULE flag, but the `watch(loggedIn, …)` it guards was registered in the setup of the first consumer — `NotificationBell`, mounted by the default layout. The login page is `layout: false`, so signing out tore the layout down and killed the watch while the flag stayed true. Signing back in is a client navigation with no reload, so `start()` was never called again: no SSE, no catch-up, a bell silent until F5. It now lives in a detached `effectScope`, so the flag and the watch have the same lifetime. Verified through a full sign-out/sign-in cycle with no reload: both the fetch and the stream come back. Three hydration mismatches with the same shape as the clocks fixed earlier: `Math.random()` evaluated at setup in the moderation panel (now `useId()`, which `Modal.vue` already documents as the fix), a `typeof window` guard inside a `computed` on `/mod` — a computed is lazy, so its first client evaluation happens DURING hydration where `window` exists, guaranteeing the mismatch it was meant to avoid — and `new Date().getFullYear()` in the footer, on every page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uld open `ReportModal` carried `role="dialog"` and `aria-modal="true"` but handled only Escape: no Tab handling, no scroll lock, no focus restoration. `aria-modal` tells the browser nothing — tabbing continues into the page behind the scrim, which `Modal.vue` explains at length and implements correctly. A keyboard user reporting a torrent walked out of the dialog on the last Tab and into a page they could no longer see. It is reachable from every torrent page. The mechanism is extracted into `useModalChrome` and used by BOTH, with a shared scroll-lock counter — one implementation instead of a second copy that would drift the way the two sanitiser profiles did. Group rows opened on a bare `@click` with no `tabindex`, `role` or key handling. The chevron is now a real button carrying `aria-expanded` and a localised label. Deliberately not `role="button"` on the row itself: it already contains buttons and a link, which such a role would remove from the accessibility tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dominant shape was `<label class="…">Text</label>` followed by an `<input>`, with neither `for` nor nesting — a styled paragraph, not a label. 130 controls across 49 files; the worst two sat on the sign-in path, the TOTP code and the recovery code. `SettingsGroup` was the real lever: its `<label>` named nothing across 35 settings. It now takes a `control-id`, and renders a `<label>` only when it has something to point at — otherwise a `<span>`, rather than a `for` dangling at nothing. That one component paired 24 controls across twelve admin files. `SearchBar` gets a short `aria-label` rather than leaning on a sixty-character placeholder. The freeleech day grid becomes a `role="group"` with `aria-labelledby`: a `<label>` over a set of buttons names nothing. `useFieldIds` exists because the cost of one `useId()` per field is what stopped the `for` attributes being added in the first place. 81 controls across 37 files are still unnamed — `ThemeEditor` (10), `BonusEvents` (5), `TwoFactorSection` (4) lead the rest — and the helper is in place for them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Title, intro and the codes themselves were concatenated into raw HTML for a `window.open` document. Not exploitable today — the i18n catalogue ships with the build and the codes come from the API — but it was the application's only raw-HTML sink, on its most sensitive screen, with no barrier if translations ever became operator-editable. `createElement` and `textContent` cost five lines. The print-stylesheet test asserted the old shape; it now checks the new one AND forbids `document.write` coming back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bonusCountdown` rendered `'ended'`, `'10d 10h'` and `'2h 3m'` in English, while
its own header comment promised « 10j 10h ». `formatPoints` called
`toLocaleString('fr-FR')` outright, on the English page too. `utils/format.ts`
has provided `currentLocale()` all along.
The countdown takes `t` as an argument rather than calling `useI18n()`: it is a
module-level utility with no component instance to read from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A card in the security section, behind a destructive confirmation that says plainly what happens: every session on this account closes, INCLUDING this one, and you sign in again. That is the honest wording, because without a device register the server cannot spare the caller's own session — and it is the right behaviour anyway when you do not know which device is compromised. Handles the three answers the route gives: `session-revoked` (carry on, that is success arriving as a 401), a 429, and the fresh-auth refusal, phrased like the other step-up actions. The login page shows a notice on `?revoked=all` so the sign-out that follows reads as the intended consequence rather than a failure. It stays silent without the flag — an unexplained banner is its own small alarm. Not done: a global 401 interceptor. There is no interception point in this front end — no `$fetch` interceptor, 401s are handled case by case — and creating one is an application-wide change, not part of this. The reason is set on the redirect instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…seconds `CheckAndMarkFor` sets the marker BEFORE the side effect it protects — that is what makes it atomic across instances — and nothing removed it when that side effect did not happen. `recordSeedTime` can give up silently three ways: the eight-slot semaphore saturated for 5 s, a Postgres error (hiccup, `statement_timeout`, exhausted pool), or a recovered panic. The marker's window is `minAnnounceInterval`, 900 seconds. So a three-second Postgres hiccup during a burst did not merely lose one interval for every seeder involved — it forbade the retry for a quarter of an hour. Hit-and-run stopped measuring, and nothing said so. The clamp on `elapsed` makes the loss permanent either way: a later announce can only ever claim one interval, never two to make up for a missed one. `dedup.Release` gives the slot back on all three paths, so the cost of a failure drops from fifteen minutes to one interval. Both layers are released: clearing only Redis would leave the local map refusing until the window ends on the very instance the client re-announces to. The release defer is registered BEFORE the recover defer so it runs after it — otherwise the one path that most needs compensating is the one that never gets it. It uses `context.Background()`, not `appCtx`, because a compensation must still land while the process is draining. Nothing is lost when Redis itself is the problem: `checkRedisFor` already fails OPEN, returning true without setting a marker, so there is nothing to release. `seedTimeDropped` counts the failures, sampled into the log like the UDP counters. It exists because the fault it measures was undetectable: a failed write logged one warning, and the only visible symptom was members never crossing their required hours — noticed a month later and blamed on something else. This is the cheap half of the repair. The database still does not hold the truth; a `last_seed_credit_at` column would make the path self-healing and drop a Redis round trip per seeder announce. That is a schema change, measured separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ven indexes A Postgres update stays HOT — Heap-Only Tuple, no index maintenance — when the new row version fits in the SAME page and no indexed column changes. With the default fillfactor of 100 a full page has no room for another version, so NONE of them could be. `AddSeedTime` touches every row once per announce interval: every seeder credits its time, each on its own row. That is the worst possible shape for a 100-fillfactor table. Measured on an exact replica — same columns, same seven indexes, 200 000 rows, ~40 rows per page, the real `AddSeedTime` statement, four cycles with a VACUUM between each, Postgres 18.6: fillfactor HOT table indexes total 100 0.1 % 29 MB 36 MB 65 MB <- before 85 60.7 % 32 MB 32 MB 64 MB 70 91.2 % 36 MB 31 MB 67 MB <- chosen 50 100.0 % 39 MB 19 MB 58 MB The indexes column is what decides it: index bloat from non-HOT updates costs more than loose packing does. At 100, nearly every credit wrote a row version AND seven index entries — around 13 000 writes a second at the scale this is sized for, where 1 700 would do. 70 rather than 50: 91 % against 100 % for three more megabytes per 200 000 rows, and headroom if the row ever widens — a `last_seed_credit_at` column, say. The 100 % at fillfactor 50 is a bench optimum, not a target worth defending. Hand-written because drizzle does not model table storage parameters, so `generate` cannot produce this file. Journalled like the rest so `check-schema-parity` can see it. It rewrites nothing: `SET (fillfactor)` is catalog-only and applies to pages filled AFTER it. Existing full pages stay full until autovacuum reclaims dead tuples in them, so the table converges on its own over a few announce cycles. To realise it at once on a loaded instance you need a rewrite — `VACUUM FULL` (exclusive lock, maintenance window) or `pg_repack` — and neither belongs in a migration that runs at API boot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The benchmark behind 0071 applied its 100 000 updates inside a single `DO` block — one transaction. Every superseded row version stayed live until it committed, so no page could prune and recycle its free space, and the table appeared to collapse to 0.1% HOT at the default fillfactor. The announce path does the opposite: one short transaction per announce, pruning continuously. The whole curve in 0071 measured the artefact. Remeasured at target scale — 300 000 rows, each updated once per announce cycle in random order, ONE TRANSACTION PER ANNOUNCE, `VACUUM` every 10 000 writes (autovacuum's cadence at that scale), steady state, second cycle: fillfactor 100 92.3% HOT 78.1 MB WAL table 31 776 768 B fillfactor 95 99.9% HOT 68.7 MB WAL table 31 948 800 B fillfactor 90 100% HOT table 32 MB fillfactor 85 100% HOT table 34 MB fillfactor 70 100% HOT table 41 MB <- 0071 95 reaches full HOT for +0.5% of table. 70 reached the same HOT for +35% — space spent for nothing, and more pages to scan for the same live rows. 0032 had already got this right: it measured hnr_tracking at 99.5% HOT on a REAL instance, declined to touch it, and wrote why — "tuning a number that is already right is how a schema accumulates cargo". 0071 overrode that on a flawed measurement. This restores it, tightening only the 0.5% the corrected measurement justifies. Like 0032 and 0071, this rewrites nothing: the new packing applies only to pages written from now on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mes per interval `IncrementUserStats` was the first line of the per-announce cost table in doc/guide/scaling.md: one `UPDATE users` per credited announce, 0.118 ms, on the request path. It was also the most redundant statement in the system. A member seeding 70 torrents announces 70 times per interval and every one of those announces updates THE SAME ROW — while the increments are commutative, so nothing required applying them one at a time. They are now accumulated in Redis (`HINCRBY`, pipelined) and applied by a background flusher. Measured over five minutes of traffic at target scale — 1 964 credited announces/s, 50 000 members, the real `users` DDL and its seven indexes, one short transaction per announce on the control side, `VACUUM` at autovacuum's cadence, steady state: disabled (per announce) 113-125 MB WAL 589 200 row writes 1.00 stmt/announce 30s 64.4 MB 345 994 0.059 60s (default) 45.8 MB 226 315 0.038 300s 7.3-7.6 MB 50 000 0.0085 Table and index sizes are identical in every row; HOT stays at 100%. At the default that is 2.7x less WAL — roughly 22 GB/day at this rate — and 26x fewer statements. FLUSH IN SMALL CHUNKS, OR IT BACKFIRES. The obvious implementation — one `UPDATE … FROM unnest(…)` per window — is WORSE than what it replaces. A single transaction updating 45 317 rows keeps every superseded row version live until it commits, so no page can prune and recycle its free space; every row migrates and rewrites all seven indexes: one block of 45 317 rows 50 MB WAL 19% HOT table 23 MB chunks of 200 28 MB 85% table 15 MB chunks of 10 (default) 20 MB 99.8% table 14 MB chunks of 5 19 MB 100% table 13 MB Below five, commit overhead takes over again. Ten locks ten rows for microseconds, so it also cannot delay the shop's single-row `SELECT … FOR UPDATE`. WHAT IT COSTS. The counters lag by up to one window. The only gate in the codebase that reads them is the ratio check in `ProcessAnnounce`, and that already reads a value up to 60 s old from the passkey cache — for a check that repeats only once per announce interval per torrent, i.e. every 1 800 s. Everything else that reads them (the profile, class-promotion rules, public stats) is display, not enforcement. Every write to these columns in the repository is a relative increment, never an absolute `SET`, so batching is algebraically safe. WHAT IT DOES NOT COST. The accumulator lives in Redis rather than in memory, so it survives a tracker restart, and production runs `appendonly yes`. A chunk is removed atomically (a Lua script — which is what makes two concurrent flushers unable to double-credit) and then written; if the write fails the deltas are put back and the next flush retries them. The old path had no retry at all: any Postgres error logged a warning and dropped the credit on the floor. `Stop()` flushes once more on SIGTERM, and whatever it does not finish stays in Redis for the next instance, or the next start, to pick up. Seven mutations of the code are each caught by a named test. One defect surfaced ONLY on the compiled stack: `TRACKER_STATS_FLUSH_INTERVAL=0` kept accumulating, because the guard tested `rdb == nil` rather than whether batching was enabled — the escape hatch did not escape. The unit tests could not see it; they cut Redis, not the window. `stop_grace_period: 45s` on the compose tracker: shutdown now flushes (up to 10 s) before draining in-flight HnR writes (up to 8 s), and Docker's 10 s default was already cutting the second one short before this change. The Helm chart allows 30 s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…for a redirect
The SSRF guard was doing its job — the hostname is re-resolved and
re-validated on every hop, so no redirect could reach a private,
loopback or metadata address. What it did not do was stop at the origin
boundary: the caller's `init` was spread into every `fetch()` of the
loop, headers included.
So a perfectly public target answering `302 Location: https://attacker`
was handed whatever the original call carried:
- channels/webhook.ts up to SIXTEEN member-chosen headers, plus an
X-Trackarr-Signature HMAC of the body
- channels/ntfy.ts the server's Authorization
- storage/s3Driver.ts a SigV4 signature and its x-amz-* headers
- federation/signing.ts x-trackarr-signature, which authenticates
the sending instance
No private range crossed, nothing in the log. And `webhook.url` is a
`userField` that any member can persist.
An ALLOWLIST, not the standard denylist. Browsers and undici strip
`authorization`, `cookie`, `proxy-authorization` and `host` on a
cross-origin redirect, which is right for a browser — there, the only
credential-bearing headers are the ones the browser itself sets. Here
the callers set their own, and that denylist would have covered two of
the four cases above. So this does not enumerate what is dangerous; it
enumerates what cannot authenticate anything (`accept`,
`accept-language`, `user-agent`) and drops everything else at the first
hop that changes scheme, host or port.
The strip is destructive rather than masked, which is what makes an
A → B → A chain safe by construction: on the way back there is nothing
left to restore. B chose that return, and could as well have chosen a
homograph of A. (A `sameOriginThroughout` flag was written first and
removed: no single-line mutation could make it fail, because the
destructive strip already covered it. Untestable state is a liability.)
A 307/308 to another origin on a call that still has a body is refused
outright. Those two statuses preserve method and body, so removing the
headers would not stop the payload from being delivered elsewhere — and
degrading the method to GET would return a 200 for a write that never
happened. It fails visibly instead.
Accepted cost: a target that legitimately redirects across origins (an
S3 region redirect, say) now answers 403 instead of succeeding. That is
the right direction for the error — a visible failure rather than a
token delivered to the host that asked for it. Documented under
Troubleshooting in doc/guide/notifications.md.
Six tests, and each one was checked against a mutation of the code that
should break it: headers re-injected per hop, denylist instead of
allowlist, restoring on the way back to A, comparing host instead of
origin, replaying the body, and stripping even same-origin. The
same-origin test is the positive control — without it, "no secret at
hop 2" would not distinguish "removed" from "never sent".
Then proved against real undici rather than a stub: two servers on two
origins, A redirecting to B, B reporting what it received —
`accept, accept-encoding, accept-language, connection, host,
sec-fetch-mode, user-agent` and nothing else. The 307-with-body case
never reached B at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n flight `Close()` was `return s.conn.Close()`. That makes the read loop exit on its next deadline tick, which is all the comment in main.go claimed it needed to do — "UDP has no in-flight connections to drain". True of the protocol. False of this implementation: every datagram is handed to its own goroutine, up to `cap(workerSem)` of them, and each one may be mid-write to Postgres when the process exits. So an announce arriving during a redeploy lost its credit, silently, every time. Bounded — the same order as what the tracker already forfeits when a peer's Redis baseline has expired — but free to avoid, and the HTTP side has drained its in-flight requests since forever. `Close` now closes the socket first (that is what stops the loop feeding the counter it then waits on) and waits on a WaitGroup, capped at `udpDrainTimeout`. The `Add(1)` is before the `go`, not inside it: inside leaves a window where `Close` believes there is nothing to wait for. Five seconds, because the application context is NOT yet cancelled when main calls `Close` — in-flight writes run to completion normally and carry their own pgx deadlines. The cap only exists so a stuck database cannot hang a shutdown. The shutdown budget, which has to fit inside the container's grace period: 10 s HTTP drain, then 5 s here, then 10 s for the final byte-credit flush, then 8 s for background tasks — 33 s worst case. The Helm chart allowed 30 s, which would have cut the last one; raised to 45 to match what compose now grants. Three tests, each checked against a mutation that should break it: `Serve` not registering the goroutine, `Close` not waiting, and `Close` always waiting out its cap. The last two matter together — without the "returns promptly when idle" control, a `Close` that always slept five seconds would look correct. Testing the drain needs a handler that BLOCKS, and `handlePacket` needs Postgres and Redis. Hence the `handle` field: a seam, documented as one, without which the drain would be untested code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The device that clicks "sign out everywhere" is handled: the settings page sends it to `/auth/login?revoked=all` and it reads a notice there. Every OTHER device had nothing. The member stayed on whatever page they were on and the next call rendered the bare failure — a "Server Error" or an empty list depending on where — while the cause was known, named by the API (`data.reason = 'session-revoked'`), and perfectly explainable. There is no centralised API layer in this app; pages call `useFetch` and `$fetch` directly. The single point is the global `$fetch` instance, replaced here with a copy carrying an `onResponseError` interceptor. `useFetch` builds on that same instance, so both routes are covered. Only on `data.reason === 'session-revoked'`, never on a bare 401. A plain 401 is the normal case for a signed-out visitor, and redirecting from any public page would be a regression rather than a fix. Verified with both controls: the revoked call redirects to `/auth/login?revoked=remote`, and an unauthenticated 401 leaves the page where it is. Client-only, deliberately. During SSR a 401 must stay an error the page handles — redirecting from a server plugin would replace a partial render with a navigation, for every request in that process. One redirect, guarded by a flag: a page easily carries five parallel calls, all failing at the same instant with the same reason, and each would otherwise start its own navigation. Two provenances, two messages. Telling someone who did nothing on this device that "every device has been signed out, this one included" describes the cause from someone else's point of view. Note for whoever tests this by hand: the revoked 401 fires EXACTLY ONCE — `requireUserSession` clears the cookie as it throws — so a fresh page load with a stale cookie has its one chance consumed by SSR, and every call after that is a bare 401. The real scenario, and the one to reproduce, is a live hydrated page whose session is revoked underneath it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…othing else
Without an accessible name a screen reader says "text field" or "combo
box" and stops: the control exists, but nothing says what it wants.
WCAG 4.1.2. The dominant pattern in this repo was
`<label class="…">Text</label>` followed by an `<input>`, with no `for`
and no nesting — which makes the `<label>` a styled paragraph.
MEASURING THIS TOOK FOUR PASSES, and the first three were wrong in the
same direction. A crude grep said ~300. Writing a real detector said 51.
Three classes of false positive brought that to 24:
1. `SettingsGroup` renders its own `<label :for="controlId">`. The 17
settings fields that use it are labelled — in ANOTHER FILE.
2. An HTML comment that talks about a `<select>` is not a select.
3. An `<input type="file" class="hidden">` driven by a visible button
is `display:none`: neither keyboard nor screen reader reaches it,
so it has no name to carry.
All 24 now carry one. Where a visible `<label>` already existed it is
ASSOCIATED rather than duplicated into an `aria-label` — that also fixes
click-to-focus, which had never worked on those fields.
`FicheCombo` and `FicheAmount` gained a REQUIRED `fieldLabel` prop.
Required is the point: optional, a caller who forgets it leaves an
anonymous control and nothing says so — not the compiler, and not a
static detector, which sees the attribute bound on the control but not
whether a value arrives. Required, the typecheck enumerates the misses
itself, and it found all 12.
`fieldLabel` and not `ariaLabel`, because `aria-label` IS a native ARIA
attribute: `:aria-label="…"` on a component satisfies the element's HTML
signature and never feeds the camel-cased prop of the same name. The
typecheck stayed red reporting a missing prop while the attribute was
plainly there.
And a caution against blanket renames: replacing `:aria-label=` with
`:field-label=` across fiche.vue also hit two `<button>` elements whose
`aria-label` was legitimate, and the typecheck could not see it — an
unknown attribute on a native tag is allowed. Caught by checking which
tag each renamed attribute belonged to; both restored.
`formControlNames.test.ts` is the ratchet, without which this regresses
next week. It asserts zero for the real failure and caps the "named only
by placeholder or title" tier at its current 56 so the number can only
fall — a placeholder IS a name per the spec, but it vanishes at the first
keystroke, which leaves someone returning to a half-filled field with
nothing. Verified by removing one `aria-label`: the test names the file
and line and says what to add.
Co-Authored-By: Claude Opus 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.
Ninety-six commits from an audit pass over the four apps, plus the features
that came out of it.
mainis a strict ancestor, so this is a cleanfast-forward with no conflicts.
What is in it
fix, 19feat, the rest docs/test/perf/ciThe 96 commits are kept rather than squashed: each one documents a specific
finding, the measurement behind it, and the reasoning. That history is the
useful part.
The audit, and what it actually caught
Static analysis found almost nothing. semgrep (two passes), trivy, gitleaks,
gosec, hadolint, actionlint, zizmor, govulncheck and osv-scanner came back
clean or already-arbitrated. Every finding that mattered came from probing
the compiled stack: 131 GET and 90 write routes called without a session,
IDOR probes with a positive and a negative control, UDP/BEP 15 amplification.
The one real leak came from there:
/api/freeleech-pool/stateserved anANONYMOUS caller the top five contributors with their usernames and INTERNAL
ids. The anonymous path existed for
userContribution;topContributorshadcome along for the ride.
Highlights
Security
safeFetchhanded the caller's credentials to whoever asked for a redirect.The SSRF guard re-validated every hop, so no private range could be reached
— but the caller's
initwas spread into everyfetch, headers included. Apublic target answering
302 Location: https://elsewherereceived thesixteen member-chosen webhook headers, the body HMAC, the ntfy
Authorizationor the SigV4 signature. Fixed with an allowlist, not thestandard denylist, because the callers here set their own credential headers.
session_epoch), with the "sign outeverywhere" control in user settings.
uriinstead ofrequest>uri— sopasskeys had been written in cleartext since it was added, without a warning.
Performance
updating their own row 70 times per interval. Measured over five minutes at
1 964 credited announces/s: WAL 113–125 MB → 45.8 MB at the 60 s default,
7.5 MB at 300 s; statements 1.00 per announce → 0.038. Flushed in SMALL
chunks, because a single-block flush defeats HOT pruning and writes more
WAL than the individual writes it replaces.
100 000 updates inside one
DOblock — one transaction — so nothing couldprune, and
fillfactorlooked guilty. The announce path does the opposite.Correctness / robustness
to Postgres;
Closenow drains them.db.executesilently drops the timestamp parser thatdb.selecthonours —"2 hours ago" for 12 minutes.
schema.parse(getQuery(event))let a ZodError escape as a500 instead of a readable 400.
Interface
named, with a ratcheted test so it does not come back. Counting them took
four passes: a crude grep said ~300, the truth was 24.
the login form with an explanation.
Verification
The CI had never run on this branch, so every gate was replayed locally first:
typecheck (api + web), 36 api unit files, 29 web unit files, 32 api integration
files (including
objectStorage, not skipped), tracker and relay under-race, schema parity, api and web SSR builds, and the docs build.Two things worth checking deliberately rather than trusting:
main's maximum, so none issilently skipped — the failure mode where a long branch loses its whole
migration chain without a word.
578 indexes.
Behavioural changes were verified at runtime, not from source: the Caddy scrub
with a canary, the forum cascade, session revocation across two sessions, the
credit-budget clamp (2 000 GiB claimed → 68 GiB credited), the UDP scrape
switch in both positions, and the byte-credit batching against a compiled
tracker with real announces.
After merging
hnr_tracking'sfillfactorchange (0072) deliberately rewrites nothing, soit applies only to pages written from then on. Run
pg_repackon that table(online) or
VACUUM FULLin a maintenance window to realise it now.Known and left open
placeholder. Technically a name per the spec,fragile in practice; capped by a test so the number can only fall.
2 low). Pre-existing, untouched here.
🤖 Generated with Claude Code