From c5f552921e66f004505b82eb99d8c398ade6e748 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 29 Aug 2026 15:12:27 +0200 Subject: [PATCH] Updated CHANGELOG --- CHANGELOG.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ea79450..09c7b01e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,13 +111,18 @@ All notable changes to Bambuddy will be documented in this file. - **A print archived without its 3MF can be given its filament weight by hand (#1820, reported by @ojimpo)** — When the sliced file stays somewhere Bambuddy cannot read, the archive is created from the printer's report alone and carries no weight, so the print is missing from every filament total and there was no way to put it right afterwards: Rescan reads the figure out of the 3MF, and that archive has no file to read. The reporter's H2S print left 46 g of PLA on the spool with nothing recording it, and he corrected Spoolman by hand. **Edit Archive** now has a **Filament used (g)** field. It is written to the print's most recent run as well as to the archive, because the Projects roll-up and the Prometheus counter sum the runs rather than the cards — correcting only the card would have fixed the display and left every aggregate reading the old figure. The value is bounded at 0 to 100 kg, it is sent only when you actually change it, so an ordinary save cannot round off a sliced figure, and emptying the field clears it. A run that measured its own weight through spool tracking keeps that measurement — the correction fills in a run that has none, or one that only ever inherited the archive's estimate, and never overwrites a real measurement with a typed one. Nothing is deducted from Spoolman or internal inventory either: those are charged from what was tracked at the time, and a print that recorded nothing has nothing to reverse. On an archive that does have its 3MF, Rescan still overwrites what you typed — there the file is the authority. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **The internal-storage probe now logs which directory served the file (#1820)** — When a printer says a print went to internal storage and Bambuddy finds it over FTPS anyway (#2856), the log said which file but not where it came from. On a printer that keeps uploads for weeks — the reporter's H2S has months of them in `/cache` — a reprint of a name that was re-sliced but never re-sent can match an older copy, and without the directory in the log that mismatch was invisible rather than merely rare. The download helper now reports the path that served the file instead of a bare success flag, and the hit line names it. Covered by backend tests. - **Card and row actions were unreachable on phones and tablets (#2865, reported by @aishlai)** — The three-dot menu on a project card carries Edit and Delete and appeared only on hover, so on a phone there was no way to rename or delete a project at all; the File Manager's folder actions went the same way, as did duplicating a preset, renaming or deleting a tag, removing a print photo and deleting a plate-reference image. This is worse than a missing hover event: Tailwind v4 compiles `hover:` and `group-hover:` inside `@media (hover: hover)`, so on a touch-only device the rule that reveals the control is not merely never triggered, it is never applied — the element stays invisible for good. The hiding half is now what depends on a hover-capable pointer, so a device that cannot hover simply shows the control, and a mouse behaves exactly as before. Four spots on the Archives cards and two on the File Manager's file cards already tried to handle this by viewport width, under 768 pixels, which meant a phone was fine and a tablet in landscape was not; they now use the same capability check and the width guess is gone. Keyboard users were affected too, in the other direction — the buttons were invisible but still focusable, so tabbing through a card stopped on something nobody could see; focus now reveals them. Wiki updated. Covered by frontend tests. +- **The File Manager's card menu no longer loses its top entry (#2846)** — In grid view a file card's action menu was drawn inside the card, and the card clipped anything its children painted outside it. A card is as tall as its square thumbnail plus whatever metadata the file has, so an STL — which has none beyond a name and a size — produced the shortest card in the library, about 270px against a seven-entry menu that needs closer to 310px. The difference was one row, and the row it took was the top one: **Slice**, since **Print** is only offered for a file that is already sliced. A 3MF carries a target model and a print count, two more rows, and its card was tall enough, which is why the button appeared there and looked like a file-type rule rather than a layout accident. Nothing about STL was special; the shortest card simply lost the first item, whichever it happened to be. The menu now opens against the viewport, the way the archive card's menu already did, so no card can crop it, and the card no longer clips its own children. List view was never affected — it has no menu, only inline buttons. Covered by a frontend test. +- **Closing the bug-report panel no longer throws the capture away and leaves the logs running (#2847)** — Step 2 of the report flow asks you to reproduce the problem, and the panel sits over the part of the app you have to reach to do it. Closing it was the obvious move and it was the wrong one twice over. Reopening put you back on an empty step 1 — while the server was still logging at DEBUG, with nothing left in the flow that could stop it, because only **Stop & Submit** ever did. Leave it closed instead and the five-minute cap eventually fired behind your back: logging stopped and the report was filed with no window open and no confirmation that it had happened. Which of the two you got depended only on whether you reopened the panel inside five minutes. A capture is now a thing that outlives the panel. Closing keeps it running and says so — the bug button turns amber for as long as a capture is going, and clicking it returns you to step 2 with your description, your screenshot and the elapsed timer where you left them. If the cap does fire while the panel is closed, the panel reopens so the submission happens in front of you rather than behind you. The timer is measured against the capture's start time rather than counted in ticks, so a background tab, where browsers throttle timers hard, no longer stretches five minutes into something else. A capture also survives a page reload, which matters because reloading is a perfectly ordinary step in reproducing a bug: the report picks it back up where it was. One that outlived the cap while nobody was watching is not resumed and not filed — a description written an hour ago is not a report you are still expecting — but the log level is put back, which is the part that previously stayed wrong indefinitely. Translated in all locales; wiki updated. Covered by frontend tests. - **The build plate of a powered-down printer can be cleared again (#2864, reported by @bryanmahin)** — With Auto Power Off enabled this is the ordinary end of every print: the job finishes, Bambuddy switches the printer off at the plug, and the plate is left flagged dirty on a machine that is no longer reachable. The operator then walks over, clears the plate, and had no way to say so — `POST /printers/{id}/clear-plate` answered 400 "Printer not connected", and the button was hidden on the printer card, so the physical clear-plate buttons some farms drive over the API went dead too. Everything gated on the flag stayed stuck until each printer was powered back on by hand, cleared, and switched off again, which is the opposite of what Auto Power Off is for. Nothing in clearing a plate talks to the printer: the flag is Bambuddy's own state, persisted in its database precisely so it survives the power cycle, and the check that refused was inherited from the stop, pause and resume handlers next to it, where reaching the printer genuinely is required. It is gone, and the card offers the control whether the printer is online or not — in both card sizes, and for bulk selections, where powered-down printers were being filtered out of a Clear All. This does not dispatch work to an unreachable machine: the queue still requires a live connection before it sends anything, and releasing the gate is what lets it power the printer back on for the next job instead of skipping it. One related gap went with it — a printer with no live connection at all, disconnected by hand or not yet reconnected after a restart, reported its plate as clean over the API regardless of what the database said, which hid the control on exactly the printers that needed it. Wiki updated. Covered by backend and frontend tests. - **Timestamps hours ahead of themselves on PostgreSQL in a non-UTC zone (#2855, reporter @Tolga-Unal)** — On a UTC+3 install every AMS humidity reading and every archive was stamped three hours ahead of when it happened. Bambuddy stores naive timestamps holding UTC and the frontend reads an offsetless timestamp as UTC, so the display added the offset to a value that was already local. The Python side has honoured that contract since #504 — but the reporter's timestamps were not written by Python. Around ninety-six columns take their value from `server_default=func.now()` and the migration DDL carries another forty-nine on `DEFAULT CURRENT_TIMESTAMP`; the database fills those, and on PostgreSQL `now()` is a `timestamptz`, so storing it into a `timestamp without time zone` casts it through the session TimeZone. A Postgres container started with `TZ=Europe/Istanbul` bakes that zone in at initdb, and every defaulted column then receives local wall-clock. Connections now carry `timezone=UTC`, which makes the cast a no-op whatever the server is set to — measured through the real engine factory against a live PostgreSQL, a session on the reporter's configuration stored +10800s and the fixed one +0s. Pinning the session was preferred over a hundred and forty-five individual edits partly for its size but mostly because half of those sites are raw DDL no model-level change can reach. SQLite needed nothing: its `CURRENT_TIMESTAMP` is UTC by definition and it has no session timezone to get wrong, which is why this survived two years of timezone fixes without showing itself, and its behaviour is now pinned by a test rather than assumed. Rows already written are deliberately left alone — the inverse cast is computable, but `created_at` is assigned explicitly on some paths and defaulted on others, and an install that began on SQLite holds correct and shifted rows side by side with nothing to tell them apart. The support package's oldest-pending-job age went with it: it subtracted a naive local clock from a naive UTC column, reporting a job queued five minutes ago as three hours old east of Greenwich and a negative age west of it. - **A print with no 3MF borrowed another model's filament and cost (#2843, reporter @gyrene2083)** — An archive with no 3MF keeps the path the printer is executing as its filename, and on a sliced job that is always `Metadata/plate_1.gcode`. The fallback that looks for the same model in the Library or among earlier prints took its search term from there, so it searched for `plate_1` — a name every Bambu print in existence has — and matched on a substring, so it also matched any name merely ending that way. On an H2D a 1.6 g cube resolved to `lid_plate_1.gcode.3mf` and was costed at 207 g across three real spools; in the same database `Bank.3mf` matched "Piggo the piggy bank". The matcher now takes the model name the printer reports when the filename is only a plate path, refuses a bare plate stem rather than searching for it, and anchors to a whole filename with LIKE metacharacters escaped, because `_` is a wildcard and model names are full of them. A print that cannot be identified is left untracked, which is the honest answer; checked against every row on a real install, 233 archives still match their own filename and the fourteen results that changed are all false positives that went away. Two further faults went with it. Those archives could not receive a timelapse at all: the destination was derived from the missing file's path and landed one level outside the data directory, which in Docker meant every attempt failed EACCES and the scan re-downloaded and discarded the video twenty-five times over twelve minutes; it now uses the shared helper #1820 introduced for exactly this. And a slot whose spool has no RFID and no hand-set remaining amount was skipped silently when filament was charged by percentage delta, which is indistinguishable from having nothing to charge — it now says so in the log. +- **A slot that could not be charged now says so (#2843)** — When a print's filament cannot be read from a 3MF, Bambuddy falls back to the drop in the AMS's own remaining-filament percentage. That needs a reading when the print starts, and a spool without RFID has none until you set a remaining amount by hand — so those slots were skipped in silence. Nothing was deducted and nothing said why, which is indistinguishable from having nothing to deduct. Every other reason for skipping a slot was already logged; this one now is too. +- **Timelapses were lost, and written outside the data directory, for any print archived without a 3MF (#2843)** — Every H2-series and P2S print sent from Bambu Studio, so not a rare case. The video downloaded from the printer correctly and was then written next to the data directory rather than inside it, because an archive with no 3MF has no directory of its own and the destination was derived from the missing file's path. In Docker that meant a permission error, retried and discarded twenty-five times over twelve minutes, roughly a hundred connections to the printer for a video that was thrown away each round. Where that location happened to be writable it was worse: the file landed beside the installation, the attach failed anyway, and the stray video stayed there. Bambuddy has had a shared helper for exactly this since #1820 and this was the one place still deriving the path by hand. Timelapses now land in the archive's own folder and attach normally. Covered by backend tests. - **The connection diagnostic mistook a slicer's print for one of Bambuddy's own (#2843 follow-up)** — Bambuddy records the `project_file` command behind every print, because it names where the sliced file was put and so decides whether the archive can have a thumbnail and slicer metadata at all. Its own dispatches were told apart from a slicer's by testing the sequence id against 20000, on the belief that 20000 was Bambuddy's alone. It never was: 20000 is the slicer convention Bambuddy copied, and both slicers count up from it — measured on the wire, OrcaSlicer dispatched 20000 and then 20001, Bambu Studio 20009 and 20010. So whichever dispatch happened to land on the shared value was filed as ours and never recorded, and after a slicer restart that is the first print you send; the test was wrong in the other direction too, calling every higher value external including ones we had sent ourselves. Ownership is now established by remembering the job actually dispatched — sequence id, file, url and subtask name — and consuming that marker on the echo, one-shot, so a slicer reprint of the same file a moment later cannot hide behind our last one. Nothing about printing or archiving changes, and two tests pin that the storage verdict gating the FTPS sweep is untouched; what changes is that the diagnostic entry telling an operator whether their printer stores files where Bambuddy can read them stops undercounting, silently. - **A reprint of a file already on the printer lost its thumbnail (#2780 regression)** — A print of a file the printer already holds — a reprint from the touchscreen, from Handy, or a slicer send-to-storage followed by a print — reports its location as a path rather than as a fresh upload, `file:///media/usb0/`. Since #2780 landed Bambuddy read anything that was not `ftp://` as "the printer kept this internally", skipped the FTPS sweep, and archived the print with a name and timing only. Measured on an H2D: the file was listable and downloadable over FTPS at the moment Bambuddy declared it unreachable, and it was not confined to the H2 series the change was about — an X1C reprint from its own screen lost its thumbnail exactly the same way. That module's own rule is to skip only on positive evidence, and a `file://` path is not evidence of internal storage. It now reads the path: the printer's model cache under `/userdata` is a genuine skip, anything else is unknown and sweeps, which is what it did before. Unknown rather than external on purpose — the empty-slot check still runs ahead of it, so a `file://` print on a printer with nothing in the slot reports the missing card instead of sweeping for something that cannot be there. - **A printer that keeps its files on the card was written off as internal-storage-only (#2856, reporter @aishlai)** — A print's dispatch says where the printer put the sliced file: `ftp://` for external storage, `brtc://emmc/` for internal. Reading the second as "no file to fetch" is where the printer chose to put it, which is not the same as where port 990 can read it. The reporter's H2D — firmware 01.03.00.00, card in the slot — reports `brtc://emmc` and keeps the same file under `/cache`: their log has every print downloading from there, a 19 MB one included, until the skip landed and two days of archives came out as a name and nothing else. #2780's P2S and H2C really did fail on every path, so both are true and the URL alone cannot tell them apart. So Bambuddy now asks the printer rather than the model. The dispatch names the exact file, which turns the question into one connection walking five directories, against the sweep's ~110 that made skipping worth doing in the first place. A hit archives normally and is shared with the cover endpoint; a miss keeps #2780's fallback archive and its stated reason, so the banner still explains itself. It is not probed when the printer reports an empty slot, or while its file service is in TLS cool-off, because both have already answered the question. The connection diagnostic asked the same question off the URL and warned that the last print was out of reach — on this reporter's printer that warning pointed at a setting that was already right, so it now probes too, by directory listing rather than download, capped at six seconds to stay inside the support bundle's per-printer budget, with "could not check" leaving the warning standing. The probe filename arrives over MQTT and becomes both a remote path and a local temp filename, so names carrying separators, traversal or control characters are declined rather than cleaned. - **Energy tracking stopped as soon as a second plug was linked to a printer (#2859)** — Per-print energy is one plug's meter read at the start of a print and again at the end. Both readings asked for "the plug on this printer" in a way that raises when there are two rows, so linking a dry box, a filter fan or a lights script to a printer stopped energy tracking on that printer outright — and did it silently: the start handler logged the exception as an ordinary failure and the end handler then reported "no start kWh recorded", which is also what it says for a printer with no plug at all. The assumption was never enforced anywhere else; the plug API deliberately allows any number of Home Assistant entities, the unique constraint on the printer column was dropped on purpose, and every other consumer reads a list. Energy now ranks a printer's plugs — the one that powers it first, then by id so the start and end readings agree — and takes the first that actually reports a counter, so accessories drop out with nothing to configure. Ranking rather than filtering, because a printer whose only linked row is disabled or is a script used it before and still does; and when none of them measures anything, the log names the ones it tried instead of reading like "no plug configured". Existing archives cannot be backfilled, since the starting reading was never taken. Separately, the plug page counted an online plug as offline unless it reported energy, so a switch with no power sensor showed as offline for as long as it stayed linked. +- **The smart plug page counted an online plug as offline unless it reported energy (#2859)** — A plug with no power sensor is still online, but "N/M plugs online" only counted the ones sending energy figures, so a working switch showed as offline for as long as it stayed linked. The count now reflects whether the plug answers. - **The AMS temperature alert fired for the whole of a drying cycle (#1802, reporter @apizz)** — The alert compares against the same threshold that colours the printer card, which defaults to 35 °C. Drying deliberately runs at 45 °C for PLA, 65 °C for PETG and up to 85 °C on an AMS-HT, and the alert repeats once an hour for as long as the condition holds, so a twelve-hour dry sent twelve notifications about a temperature the user chose — and then kept sending them while the unit cooled back down, which is the half the reporter confirmed on an AMS 2 Pro and an H2C. Dispatch now consults the drying state the firmware already reports. Dry time alone is not enough, since it reads zero through the cooling phase that closes a cycle, so the dry-status bits already parsed for the drying-complete edge carry the rest. The cool-down afterwards is held by a latch released as soon as the unit reads back at or below the threshold rather than after a fixed delay, so a 65 °C cycle in a cold basement and a 45 °C one in a warm room each get the time they actually need, with a two-hour cap bounding the one case the latch cannot resolve on its own. Two exclusions are deliberate: humidity is untouched, because during drying that reading falling is the whole point, and HeatOutOfControl is kept out of the active set, since an AMS that has lost thermal control is exactly when the alert should still arrive. A cycle plus its cool-down outlasts a restart, so the latch is stored rather than held in memory, with clocks that jump backwards clamped on read. No new setting: the reporter was offered the opt-out checkbox they asked for and said they would not want it if the alert simply never fired during drying. - **Automatic flow-dynamics calibration left an archive and two notifications behind** — With flow dynamics calibration on, the printer lays down a pressure-advance line before the print itself and announces it over MQTT through the same print-start event a real print uses. Bambuddy archived it: a row named `auto_pa_line_calib_mode` marked as having no 3MF, sitting in among the user's actual prints, with a Print Started and a Print Completed notification for each one. The printer's other internal jobs were already skipped, but only by the `/usr/` path they carry, and the pressure-advance line carries no path at all — it arrives as a bare subtask name, so a rule that only ever looked at the filename could not see it. Internal jobs are now recognised by name as well as by path, from either field, in one place both callbacks share, matching exactly after normalising away the directory, one print-file suffix and case: "auto" and "calib" are ordinary words in a user's own filenames, and a looser rule would quietly swallow somebody's print. Suppressing the completion matters more than the noise it removes — with no archive to close, an unmatched completion is attributed to any queue item the printer finished in the last five minutes and emails its owner, so silencing only the start would have told the owner of the real print running alongside it that their job was done, early, and again for real later. The guard sits inside that branch only, so the plate-clear gate, the queue reconciliation and the SD-card cleanup all still run. Skipping the run early also drops the FTP sweep that preceded the fallback: around a hundred connections looking for a file that cannot exist, aimed at a printer in the middle of calibrating. Bed levelling no longer sends a Print Started notification either. @@ -270,12 +275,6 @@ All notable changes to Bambuddy will be documented in this file. - **Ukrainian was listed above Russian in the language picker** — Locales appear in the picker in the order they were added, but `uk` had been inserted ahead of `ru` in the import block, the resources map and the list the picker renders. Moved to the end of all three; the alphabetically sorted supported-language list already had it in the right place. Frontend-only, with no behaviour change beyond the row order. - **Setting Spoolman options over the API with a true/false value returned a server error** — `PUT /settings/spoolman` accepts a free-form body, and sending the natural JSON form for a switch — `{"spoolman_enabled": true}` rather than `{"spoolman_enabled": "true"}` — came back as a 500 with nothing useful in it. The shipped UI always sends strings, so this only affected people driving Bambuddy from a script or a Home Assistant `rest_command`, which is exactly where a real boolean is the obvious thing to send. **Root cause.** Settings are stored as text and every reader compares them as text, but the submitted value went in untouched. Deciding whether Spoolman had just been switched on called a string operation on it, which a boolean does not have; and the raw boolean was also written straight to a text column, which SQLite quietly turns into 1/0 while PostgreSQL refuses it outright — so the stored result depended on which database the install used. **Fix.** Boolean-ish settings are now converted to a canonical `true`/`false` on the way in, accepting real booleans, `1`/`0`, and the usual spellings (`True`, `yes`, `on`) case-insensitively, since this is a documented API that scripts talk to. A value with no sensible reading, such as `"banana"`, now returns a 400 naming the field instead of being stored as-is and silently treated as off. Two details are preserved deliberately: a blank value still means "use the default" for the two options that default to on, and reading a stored value stays as strict as it has always been elsewhere in the codebase, so no existing row changes meaning. Text options are checked too, so a JSON object can no longer be stored as its own printed form. One incidental improvement: a value stored as `True` by an earlier API call showed as off in the UI, which compares case-sensitively, while the backend treated it as on — canonical storage removes that disagreement. Covered by tests across the accepted spellings, the rejected values, the blank-means-default behaviour, and the read path. -### Security -- **Patched the build-time frontend dependencies flagged by `npm audit` (GHSA-rgw5-rvv9-x895, GHSA-5p4m-2wfm-xmqj, GHSA-2v37-7h3g-55p8)** — Three transitive dependencies of `eslint` and `postcss`, bumped through the existing `overrides` block. `brace-expansion` goes `^5.0.8` → `^5.0.9` for a denial-of-service via unbounded expansion: the first advisory was answered in 5.0.8 by capping the length of the combined result, but that cap covered only the accumulator the results are merged into and not the two intermediate arrays that feed it, so a small brace pattern could still exhaust the heap — fatally, and beyond the reach of a `try`/`catch` — or stall the event loop for minutes. 5.0.9 bounds both arrays as they are built. `js-yaml` goes `^4.3.0` → `^5.2.3` for quadratic CPU consumption while resolving `!!omap`; that fix was deliberately not backported to 3.x or 4.x, which is what makes this a major, so it was checked rather than assumed — `@eslint/eslintrc` calls exactly one js-yaml API, `load()`, and only on the legacy `.eslintrc.yml` path this repo does not use, and `eslint`, `vite build` and the full frontend test suite all pass on it. `nanoid` is newly pinned at `^3.3.18`, where a custom generator asked for size zero loops forever; the patch stays inside 3.x. All three are build and lint-time tooling — none is part of the shipped app, so no running Bambuddy install was exposed. The pins are needed because `npm audit fix` cannot lift a transitive of `eslint` or `postcss` on its own. -- **Took the backported React Router fix and retired the audit exception (GHSA-qwww-vcr4-c8h2)** — `react-router`/`react-router-dom` move from 7.18.1 to **7.18.2**. The RSC-mode CSRF advisory noted in 1.2.5.1 was carried as a documented, fail-closed exception in the CI audit gate, because at the time its only fix was the 8.3.0 major and `react-router-dom` has no 8.x — adopting it would have meant migrating every import to `react-router` plus a React peer bump. Upstream has since backported the patch to the 7.x line, so the pin moves and the exception is gone, leaving the gate's allowlist empty. That happened on its own rather than by anyone remembering to check: an entry only holds while the offered fix is semver-major, so the gate failed the moment the backport shipped instead of quietly carrying a now-fixable advisory. The finding was never reachable here in any case — Bambuddy is a Vite SPA using `BrowserRouter` with no RSC runtime installed. `npm audit fix --force` remains deliberately avoided; its suggested "fix" is a downgrade to 7.11.0, which reintroduces the 14 advisories older 7.x releases carry. -- **`dompurify` 3.4.12 → 3.4.13 (GHSA-55q2-fjhq-7xh7)** — Removing a hook mid-sanitisation could leave a detached subtree executable in DOMPurify's `IN_PLACE` mode, an XSS. Unlike the build-time bumps above, DOMPurify does ship in the app — it sanitises MakerWorld-supplied design summaries and project notes before they are rendered — so it is worth being explicit that this particular path was not reachable: Bambuddy registers no DOMPurify hooks and never uses `IN_PLACE`, calling only the string-returning `sanitize()` with an explicit tag and attribute allowlist. The patched release is inside the existing `^3.4.10` range, so this is a lockfile move rather than a new pin. -- **Raised the `cryptography`, `pyOpenSSL` and `aiohttp` floors so a resolve cannot pick a vulnerable-but-satisfying version (PYSEC-2026-3552, PYSEC-2026-3545/3546/3547)** — `cryptography>=48.0.1` → `>=50.0.0` and `aiohttp>=3.14.0` → `>=3.14.3`. Neither had gone stale in CI, which resolves from scratch and so was already installing the fixed releases; the floors matter for the case CI does not cover, an existing environment where `>=` is already satisfied and `pip install -r` therefore upgrades nothing. `pyOpenSSL` moves `>=26.3.0` → `>=26.4.0` for a subtler reason worth writing down: every pyOpenSSL release caps `cryptography` to a narrow window (26.3.0 permits `<50`, 26.4.0 permits `<51`), so a stale pyOpenSSL silently holds `cryptography` below its own fix line and pip cannot climb past the cap even when asked for it directly. The two floors have to move together, which the comment in `requirements.txt` now says. Bambuddy's `cryptography` surface is indirect throughout — asyncssh, pyOpenSSL, py-vapid, http_ece, pywebpush — and the 49 → 50 major was verified rather than assumed: the X.509/PKCS#7/EC/RSA entry points and pyftpdlib's `TLS_FTPHandler` all import, `ruff` is clean, and the full backend suite passes unchanged. - ## [1.2.5.1] - 2026-07-27 ### Added