c47c193ad1bd06606cb2cef1127e5af7f880491a
2007
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c47c193ad1 | feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1) | ||
|
|
db10bfbbca | feat(design): the header search is a 40 glass pill; a wrapper field dims for its input only (E28 S3c) | ||
|
|
dac9d89f91 |
feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)
- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch - ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date filter on one trigger, a pill in a toolbar, r8 in a form - ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput - every raw input swept (~100 sites); inputCls retired; local/input-primitive - useReturnFocus: an in-place editor hands the focus back to its trigger on Enter/Escape (the playlist and Plex title renames, the priority number); the Plex playlist rename gains Escape and an accessible name - back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log |
||
|
|
81936e9f56 |
feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2)
The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.
- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
floor, 44 on touch) replace five chip geometries; FilterChipGroup
renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
round, overlay; 44 on touch); the YT modal bar is on the sm rung with
pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
add) announce checked/mixed; a one-letter tag chip was 21px wide;
descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
control-primitive files is a lint error (contract-tested, seen red).
|
||
|
|
86f467bd65 |
feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`, `media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held by theme.contract.test.ts. Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor, Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are 153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play). |
||
|
|
7d480e2ad0 |
feat(design): Matrix toned down to moss, text on an accent fill held to --accent-fg
The lime accent was the loudest of the four (OKLCH L 0.85, C 0.21) and read as neon; the owner chose the moss variant at Midnight's lightness with near-neutral greys, and the Matrix backdrops follow it. Fixed white/black parts on a solid accent fill (two switch knobs, three play buttons, the Up next pill) now use --accent-fg, and theme.contract.test.ts refuses a fixed white or black next to a solid bg-accent. |
||
|
|
ad136c3fe8 |
feat(design): the scheme set, the colour contract and the token utilities (E28 S3a)
Four schemes (Midnight; Forest cyan, Slate fuchsia, Matrix lime), YouTube and Starship retired with no data migration: normalizeTheme maps a stored retired scheme to Midnight. A vitest contract measures every scheme x mode (text 4.5:1, control edge 3:1, status text, the accent's 30-degree hue gap); it caught light-mode danger at 4.43:1, now red-700. The state layers, the strong edge, the touch target and the fades are index.css tokens and utilities the lab now reads. Backdrops recoloured to the new accents, with a grey overlay grain that actually breaks the banding (longest flat run 15-26px -> 4-8px). The glass tuner is retired. |
||
|
|
9c4a999e54 |
feat(design): C5 recorded, every E28 decision taken
The owner's lean Plex control set with both refinements (Back by position, a touch double-tap for the 10 s skip) goes into DESIGN.md's Players chapter and the log. The lab is now the reference only. |
||
|
|
3f5634a44f |
feat(design): C5 redrawn around the owner's reworked Plex controls
Stop, Restart and the 10 s skips leave the row, Back restarts or steps to the previous episode, Next shows only when there is a next item, and the card carries Resume + From the start. The lab asks back two points: how Back tells restart from previous, and a touch path for the 10 s skip. |
||
|
|
f4bc60d659 |
feat(design): batch C recorded, the player row open as C5 (E28 S2d)
C1-C4 as proposed: the action classification, overlay-drawer side panels, touch-first players, dialog focus by purpose with a pinned footer. The players were then played live at tablet size, which found the Plex control row running off-screen below 1024; how it fits is open as C5. CLAUDE.md now points at DESIGN.md. |
||
|
|
7055548260 |
feat(design): batch B recorded, batch C in the design lab (E28 S2c)
DESIGN.md: B1-B9 decided (compact density, the four-scheme set with the accent-vs-status rule, state layers, the sticky band, surface per role, the watch page, the slim NavRail with optional labels, loading by shape, the fade edges) with their decision-log rows. The lab keeps the chosen A and B renderings as reference on shared tokens (labTokens.ts) and adds batch C: the action-placement classification for review, side panels on touch and below 768, the Plex player on touch, and the dialog conventions. |
||
|
|
477271907e |
feat(design): batch A recorded, batch B in the design lab (E28 S2b)
DESIGN.md: the Controls chapter decided (A1-A6) with the decision-log rows; density stays open as B1. The lab keeps only the chosen A renderings as reference and adds batch B: density, the scheme set (measured accent hue gaps and light contrast), state layers, the page-chrome model, surface per role, the public watch page, the nav rail, the loading form and where the header + fade edge goes. |
||
|
|
50923bb684 |
fix(build): keep JSX line-end spaces in dev and vitest
plugin-react recognises the React Compiler only by its package name, and reactPlugin.ts passes the imported plugin, so plugin-react left Babel's retainLines on in dev. With the compiler's re-printed JSX that trimmed the space at the end of a text line (the privacy page read "for example/api/feed" on :5173). Production builds never had it on. Turn retainLines off through generatorOpts, which wins over the plugin's top-level option, and pin it with a DOM test seen red without the fix. |
||
|
|
d2e40dcffb |
feat(design): design lab + DESIGN.md skeleton (E28 S2a)
- docs/DESIGN.md: the written source of truth for the UI. Decided chapters (principles, device adaptation, action placement, text stays selectable, interaction rules, copy, anti-patterns, decision log); controls and surfaces are marked Pending until the owner picks from the lab. - /design-lab (dev only): renders each open decision's variants with the app's real CSS, themes and font scale. Batch A = six control decisions. - devOnlyGuard: a production build fails if anything under src/design-lab reaches the bundle (seen red with an unguarded import). - Size contract: tablet portrait + landscape and up, replacing the 1024x768 floor with its never-built "too small" message. |
||
|
|
58878958d8 |
merge: chore/e2e-password-from-store (E2E password from the secret store, no committed default)
gate / frontend (push) Successful in 4m37s
gate / backend (push) Successful in 6m29s
|
||
|
|
b47d22cf3b |
chore(e2e): no committed default for the E2E account password
The localdev e2e account's password was a hard-coded default in this public repo, so it was never a credential. E2E_PASSWORD is now required; the ops wrapper exports it from the secret store, and the seed/setup fail with a clear message when it is missing. |
||
|
|
e804ff3b8e |
release: 0.95.0 (improvement/p1-upload-auth-first)
gate / frontend (push) Successful in 4m35s
gate / backend (push) Successful in 6m18s
|
||
|
|
cd6f119883 |
chore(release): 0.95.0
The P1 epic: the upload route reads its own body after `require_human` resolves, so an anonymous caller can no longer make the server spool 500 MiB before the auth check runs. Three deep-review fix rounds on top of it. |
||
|
|
321776480e |
fix(uploads): make the drift guards fail for the reasons they claim
Deep-review round 3, targeted at the surfaces round 2's fix commit changed. Five of the eight findings are the same shape: the guards written to stop half-done sweeps were themselves half-done. test_upload.py's display-field guard walked only `ast.Assign`, so the one writer with an unbounded REQUEST HEADER behind it — `spool_upload`'s `display_name=display_text(...)` keyword — was invisible to the guard meant to protect it. It also stopped at `app/routes/`, missing the four writers in `downloads/` that actually construct the row, and asserted `not offenders`, which enforces nothing once a refactor moves the writers out of its view. It now walks assignments AND keywords across `app/`, and holds exact set equality against a registry of all twelve write sites with each one's value. test_body_limit.py's release guard used `any(... ast.walk(fn))`, which accepts a `release_read_connection` that runs AFTER the await it exists to precede — satisfying the letter of the rule while pinning a connection for exactly the transfer it was supposed to free. Position is checked now, and a late release is its own, louder failure. The registry keyed sites by file basename while scanning recursively, and `admin.py`, `discover.py` and `playlists.py` each exist both at the top level and under `plex/` — the same entry-collapse the registry was widened to close. Keyed by relative path now. And all three guards recognised a body read only by the `capped_` naming convention, so a route spelling `await request.body()` inline declared no ceiling, released no connection, and was invisible to every one of them. `release_read_connection`'s precondition claimed the caller "must have NOTHING PENDING, and that is checked rather than described" while checking only UNFLUSHED ORM work. Measured: after `add`+`flush` all three collections are empty and the rollback loses the row, and Core DML never enters them at all — both of them this codebase's canonical write shapes. It now also asks the transaction directly via `pg_current_xact_id_if_assigned()`. `errors.tooLarge` was reworded to "try a smaller file", but that key answers every route's body cap: the issue composer's over-size JSON draft got advice about a file it does not have. Back to payload-neutral; the file-specific sentence keeps its own key. The upload dialog's new danger border outranked `dragOver`, so a user replacing an over-cap file got no sign the drop zone was live — and with a file picked the label shows its name rather than "drop it here", so the border was the only feedback left. Four further findings were refuted 3/3 and are not fixed: `aria-invalid` on the drop zone (measured in Chromium — the AX node does carry it on a native button), a bidi-override spoof through `display_text` (the served filename already strips those categories, and the proposed filter would have broken every ZWJ emoji), and `capped_body` not draining (the screenshot route's edge ceiling equals its own cap, so that refusal is unreachable, and the log sink is sent by beacon with nobody reading the answer). |
||
|
|
7c0647f87a |
fix(uploads): finish the sweeps round 1 left half-done
Deep-review round 2, on round 1's own fix commit. It found MORE than round 1 did (28 raw, against 13), and most of it was mine -- a new piece of UI attracts findings a backend-only diff never does. The pattern in almost all of them is the same: a principle applied to the site that prompted it and not to its siblings. * `display_text` reached three of the FIVE writers of a user-supplied name. `POST /api/downloads` and `POST /api/downloads/edit` still handed Postgres an unbounded string, so a 300-character name pasted into the Add-download dialog or the editor was a bare 500 (main.py re-raises every DataError but the NUL case). Four of the seven reviewers found it independently. The bound now lives in `models.py`, the three columns are DECLARED with it, and the guard for the next writer is parsed rather than remembered: every `display_name` / `display_uploader` assignment under `app/routes/` must go through the helper. That guard immediately found two more -- the Google sign-in callback writes the profile name into the same 255-wide column -- so it is six, not five. * The connection release reached one of the THREE routes that read their own body. The screenshot route (10 MiB, from a phone) and the client log sink both still held a pooled connection idle-in-transaction for the length of the upload, which is the invariant db.py sizes the pool on. All three now call `db.release_read_connection`, whose precondition -- nothing pending on the shared session -- is CHECKED rather than described, because a whole-session rollback discarding a caller's staged write is a mistake this repo has already made once. `test_body_limit.py` holds the rule for the next such route. * The AST drift registry keyed its sites by (module, limit expression), so a second route copied from the first collapsed onto the same entry and went uncovered -- the exact hole the registry exists to close. Keyed per call site now, and it carries the release requirement too. * The dialog's own refusal was announced once and never again: two over-cap picks leave an identical node with identical text, and an untouched live region says nothing. It is keyed per pick now, the drop zone reads as invalid and points at the sentence, and the reason is reachable from the control rather than only from a one-shot announcement. * Two tests rebuilt the blind spot they were written to remove: the dialog's cap test sat nowhere near the boundary (a `>` loosened to `>=` survived it, which is the mutation round 1 fixed on the server side), and the header-precedence test only covered an exact-case duplicate while a differently-cased one would have been COMBINED onto the wire rather than replaced. Both fixed, and the merge now dedupes case-insensitively because header names are. Also: the upload dialog's unstubbed usage query was firing a real request from jsdom in a neighbouring suite and leaking a delayed "connection lost" toast across test boundaries; `GET /api/downloads/usage` had no test at all though the whole client-side check hangs off it; and the comment claiming the edge 413 is readable now says what was measured -- readable to 200 MiB over loopback, a bare `onerror` through a proxy -- so the pre-send check is named as the mechanism and that branch as the backstop it is. Every test added or changed here was mutation-checked. One mutation SURVIVED and is recorded rather than papered over: the `tooBig` term inside `submit()` cannot be reached, because `ui/Button` swallows the activation itself, so it is marked as belt and braces instead of being described as the guard. |
||
|
|
efd7b217ca |
fix(uploads): make both caps answerable, and stop holding a connection
The epic-end deep review found three defects in P1 and four tests that did not hold what they claimed. They land together because they share the upload route and its test module; splitting by hunk would have produced commits that do not individually run green. * The edge ceiling and the route's own cap are the SAME number at the default configuration, so the edge always answers first: a declared Content-Length over the cap is refused before a byte is read, and an undeclared body trips the middleware on the same chunk the route would have tripped on. The route's numbered 422 -- and the drain written for it last week -- are reachable only once an admin LOWERS the knob, which is the one shape the browser UAT tested. Measured: headroom does not fix this, because the declared-length check fires first for any body big enough to matter and the drain pushes the count back over the edge for the rest. So the edge's own refusal is made readable instead: `errors.tooLarge` localizes `too_large` the way `upload_too_large` was already localized, and the dialog refuses an over-cap file BEFORE sending it, off the `upload_max_bytes` that GET /api/downloads/usage now reports. Measured in Chrome against the API directly: that 413 arrives as `onload` with a readable body in 43 ms. * Reading the body behind the auth dependency put the whole transfer inside the request's database session -- the auth SELECT autobegins a transaction, so one of the twenty pooled connections sat `idle in transaction`, with nothing to time it out, for as long as the client cared to keep sending. That is the invariant db.py sizes the pool on and plex/playback.py records an incident for. The read transaction now ends before the await. Measured on the dev server: an 1818 ms upload shows no idle-in-transaction sample while the bytes move, only afterwards, when the handler actually writes. * The display name moved into a request header, where nothing bounds its length, while the column stayed varchar(255) -- so a long enough name was a DataError 500 after the whole file had already transferred. One `display_text` helper now serves all three writers (the metadata PATCH already truncated, the upload ingest did not), and the dialog stops at the same number. * The gate invariant asked `route.dependant.body_params`, which holds only the route's OWN callable's parameters: a body declared in a SUB-DEPENDENCY -- the upload route's own idiom -- leaves it empty while FastAPI still reads the body before auth. It asks `route.body_field` now, which is what FastAPI itself consults. The AST registry of body-reading call sites had also never followed `capped_stream_to`, so the one route the whole invariant exists for was the single site the drift guard could not see. Four tests that passed over their own mutation are fixed: the cap boundary (a `>` loosened to `>=` survived the entire suite), the percent-decoding (every filename the suite sends is ASCII, where quote() is the identity function), the display-name header (no test at all), and the staging cleanup (deleting the unlink left the suite green while every refused upload leaked its partial bytes onto the volume). Every test added or changed here was mutation-checked. |
||
|
|
3d7ef9e945 |
fix(uploads): drain an over-cap body before refusing it
Found in the browser UAT, not by the gate. Moving the cap onto the arriving bytes meant it fires while the client is still sending, and answering + closing mid-upload is what XHR reports as a network failure: the upload dialog said "the server couldn't be reached" while the access log showed a correct 422 carrying the limit. A refusal the client cannot read is an outage as far as the user is concerned. `capped_stream_to` now reads the remainder to nowhere before raising, so the client finishes sending and reads the real answer -- re-measured in the browser, which now names the limit. Nothing past the cap is written, and the request is still bounded by bodylimit's ceiling, so the cost is bandwidth an AUTHENTICATED caller was sending anyway. The anonymous path is untouched: auth still resolves before a byte is read. The regression test sits on the helper, not the route, and the reason is worth keeping: the route-level version was written first and PASSED with the drain deleted. httpx's ASGI transport pumps the request body whether or not the app reads it, so "drained" and "abandoned" are indistinguishable through TestClient. Its own mutation check is what exposed it. |
||
|
|
7fce7a7ce5 |
chore(deps): drop python-multipart -- nothing parses a multipart body now
Its own entry in requirements.txt said the download center's local-file upload was "the only multipart endpoint in the app". Since that route reads a raw body, the claim is false and the package is dead weight: no route declares File/Form, and nothing calls `request.form()`. Removed from requirements.txt and from its single pinned line in requirements.lock. The lock is generated and normally regenerated wholesale, but a full regeneration would bump every transitive dependency, which has no business riding inside a security fix -- the next legitimate regeneration reproduces exactly this state, since the package is gone from the source. The `multipart` entry in logs.py's third-party level map goes with it: a level for a logger that can no longer exist. |
||
|
|
f5018bdcbc |
fix(uploads): read the body after authenticating, not before it
`POST /api/downloads/upload` declared `file: UploadFile = File(...)`, and FastAPI parses and spools a declared body before the first dependency runs -- `require_human` included. An unauthenticated caller could therefore make the process write the whole per-file allowance (500 MiB by default) to temp, once per request. SEC-20's ceiling bounded that number but could not stop the writing, which is why E27 parked it rather than closing it. The route now takes no body parameter. `spool_upload` names `require_human` first and only then streams `request.stream()` to the staging temp file, so a stranger is refused before a byte moves -- the shape the screenshot route has used since E16. Filename and display name travel as percent-encoded headers (latin-1 on the wire, so a Hungarian filename needs the encoding) instead of multipart parts. `capped_stream_to` joins `capped_body` in routes/_common.py: the same counting rule, written to a file descriptor rather than accumulated, because holding 500 MiB would trade a disk problem for a memory one. Its refusal is the caller's, so the upload route keeps answering 422 `upload_too_large` with the admin knob's own number. With no envelope to allow for, the edge is now exactly that knob, and MULTIPART_ENVELOPE_ALLOWANCE is gone. The invariant stops being prose: test_body_limit.py asks FastAPI itself (`route.dependant.body_params`) and fails the gate if any route with a raised ceiling declares a body. A comment could not have prevented the defect it described; that test would have. Both new tests were seen red for the right reason -- before the fix the anonymous probe got its 401 only after the server had read 4236 bytes of a multipart body, and re-declaring the File parameter turns the invariant test red naming the route and the parameter. |
||
|
|
b82c22b888 |
release: 0.94.0 (improvement/e27-contract-hardening)
gate / frontend (push) Successful in 4m32s
gate / backend (push) Successful in 6m8s
|
||
|
|
f839a461e6 | chore(release): 0.94.0 — framework & contract hardening | ||
|
|
8525666913 |
test: round 4 — an assertion that could not fail, and a comment that did not add up
Two findings, both in the review's own work; nothing in the product code. That is the shift that ends the loop: 22 → 13 → 6 → 2, and the last two are a test and a comment rather than a defect. The "an override that is fine is still honoured" leg of the bounds-fallback test stored `spec.max` — which, for this key, IS the env default, because the spec's max is derived from it. So the assertion was satisfied by the fallback it existed to rule out: mutating `_coerce` to reject EVERY int override left all three legs green. It stores `spec.max - 1` now and asserts the value differs from the default, so the leg can actually fail. And the shrink test's comment claimed `3 - 5 = -2` for an expression that computes `chips.length - visible` = `2 - 3 = -1` — naming neither operand nor the result, in a comment that was rewritten one round earlier precisely for miscounting. |
||
|
|
3f24248e01 |
fix: round 3 — a vacuous ordering test, a clamp that was a silent rewrite, a regex that missed
Round 3 over round 2's fixes. The trend is 22 → 13 → 6, and the character has
shifted: these are mostly defects in the REVIEW's own work, not in the epic.
The ordering test I added to hold the middleware's position could not fail for
either arrangement it names. All four `@app.middleware("http")` layers — the
access log, the two header decorators AND the setup gate that must be inside —
register as the same `BaseHTTPMiddleware`, so asserting on the class passes for
the pre-move stack and for a cap moved back outside CORS. It names the layers by
their dispatch function now, which is the only thing that distinguishes them.
The `sysconfig` bounds check CLAMPED, and clamping is a silent rewrite — the thing
this module refuses to do everywhere else, where an unusable override falls back
to the env default and logs once. On the max side the difference is cosmetic; on
the min side it is a live bug. `download_upload_max_bytes` had `min=0` before E7,
and a stored 0 means "no per-file cap" to the route that reads it. Clamped to 1 it
becomes a ONE-BYTE cap: uploads dead, nothing in the log, the admin page showing
"1". It raises `OutOfRange` now and takes the existing fallback, with its own
event code.
The `capped_body` sweep grepped for an identifier as the limit argument, so a site
written `capped_body(request, 4 * 1024 * 1024)` — or with the parameter not named
`request` — was MISSED, and a miss reads as "no new sites". Parsed with `ast`
instead: every call is seen whatever it looks like, and an unfamiliar argument
shape fails the equality instead of vanishing.
Frontend: the shrink test drove the mock in a way the real hook cannot produce
(it moved `visible` in the same step as the shrink, and the comment miscounted the
tags), so it reproduced the right END STATE by accident rather than the sequence.
`visible` is now left alone across the shrink, which IS the precondition.
Ops: the baseline guard answered "did this run rewrite it?" and therefore went
green on the second run over an already-tightened, uncommitted baseline — the
exact state its own comment says marks the localdev stack stale ever after. It
asks both questions now. And `.dockerignore` joins BAKED_PATHS: it decides what
the COPY lines actually take, so it is an input to the build context exactly as
the copied trees are.
|
||
|
|
6b86673af4 |
chore: untrack tsconfig.tsbuildinfo, and hold the CI baseline the way the local gate does
`frontend/tsconfig.tsbuildinfo` was swept into the previous commit by a `git add -A`. It is a machine-local incremental artifact carrying absolute Windows paths, no lane reads or regenerates it, and it was already stale in the commit that added it — it does not list the contract test that same commit created. Untracked, and `*.tsbuildinfo` ignored, because the day anything does start rewriting it a green gate would leave a tracked file dirty: the exact hazard the basedpyright baseline just got a guard for. And CI ran bare basedpyright while the local lane gained that guard, so a burn-down would have been green on the server and red on the developer's machine over the same tree. Two gates, one tree, one answer. |
||
|
|
d924c665f8 |
fix(frontend): round 2 — the popover latch reset read a stale, negative count
The reset I added guards on `hidden === 0`, and `hidden` can be NEGATIVE. `useChipOverflowFit` keeps `visible` in state and re-decides in a layout effect, so the render in which the chip set SHRINKS still carries the previous measurement — delete three tags under an open "+N" and `chips.length - visible` goes to -2, the equality never matches, and the flag survives the one window the reset exists to close. The next re-fit brings the menu back on its own, which is the original defect with an extra step. Clamped at all three lanes, with a test that drives the shrink through the stale value. Also: the Scheduler characterization banner said "the four tests below" and now sits above six, the last two of which are genuine regression guards for the range-refusal fix. A future mutation check would have read it, seen them go red, and concluded the harness was broken rather than that the guard worked. |
||
|
|
aa51644f5f |
fix(security): round 2 — the SEC-20 fix had four defects of its own
A second review round over the first round's fixes, which is where the remaining real bugs usually are. All four are the same failure as the round it reviewed: a claim stated in a comment that the code does not hold. The middleware moved inside CORS, but the setup gate is registered LATER and is therefore OUTSIDE it — so the comment's "the setup gate never runs for a body that is already too large" was false the moment it was written. On an unconfigured instance that gate opens a DB session per request, which is exactly the anonymous pre-auth work SEC-20 exists to remove. The cap now sits between them, and the order is asserted against `app.user_middleware` rather than described: prose about a stack that `add_middleware` builds inside-out is how this got backwards. The edge ceiling and the route's own cap were pinned EQUAL, but they measure different things — the edge counts raw body bytes, the route counts the decoded file. A file of exactly the advertised maximum was refused one layer before the route that would have taken it. The edge now allows for the multipart envelope, and the test asserts the relation and the gap instead of equality. `sysconfig`'s new `max` was enforced on write only. That bound did not exist before this epic, so a value that was legal when it was written can be sitting in the database — served unclamped it produces exactly the drift the comment says is impossible, with the admin page showing a number outside its own range. Int overrides are clamped on read now, which also survives an env default being lowered later. And `DOWNLOAD_UPLOAD_MAX_BYTES=0` meant "no per-file cap" to the route but a hard zero to the edge: every upload refused, including the admin page's own way back, recoverable only by editing env and restarting. The setting takes `ge=1`. Two test gaps from the same round: the header assertion that is the ONLY check of the middleware's position was DB-gated and skipped on a host without Postgres, and the headroom "ratio" the docblock calls load-bearing was held as `<=`, i.e. no ratio at all. Both fixed, plus a sweep that every `capped_body` call site in the routers is known and fits under its ceiling — the point of that list is the third entry, not the two that exist today. |
||
|
|
378b7559e4 |
build(frontend): finish the React 19 move, and hold the versions that were left in prose
react-i18next was still on ^14.1.2. The E27 S1 plan named it as "the one peer that actually needs moving — React 19 support is the 15 line", and then it did not move. The 14 line's <Trans> reads `element.ref`, removed in React 19, and its withTranslation types are built on the GLOBAL JSX namespace @types/react 19 no longer declares — both latent only because this app imports neither. 15.7.4 fixes both at the source (`c.props.ref ?? c.ref`, `React.JSX.LibraryManagedAttributes`). `reactEngine.contract.test.ts` joins its Node and Python twins and holds what was only a comment: the compiler's `target` against the installed React major, react and react-dom together, the types against the runtime, and react-i18next's floor. A stale target is not a corrupt bundle — the plugin validates the option — but it is a silent one, since tsc never sees Babel's output. pythonEngine gains the seventh spelling nobody held: `requirements.lock`'s own regeneration recipe names a python image, and wheels resolve against THAT interpreter, so a recipe left behind yields a lock that does not match the image installing it. The type lane now covers `alembic` too, for the reason the ruff lane was widened to it — migrations run on every container start, so one that raises crash-loops prod. It came back at zero new errors, so the baseline is untouched. `tests/` is deliberately still out, and the config says why. vitest.config's docblock claimed the React transform is "a no-op for the plain .ts node tests". It is not: the compiler rewrites src/lib's hooks in that lane too, which is the point of sharing one factory with the build — and worth knowing before ruling the compiler out of a divergence. Tests: useCardPager had none, in either the hook or its two callers, though its page clamp moved to render in S2 — the transformation where a non-convergent condition is a crash rather than a glitch. Four cases now, including termination on an empty list. Combobox's and Avatar's close/failed-URL cases are relabelled CHARACTERIZATION: both pass against the pre-sweep code, and the tests beside them are the ones that discriminate. |
||
|
|
05121fbc99 |
fix(security): SEC-20's exemptions become raised ceilings, and the 413 is dressed
The module admitted its two opt-outs under a stated rule — "both enforce their own limit on the bytes they actually read, which is what makes opting them out safe" — and that rule is false for one of them. `POST /api/downloads/upload` declares `file: UploadFile = File(...)`, so FastAPI parses and spools the whole multipart part to a temp file BEFORE the first dependency runs, `require_human` included: its in-handler cap measures bytes that have already landed. Measured on the installed fastapi 0.141.1 / starlette 1.6.0 — starlette's max_part_size guards non-file parts only, so an anonymous chunked upload was bounded by nothing. In the shipped self-host compose that lands in a RAM-backed tmpfs. The exposure predates SEC-20 (no route was capped before it); what this branch added was the claim. So the list becomes a mapping of per-route CEILINGS instead of a set of exemptions: every request now gets a finite number, the upload route's being the same one the route itself enforces. `sysconfig`'s knob is bounded by it so an admin cannot raise the handler's cap past the edge and start collecting 413s from a layer the admin page never mentions — held by a test, not by prose. Two more things the review caught in the same module: The 413 was not byte-identical to `capped_body`'s after all. `json.dumps` puts spaces where JSONResponse does not, and the test that promised to hold them together compared PARSED JSON, so it could never have seen it. Built through the same JSONResponse now, and compared as raw bytes. And the middleware sat outside CORS and the security headers, so its 413 carried neither — on the split-origin dev setup the browser drops it and the user gets a generic network failure instead of the translated too_large, while the identical refusal from capped_body one layer down renders fine. It moves to just inside CORS: still ahead of the session, the setup gate and every auth dependency. Also corrected: the docblock's "largest non-exempt body is 64 KiB, ~16x headroom" (it is 512 KiB and 2x — the diagnostics sink, named eleven lines further down), and CLAUDE.md's Request SIZE row, which forbade the one correct use of capped_body that is actually in the tree. |
||
|
|
bf518223e5 |
fix(frontend): the scheduler's two number editors stop swallowing what you typed
Both refused an out-of-range value by discarding it: the entry vanished, the box snapped back to the server's number, and nothing said whether the save had happened, been rejected, or been ignored. That is the house rule about honest states, applied to a write instead of a read. They now keep the draft, mark the field aria-invalid and name the range (HU+EN), and the Save control is disabled rather than silently inert. The interval editor also dropped a keyboard user's focus on every exit — Escape, Cancel and Save all unmount the focused input — so it hands focus back to the pencil that opened it, the pattern IssueComposer already uses. Tests: the four interval-editor cases added in S2 are relabelled CHARACTERIZATION, because they pass against the pre-sweep code too (the old effect was already guarded by `editing`). The editor that genuinely lost typing to a poll is the maintenance batch box, whose old effect had no guard at all, and it had no such test — it does now, anchored on a value that follows the server so it cannot pass on a page that simply has not polled yet. |
||
|
|
332d24a646 |
fix(frontend): four state defects the deep-review found in the E27 S2 sweep
Every one of them is the same shape: a value derived correctly, and a WRITE to the thing it derives from left unattended. Deriving hides a stale value; it does not consume it. PlayerModal — the watch toggle inherited the previous video's auto-watch. Moving the queue-advance sync into render was right for `currentVideoId`, but the same block also synced `status`, and that one had an ordering constraint of its own: useYtProgress's cleanup for the OUTGOING item can still call setWatched(true) near the end of a clip, and cleanups run after the commit. As an effect the sync landed last and corrected it; from render it lands first and the stray write wins. Press Next in the final ten seconds and the NEXT video showed as watched — one click on the toggle then sent onState(next, "new") for an item already new. Fixed by keying the mirror to the id it describes, so a write for another item is ignored rather than displayed, and no ordering has to be argued about at all. ChannelsProvider — a focus-channel intent was masked by the page rather than consumed, and nothing ever cleared it. The manager is a page chunk: it unmounts on every navigation, and its seed effect runs again on each mount, so returning to it re-applied a channel name from earlier in the session over whatever the user had typed since. The consumed marker now lives in the provider, keyed on the token — which also closes the case the old clear-on-leave effect never handled, opening a channel page (that does not change `page` at all). TagFilterChips + ChannelTags x2 — `open = openRaw && hidden > 0` hides the overflow popover when the lane stops overflowing, but floating-ui only reports a close for a real event on a mounted surface, so the raw flag stayed true and the menu came back by itself on the next re-fit — a window resize or the text-size slider is enough, with nobody near it. The flag is now reset in render against the same condition that hides it. Avatar + downloadRow — remembering a failed image BY URL fixed the recycled-row frame but never forgot it, so a transient 429 pinned a channel to its initial for the life of the instance, never re-requesting. The memory is now dropped once `src` moves off it, which keeps both properties. And playerModalHooks' last exhaustive-deps suppression claimed "nothing in the body reads a render value" while reading five. The invariant that is actually load-bearing is that each of them is a per-item snapshot — written down instead. All four fixes are mutation-checked: each new test was seen red, against the fixed line only, naming the real defect. |
||
|
|
c5e022f056 |
refactor(frontend): the long tail — and eslint-suppressions.json is gone (E27 S2)
Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was <10), and every one of the 34 survivors is an inline directive NAMING the invariant it protects. The suppressions file is deleted, because a bulk file hides the next violation instead of answering it — and it cannot hold a warning-level rule at all. The sprint plan's three verdicts missed the answer that turned out to be the most common one: adjust the state DURING RENDER. React re-runs the component and discards that pass rather than committing it, so the stale frame an effect-based reset paints does not exist. Applied to the Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the channel banner's retry count, the download center's tab guard, the Plex search highlight, the comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so that one stays an effect and says why. Mirrors of a prop or query became derived values or `draft ?? server`: - `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset, in rows recycled down a virtualized list, so one job's missing poster painted over the next one's for a frame. Keyed by URL now. - Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six tests where there were none, the one behavioural change (the save button stops being dirty at commit rather than at the echo) mutation-checked. - SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and derived rather than pushed into state by an effect that had to commit first. Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted. Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which the checker reads as a forward reference; both go through the ref idiom this file already uses. BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so the "already adopted THIS identity" comparison now enforces it in code, with a test that fails without it (the existing reload tests all install their spy too late to catch it). One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on `q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe in production, but the component must not melt down when something hands back a fresh object per render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof; the replaced comment had warned about precisely this. CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true. |
||
|
|
4065ac7e6a |
refactor(frontend): the two players — one real ordering bug avoided, five suppressions gone (E27 S2)
The interesting part of this batch is a change that was WRONG and the test suite said so. PlayerModal synced its local watch-status mirror and `currentVideoId` from an effect on `active.id`. Moving that into render is right — as an effect there was a frame per queue advance showing the new episode's chrome over the previous item's watch state, with `navigated` still true so the "you have navigated away" affordance flashed. But the same effect also advanced `currentIdRef`, and that line must NOT move: useYtProgress's per-item cleanup persists the OUTGOING item's position and guards it on `currentIdRef.current === id`. React runs every cleanup before any setup, so in an effect the ref still names the item being left. Advanced to render time the flip happens first, the checkpoint is skipped, and stepping next→prev back to an item resumes from the frozen launch queue's stale position. "resumes an item played earlier in the SAME session" caught it immediately (748 instead of 830); the ref write stays in its own effect with the ordering written down. The rest: - PlayerModal's keyboard handler is a useEffectEvent, so the "bound once, reads live state" contract is enforced instead of asserted. `focusModal` is a useCallback now — it reads a ref and nothing else, and three hooks take it as a parameter where a fresh identity would spread. - PlexPlayer's session reclaim called `loadSession` from inside loadSession's own useCallback, which the checker reads as a forward reference. Routed through a ref, the file's existing answer for exactly this (detachSessionRef, cancelAutoSkipRef). - PlexPlayer's item-load effect excluded `prefs` by hand so a track pick from the player's own menu would not double-load the session. The two language prefs go through a ref instead — "latest value, not a trigger", said in code rather than in a comment. - PlexPlayer closes its up-next card during the render that changes `id`, not a commit later. - The one PlexPlayer directive that stays is the live session counter, whose cleanup must bump the CURRENT value: the rule's advice to copy it into a variable is precisely wrong there, and the comment now says why rather than pointing at another comment. Suppressions 75 -> 70; exhaustive-deps 20 -> 17. |
||
|
|
ce08fc2aec |
refactor(frontend): a stable useHistorySubview, and PlexBrowse can stop disabling the check (E27 S2)
useHistorySubview returned a fresh object with a fresh `open` on every render. Correct, but it made "re-run when the sub-view moves" unwriteable: PlexBrowse had three effects that wanted exactly that and had to disable the dependency check to get it, and a fourth that listed `sub` honestly and therefore re-ran on EVERY parent render, saved only by a ref guard. Fixed at the source, which is where the house rule says a principle belongs: `open` and `back` are useCallbacks and the returned object is memoised. `url` is a module constant at every call site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only module functions, so there is nothing left to churn on. All three PlexBrowse dep lists are now the honest ones, and the comment that claimed `open` was "behaviourally stable" is replaced by it actually being stable. PlexBrowse's remaining four are escape hatches, and two of them are now ONE: - `infoScrollRef` is written from four places, and the compiler objects to a ref being modified outside the effect that reads it. That objection is a conservative false positive — a scroll memory needs exactly that arrangement — but four bare mutations were worth concentrating anyway. One `rememberInfoScroll` writer now holds the "stash on the way out, reset on a fresh page" rule, and answers the checker once instead of three times. - The language-commit effect cannot move into render: it resets PlexProvider's filters, and a render-phase setState may only touch the component's own state. Said so at the site. Suppressions 78 -> 75; exhaustive-deps 23 -> 20. |
||
|
|
ea6d829c29 |
refactor(frontend): the context providers adjust state in render, not after it (E27 S2)
Eleven suppressions across the five providers. The recurring shape here was "an effect that undoes something when you navigate away", and every one of them ran a commit LATE — the new page painted once while the old module's state was still live. Adjusted during render instead (React discards that pass rather than committing it, so there is no in-between frame): - SavedViewsProvider reset the sticky view and closed its modal from an effect on `source`, so the first Plex render carried the feed's sticky id — long enough for a stale view chip. It also followed the active view into stickyId from a second effect; that one converges by construction, since preferSticky returns the sticky match when there is one. - NavigationProvider ended a live YouTube search from an effect on `page`. Keyed on the page CHANGE rather than derived from it, because "ended" has to be permanent — deriving would have quietly brought the search back on return to the feed. - ChannelsProvider's manager focus is now simply DERIVED from the page: it means nothing off the Channels page, and focusChannel sets it and navigates in one handler. Honest deps by freezing what is genuinely mount-time: - FeedFiltersProvider persisted a share link's filters with an empty dep list; listing `filters` honestly would have re-persisted on every edit and spun. The mount value is frozen into its own state, so all three deps are stable and the effect says what it does. - ChannelsProvider's URL-seed layout effect and NavigationProvider's initial history stamp get the same treatment. The three slice setters they call were already stable useCallbacks (useFilterState / useSortState / useAccountPersistedState all return one). - SavedViewsProvider reads `cfg` through a ref: it re-memoizes on every filter edit, and the per-source seed guard already enforces once. That also made the set-state finding go away — with the render value gone, the checker can see the seed for what it is. WizardProvider keeps its one escape hatch, now inline with the invariant: auto-opening is a one-way decision taken from server data as it arrives, and deriving it would reopen a wizard the user had just closed. Suppressions 89 -> 78; exhaustive-deps 27 -> 23. |
||
|
|
6a7ff330f6 |
refactor(frontend): the shared hooks in lib/ stop lying about their deps (E27 S2)
Ten more suppressions, and React 19 turns out to supply the missing primitive.
useEffectEvent (stable since React 19.2, which S1 brought in) is the canonical answer to the
"bind the listener once, but read the latest values" pattern these hooks were disabling the
checker for. Three of playerModalHooks' four escape hatches become honest dep lists: the
wheel-volume handler, the native-controls handover, and the fullscreen-change sync. The
contract is no longer prose — the compiler enforces it.
The fourth stays, deliberately, and now says why at the site: the YouTube player's lifecycle
effect must run once per video (re-running destroys and rebuilds the iframe), and YT.Player
takes its callbacks as an object literal, which an effect event may not be passed into.
Fixed, not suppressed:
- useDismiss captured onClose when the popover opened, so a caller that re-rendered with a
different handler while it stayed open kept getting the OLD one called. The disabled dep list
asserted the opposite ("read fresh from this run's closure"). Both the handler and the ref
list are read through refs now; three tests, the two that matter mutation-checked.
- usePref wrapped its seed in a useCallback whose dep list had to be excused. useState calls a
lazy initializer exactly once, so the memo was saving nothing; inlined.
- useCardPager clamped an out-of-range page from an effect. Adjusted during render instead —
React discards that pass rather than committing it, so no out-of-range page is ever painted.
- plexPlayerHooks' auto-skip countdown reads its marker from the ref that already existed, so
the dep list names its real inputs. That also made the set-state-in-effect finding go away:
the progress is genuinely timer-driven, and the checker can now see that.
- useUrlMirror and useChipOverflowFit only needed their stable refs listed.
Two test-harness suppressions (a render-phase probe ref, a module-level setState handle) moved
inline with the reason they exist: both exist to observe a hook from outside without changing
what is being measured.
Suppressions 99 -> 89; exhaustive-deps 35 -> 27.
|
||
|
|
7e1aeeb9fe |
refactor(frontend): honest effect deps in the app shell and the ui layer (E27 S2)
Burns the first 14 of the 113 hook suppressions. Three verdicts, applied per site:
A useEffect that should not exist (6):
- Avatar remembered "the image failed" as a boolean an effect reset on src change. The
component is recycled across items in the virtualized feed, so after one avatar 429s the
next item's slot committed the fallback initial over a perfectly good image and only the
post-paint effect cleared it. The failure is now keyed BY URL; derived state cannot be stale.
- Combobox cleared the typed query from an effect on `open`. Moved into setOpenAnd, the single
funnel every close goes through. That also stops a spurious onQueryChange("") AT MOUNT, which
told a controlled caller its own initial query had been cleared before the user touched it.
- CommandPalette reset query + picked row from an effect. Closed now means UNMOUNTED, not
hidden, so a fresh open starts blank because mounting already means that -- and the ranking
memo over every channel, playlist and saved view stops running while the palette is shut.
- Pager's page box held a copy of the page that an effect re-synced; it now derives it and
keeps a draft only while being edited.
- App's ?link= / ?oauth= / ?admin= one-shots asserted "once per mount" with an empty dep list.
The ref guard the codebase already uses in 22 places ENFORCES it, so the honest deps can
include `t` -- which a language switch legitimately changes, and which would otherwise have
re-fired the toast.
Honest deps that also fix something (5):
- App's module preload was keyed on the account id, so a permission change that alters the
module set without moving the id never re-preloaded. `preload()` is idempotent, so depending
on `me` costs nothing.
- App's access-request nudge now re-issues on a language switch; it used to stay in the old
language until the pending count changed.
- App's diagnostics effect: applyDiagnostics already no-ops on an unchanged effective level,
so picking four fields out by hand was guarding nothing and missed any field added later.
- DataTable's two filter effects: a ref holds the latest onFiltersChange so commitFilters can
be stable, which is what lets the effects name it instead of switching the check off.
Genuine escape hatches (7) moved OUT of eslint-suppressions.json into inline directives that
name the invariant at the site -- the toast handover's render-phase global, the live region's
deliberate arming tick, the docked one-slot component, the popover engine's floating-ui list
state, dnd-kit's per-drag `moved` flag, BackToTop's DOM read and the palette's debounced
announcement. A reader of the code now sees why; a JSON file no one opens does not.
Tests: Avatar (3, new file) and Pager (3) pin the derived-state frames with a layout-phase
probe -- layout effects run before any passive effect, so they read the frame the browser would
paint. Combobox (2) pins the close-clears-query contract and the no-callback-at-mount rule.
All three sets mutation-checked against the effect-based originals.
Suppressions 113 -> 99; exhaustive-deps 45 -> 35.
|
||
|
|
7d682c90d5 |
build(frontend): React 18 -> 19 with the React Compiler (E27 S1)
The compiler auto-memoises, which is the point: it infers what the app's 112 `useCallback` and 48 `useMemo` call sites were hand-written for and applies the equivalent itself. It is on repo-wide with no opt-out list — it bails on anything it cannot prove safe, and says so through the lint rules. Verified in the built bundle rather than assumed: 369 `react.memo_cache_sentinel` markers across 35 of the 76 chunks. ONE transform, shared by vite.config.ts and vitest.config.ts through reactPlugin.ts. Not two `react()` calls: if the compiler ran only in the production build, every vitest run would exercise code the user never receives and a compiler-induced change would ship green — the same gate-vs-image split 0.93.2 was released to fix. The plugin is imported rather than named by the usual string, because a string is invisible to knip and, worse, a typo in it is a silent no-op that ships an uncompiled bundle, while a bad import is a tsc error. eslint-plugin-react-hooks 5 -> 7 brings the whole compiler rule set: 283 findings, triaged rather than bulk-suppressed, because the two halves needed different answers. react-hooks/refs (217) is OFF. 153 of those 217 are this app's popover architecture, not its history: 79 are @floating-ui/react's documented `pop.refs.setReference` / `getReferenceProps` callback-ref setters, and 74 more are passing the popover handle down as a prop, which is what components/ui's popover engine is for. Suppressing them would not help the next popover anyone writes. The remaining ~64 are genuine render-phase `ref.current = x` writes and are real, but the rule cannot tell them apart here; they are E27 S2's work. The other 66 go into eslint-suppressions.json as a ratchet — new violations fail the gate, and `--prune-suppressions` makes a fixed one fail too until it is removed. 45 of them are `set-state-in-effect`, which IS E27 S2's subject, so that sprint now has a measured worklist. react-i18next deliberately stays on 14. It never declared a React-19 peer — every version from 14 to 17 asks only for `react >= 16.8.0` — and what the newer lines actually require is i18next 25/26 against our 23. That is an i18n runtime migration wearing a React hat, and it buys this sprint nothing. Two React 19 type changes: `useRef<T>(null)` is now `RefObject<T | null>` (the honest shape — the element is null until it mounts), so PlexPlayerSettings' prop follows; and reactPlugin.ts joins tsconfig.node.json's include. The compiler skips memoising VirtualGrid and reports it: TanStack Virtual's `useVirtualizer()` returns functions that cannot be memoised without going stale. That is the right trade for a component whose cost is its row window, and it is the one such site in the app, so it carries a targeted disable with the reason — a second incompatible library anywhere else must still stop the gate. Nine lanes green, vitest 1558. Browser UAT on the dev server against the still-running React 18 build as a control: boot, feed (virtualized grid, popover menu, a filter change that moved the list 195,187 -> 64 items with the URL mirrored, so no stale memoised UI), command palette, player modal — no console errors on any of it. |
||
|
|
cc9fc3b544 |
build: pin one Python version across the app, the images and the gate
0.93.2 shipped a week's worth of confusion about which Node this repo
runs on; the Python side had the identical shape and nobody had looked.
Prod (Dockerfile), the test image (Dockerfile.test) and CI (check.yml)
each spelled 3.12 as an independent literal, there was no
`.python-version`, no `requires-python`, and ruff had no target-version
so it judged our syntax against some other interpreter. The dev machine
had no 3.12 installed at all — only 3.13 and 3.14 — so every ad-hoc host
script ran two majors ahead of production.
`.python-version` is now the single source of truth, and the contract is
held by tests rather than by prose. It is split in two because no single
runner can see every link: the pytest suite runs inside the test image,
which mounts backend/ alone and cannot read the repo-root Dockerfile or
the workflow.
test_python_engine_contract.py : running interpreter == pyrightconfig
== ruff.toml
pythonEngine.contract.test.ts : .python-version == pyrightconfig ==
ruff.toml == Dockerfile ==
Dockerfile.test == check.yml
Together they close the loop, and the backend half carries the assertion
that pays — that the interpreter running the suite is the pinned one. A
green suite on another minor does not mean the shipped image is green;
it means they have drifted and nobody has noticed yet.
|
||
|
|
c9101e3450 |
ci: type-check the backend — the gate's ninth lane (E27 S3)
Three tsc projects held the frontend and NOTHING held the backend: ~36k lines of `app/`, annotated in 113 of 128 files, that no lane had ever read. Asymmetric by accident, not by decision. basedpyright, in basic mode. Measured against mypy on this tree with the dependencies actually installed — without them basedpyright errors on every import and mypy's --ignore-missing-imports quietly turns them into Any and UNDER-reports. Both land ~190-200 errors with the same distribution, so the choice fell to what differs: basedpyright is ~2x faster (4.4s vs 7.4s, and this runs on every `siftlode lint`), resolves our `app.*` imports from its own config with no MYPYPATH, and has a first-class `--writebaseline`. That last one is the ratchet itself; mypy would have needed a third-party tool for it. 198 pre-existing errors are baselined, so the lane fails on NEW errors only and the list burns down epic by epic. A green lane means no new damage, not a clean codebase. It runs in the pytest test image rather than on the host, and that is forced rather than chosen: `requirements.lock` cannot be installed on Windows at all — uvloop refuses to build there — so a host venv could only ever be the lock minus a package, and a type checker without the real third-party packages reports nonsense rather than nothing. The image installs the pin from requirements-dev.txt, so the lane's version IS the pin and there is no host copy to drift. The cost is that `siftlode lint` now needs Docker, which it did not before. |
||
|
|
51daf3e388 |
feat(security): cap every request body before auth runs (SEC-20)
FastAPI reads a declared body IN FULL before the first dependency, so
`POST /auth/password-login` drained an arbitrary upload from an anonymous
caller and only then answered 401. 70 of the 72 body-declaring routes
behaved the same way: one unauthenticated client could spend the
process's memory and time at will.
A raw ASGI middleware, not `@app.middleware("http")` — BaseHTTPMiddleware
cannot wrap `receive`, so it could only trust `Content-Length`, which a
client may omit (chunked) or understate. This refuses an oversized
declaration outright and counts the bytes of everything else as they
arrive, the same rule `capped_body` already applies per-route. Registered
directly inside the access log: a refusal is still logged with its
request id, but the session, CORS, the setup gate and every auth
dependency never run for a body that is already too large.
Two routes opt out, under one stated rule: an exemption is ONLY for a
route whose own cap is larger than the global one. That is why
`/api/diagnostics/events` is absent — it caps itself at 512 KiB, below
the ceiling, so exempting it could only widen what it accepts. The list
names route templates and a test resolves each against the live route
table, so a rename fails the gate instead of silently dropping an
exemption in prod.
The 413 is byte-identical to `capped_body`'s and the test compares the
two against each other rather than against a literal — two shapes for one
fact is how the `cancelled`/`canceled` bug happened.
|
||
|
|
09d351a0e7 |
docs(ci): the two gate jobs queue, they do not run in parallel
Declared side by side, so they read as parallel; the lab runs a single act_runner with one slot, so the backend job starts at the exact second the frontend one ends. Measured across runs #86-#91: ~4 + ~6 = ~10 minutes from the push, not ~6. Written down because believing the comment cost something real — the ops release read-back budgeted the max instead of the sum and timed out on its first live run, reporting a still-running gate as a failing one. |
||
|
|
b5078003ce |
release: 0.93.2 (bug/ci-node-version-split)
gate / frontend (push) Successful in 3m51s
gate / backend (push) Successful in 6m7s
|
||
|
|
f2c32cfb8e | chore(release): 0.93.2 — a Plex amount reads the same in every browser | ||
|
|
1453fa4a33 |
ci: run the gate on branch pushes only, not on release tags
A release pushes main and then the v<ver> tag, and a bare `on: [push]` treated the tag as a second push of the identical commit — every release ran the whole gate twice to the same verdict (runs #81-#90 are five such pairs). It has to be `branches`, not the `tags-ignore` this obviously wants to be: Gitea's matchPushEvent requires every named filter to match and each one bails on the wrong ref kind, so `tags-ignore` rejects every BRANCH push and silently disables the gate. Measured in go-gitea v1.27.3, where it differs from GitHub. A trigger filter that stops firing is invisible, so the ops `release` now reads the verdict back for the sha it pushed and says so when there is no run for it. |
||
|
|
efe106dfda |
build: pin one Node major across the dev, CI and build machines
The server gate ran node:22 while the dev gate ran node:24, and the two disagree about Intl.NumberFormat's compact currency padding and about which realm's Blob a Response hands back — so the frontend job had been red for 73 of its last 90 runs while the local gate was green, and nothing said so. Align everything on 24, with `.nvmrc` as the single source of truth: the CI container, the prod build stage, `engines`, and `@types/node` (which was two majors ahead of the runtime, so tsc was green over APIs the shipped engine does not have). The backend job's node only runs the checkout action, so it tracks the pin as hygiene — node 20 is EOL — but deliberately stays outside the contract. The pin is held by a contract test that reads every one of those places AND the engine running it, so the next drift is a red lane on its first run instead of a CI log nobody reads. Verified on node:24-bookworm (the CI lane: 228 files / 1552 tests, typecheck, lint, format:check, knip all exit 0) and on node:24-alpine (the prod build stage, v24.21.0, SPA built). Each assertion of the new test was mutation-checked red. |