Bläddra i källkod

Merge pull request #1616 from maziggy/0.2.4.5

# Bambuddy 0.2.4.5

**⚠ Upgrade Notes — Read Before Updating**

0.2.4.5 is a substantial patch release on the same 0.2.4 code base — no schema breaks beyond the documented column additions for the new API-key scopes (auto-migrated on first start, dialect-branched for SQLite and Postgres), no Docker entrypoint changes, no Vite/proxy quirks. The in-app Apply Update button in Settings → System → Updates works for Docker and for any native install already on 0.2.4.1 or later.

**Four behavior-change callouts to know about before you upgrade:**

- **API-key permission model switched from denylist to allowlist** (security fix). Two new scope flags were added — `can_manage_library` and `can_manage_inventory` — and existing keys that had `can_queue` set are auto-backfilled with both new scopes on first start so library upload and inventory write keep working for keys you'd previously created as "queue-only". A hardened "read-only" key (no `can_queue`) does **not** silently gain new writes on upgrade. Review the Settings → API Keys page after upgrade if you run integrations against specific keys.

- **External folder mounting is now disabled by default and requires explicit operator opt-in via `BAMBUDDY_EXTERNAL_ROOTS`** (security fix). If you currently use the File Manager → external folder feature (mounting host paths like a NAS share, USB drive, or `/mnt/library`), you **must** set `BAMBUDDY_EXTERNAL_ROOTS=/path/one:/path/two` in your env / `docker-compose.yml` / systemd unit before upgrading, or all mounted external folders will be rejected on next start. The variable takes a colon-separated allowlist of absolute paths; the mount route is also now gated on the admin `SETTINGS_UPDATE` scope rather than `LIBRARY_UPLOAD` because mounting host paths crosses user boundaries. Bambuddy-owned dirs (data dir, log dir, static dir, archive dir) are hardcode-rejected even if added to the allowlist. See [wiki → Docker → External library folders](https://wiki.bambuddy.cool/getting-started/docker/#external-library-folders-bambuddy_external_roots) for examples.

- **Slicer-API sidecar users: bump `BAMBU_VERSION` in `slicer-api/.env` to `02.07.01.57` and rebuild.** If you run the optional OrcaSlicer API sidecar for server-side slicing, the upstream `bambu-studio-api` image switched from the no-longer-published Fedora AppImage to the Ubuntu 22.04 AppImage. The new default in `slicer-api/.env.example` / `slicer-api/docker-compose.yml` matches the new build path. After pulling this release:

  ```
  cd slicer-api
  docker compose --profile bambu build --no-cache bambu-studio-api
  docker compose --profile bambu up -d
  ```

  Bambuddy users who don't run the slicer-API sidecar are unaffected.

- **Virtual-printer FTP passive port range widened from 50000–50100 (101 ports) to 50000–51000 (1001 ports)** for the non-proxy path. Docker bridge-mode users mapping the old range need to update to `50000-51000:50000-51000`. Docker host-mode and bare-metal users are unaffected. Proxy-mode VPs keep the old range (they pre-bind the printer-side range exactly).

Make a backup before upgrading via Settings → Backup → Create Backup. Native install with `update.sh` snapshots the database automatically and rolls back on failure. Docker and fully-manual paths don't.

### Docker

```
docker compose pull
docker compose up -d
```

`docker-compose.yml` doesn't need refreshing unless you map the VP passive FTP port range (see above) — no other entrypoint, volume, or env-var conventions changed since
 0.2.4.

### Native install — recommended path

```
sudo BRANCH=main /opt/bambuddy/install/update.sh
```

Snapshots the database first and rolls back on failure.

### Native install — manual path

```
sudo systemctl stop bambuddy
cd /opt/bambuddy
sudo -u bambuddy git fetch --prune --tags --force origin
sudo -u bambuddy git checkout main
sudo -u bambuddy git reset --hard origin/main
sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
sudo systemctl start bambuddy
```

### Windows install

0.2.4.5 ships Windows installer (#1529). Existing installs upgrade in place via the Service / Update entries on the installer; fresh installs use the new GUI installer end-to-end.

---

## Highlights

0.2.4.5 is a security-led release. The big-ticket security work — API-key permission allowlist, path-traversal hardening across the upload / import / file-write surface with a fifth CI backstop, a WebSocket auth gate and audit-driven sweep, a vitest dev-dep bump, and a PyJWT floor bump for four upstream advisories — sits alongside a Windows-first installer (#1529), Turkish (#1571) and Korean (#1587) locales, OS-aware system theme detection (#1418), and the second wave of virtual-printer hardening (cipher-pin parity on every slicer-facing TLS context #1610, multi-slicer response routing, MQTT brute-force rate-limit, sticky-keys for seven more fields).

On the fix side, the biggest reports closed in this cycle: multi-plate archives now report filament / time / cost honestly (#1593), card timestamps render in the browser's local timezone instead of UTC (#1602), the multi-run accuracy badge is suppressed when it would be apples-to-oranges (#1608), the cross-distro TLS handshake regression on hardened-policy hosts is fixed at every VP TLS context (#1610), inventory and AMS handling tightened across transparent filament (#1545), profile-only mismatch popups (#1552), per-spool weight sync (#1530 / #1459), the SpoolBuddy tare banner (#1536), and the OIDC `email` claim auto-provisioning gap (#1569). VP `_pending_files` / temp-file leaks, queue-position assignment, DELETE orphan cleanup, sync-from-db race, and slicer-options cache bounds all landed as one slicer-surface audit bundle (#1558) plus follow-ups.

---

## Security

- **API-key permission model rewritten as an allowlist (critical).** The three documented API-key scopes ("Read Status", "Manage Queue", "Control Printer") were enforced only inside the legacy `/api/v1/webhook/*` router; every other route fell through to a 17-entry admin denylist that granted any valid key access to every non-admin endpoint regardless of which scope flags were ticked. Fix: a new explicit mapping pins every non-admin permission to exactly one scope; unmapped permissions return 403 ("administrative operations") regardless of scope. Two new scope flags ship in the same release — `can_manage_library` (gates library upload / update / delete and MakerWorld import) and `can_manage_inventory` (gates inventory create / update / delete and SpoolBuddy kiosk writes). Existing keys with `can_queue` set are auto-backfilled with both new scopes; "read-only" keys don't gain new writes. A new CI test fails the build on any future permission added without a scope classification, so the surface can't silently grow again. See **Upgrade Notes** above for the migration story.

- **WebSocket auth gate.** The proactive WebSocket audit caught that `/api/v1/ws` was broadcasting every printer-status / archive / inventory event to anyone who could reach the HTTP port. The endpoint is now auth-gated like the REST surface; events broadcast only to authenticated subscribers.

- **Path-traversal hardening across the upload / import / file-write surface.** A private report against `POST /api/v1/projects/import/file` traced two attacker-controlled strings being joined to the library directory with no resolve + containment check (`linked_folders[*].name` from the request `project.json` and per-entry `zf.namelist()` paths from the ZIP itself). Concrete escalation: drop a `.pth` into the venv's `site-packages` for code execution on next restart, overwrite the JWT signing-secret file to forge an admin token, or overwrite `~/.ssh/authorized_keys` on native installs. Fix is structural: a new `safe_join_under(parent, *parts)` helper resolves both sides and asserts containment; both vectors now route through it. Adjacent fixes in the same audit: `GET /api/v1/archives/{id}/photos/{filename}` had no traversal guard and was serving arbitrary paths; `ArchiveService.attach_timelapse` accepted printer-FTP-listing-supplied filenames with `..` segments under the compromised-printer threat model. The audit sweep marked every Path-arithmetic site under `backend/app/api/routes/` AND `backend/app/services/` with an explicit `# SEC-PATH-OK` annotation or routed it through the helper; a new CI test (the fifth security backstop) AST-walks both directories and fails the build on any future unsafe-shape join that's neither helper-routed nor marker-annotated.

- **External folder mounting restructured to an opt-in allowlist + admin-only scope.** External folder mounting (host paths like a NAS share or USB drive surfaced into Bambuddy's File Manager) was previously gated on `LIBRARY_UPLOAD` (any non-admin user with that scope) and validated against a small denylist of system directories — meaning anything not on the denylist could be mounted, including the Bambuddy data directory containing other users' archives, the log directory, or arbitrary NFS / SMB mounts. The route now requires the admin `SETTINGS_UPDATE` scope and accepts only paths inside the new `BAMBUDDY_EXTERNAL_ROOTS` env-var allowlist. The default empty allowlist disables the feature outright. Bambuddy-owned directories are hardcode-rejected even if the operator adds them to the allowlist. **This is a breaking change for installs that currently use external folder mounting** — see Upgrade Notes above for the restoration path.

- **VP MQTT brute-force rate-limit.** Bambuddy's virtual printer exposes an 8-character access code on the slicer-facing MQTT port; without rate-limiting it was brute-forceable by anyone who could reach the VP's bind IP. A new per-IP sliding-window limiter (5 failures / 60 s) now blocks further CONNECTs from a failing IP for the rest of the window; successful auth clears the IP's failure history.

- **VP access codes now compared in constant time.** Both the FTP `PASS` handler and the MQTT CONNECT handler used Python's `==` operator on the 8-character access code. Closes the timing side-channel without changing the protocol surface.

- **VP FTP uploads capped at 4 GiB.** `cmd_STOR` now rejects uploads exceeding 4 GiB, deletes the partial file, and replies 426. Without the cap a runaway or malicious client could drive RSS or disk to exhaustion; 4 GiB is well above any realistic multi-plate `.gcode.3mf`.

- **vitest bumped 3.2.4 → 4.1.8** (development dependency) to pick up an upstream CVSS 9.8 advisory. No production-runtime impact, but the floor moves so fresh installs and CI pick up the fix.

- **PyJWT bumped to >=2.13.0** to pick up four upstream advisories. Audit confirmed Bambuddy's usage is unaffected by every behavioural change in 2.13.0 (HMAC empty-key reject can't trigger, OIDC decode uses raw-key path not PyJWK, `jwks_uri` is HTTPS from discovery, no `b64=false` usage, `enforce_minimum_key_length` not opted into); 229 auth/MFA/OIDC integration tests + 78 auth unit tests pass on 2.13.0; `pip-audit --strict` is clean.

- **Trivy DS-0026 silenced on `Dockerfile.test` via `HEALTHCHECK NONE`.** The test image runs `pytest` and exits — no service to probe. Adding a healthcheck would have b
een cargo-cult noise.

---

## New Features

- **Windows installer (#1529, contributed by @vmhomelab).** Installer for Windows hosts — fresh install, service install / uninstall, in-place update, and a Troubleshooting entry. Existing native Windows users can upgrade via the Update entry on the installer.

- **Turkish (`tr`) locale (#1571, contributed by @samedyuksel).** Full Turkish translation across all keys, joining the existing 9-locale set.

- **Korean (`ko`) locale (#1587, contributed by @hijae).** Full Korean translation across all keys.

- **System theme detection — sidebar toggle and Settings selector follow the OS dark/light preference (#1418, contributed by @TempleClause via PR #1501).** `ThemeMode` gains a third value `system` alongside `dark` and `light`. The provider listens to `window.matchMedia('(prefers-color-scheme: dark)')` and tracks the OS preference in real time. The sidebar toggle now cycles `dark → light → system → dark` with the icon hinting at the next stop; Settings → Appearance gained a 3-button Dark / Light / System selector. Existing users' persisted preference is untouched — anyone on `dark` or `light` stays there and simply gains an extra stop in the cycle.

- **MQTT auth rate-limit on the virtual printer.** Per-IP sliding-window limiter (5 failures / 60 s) on VP MQTT CONNECT, brute-force-resistant. See Security section above for the full description.

- **Per-slicer MQTT response routing for multi-slicer VP setups.** Pre-fix: when slicer A sent a bridge-forwarded command to a non-proxy VP bound to a target printer, the printer's response was fanned out to every connected slicer. Now responses are routed to the originating slicer only, falling back to broadcast for printer-initiated unsolicited pushes (push_status etc.) and for sequence_ids the routing map never saw. Bounded at 256 entries with FIFO eviction.

- **VP child-service readiness barrier.** Each VP child sub-service (FTP, MQTT, Bind, SSDP) now exposes a `ready` event that fires after the socket actually binds; `start_server` awaits all of them with a bounded 5 s timeout. Closes a race where `is_running` reported true while the child sockets were still binding, which a quick poll from the diagnostic route or the VP card could observe.

---

## Changes

- **Bug-report template tightened + new Area dropdown.** 170 issues have been closed `invalid` (61 of them in the last 30 days alone — roughly 1 in 5 of all closed issues), nearly always because the reporter hadn't run the in-app diagnostics or checked the documented troubleshooting page. The Connection Diagnostic checkbox, Support Package attachment, and a new "Troubleshooting steps already taken" textarea are now required. The old `Bambuddy / SpoolBuddy / Both` dropdown is replaced with two required dropdowns — `Product` and `Area` (15 options covering the actual feature surface) — and an auto-label workflow applies the matching `area:*` label on every issue open/edit.

- **VP virtual-printer FTP server streams uploads straight to disk instead of buffering.** Pre-fix: `cmd_STOR` accumulated every chunk in a list and called `write_bytes` at the end. Peak RSS for a multi-GB `.gcode.3mf` was ~2× the file size and could OOM-kill a low-memory host. The streaming rewrite writes each 64 KiB chunk inline as it arrives, bounding peak memory at one chunk regardless of total upload size. Same change adds the 4 GiB hard cap described above.

- **VP virtual-printer FTP passive port range widened from 50000–50100 to 50000–51000.** Docker bridge-mode users mapping the old range need to update — see Upgrade Notes above.

- **VP MQTT bridge sticky-keys: 7 more fields preserved across incremental pushes.** Pre-fix: a single 1 Hz incremental push (which only carries changed temps / fan / wifi_signal) wiped any field not in the sticky-keys allowlist. The cached state lost `upgrade_state`, `xcam`, `hw_switch_state`, `nozzle_diameter`, `nozzle_type`, `online`, and `ams_status` after a single tick — BambuStudio's Send pre-flight reads several of these and could refuse Send because the cached push said "unknown firmware state". Same shape as #1228 (storage indicators) and #1558 (live-progress fields). Sticky-keys carry-forward is now also a `copy.deepcopy` (was reference) so a future merge can't corrupt both copies.

- **VP target-printer DHCP IP / serial refresh now restarts proxy VPs.** Pre-fix: when a target printer's IP changed (DHCP renewal, network reconfiguration), the running proxy VP kept forwarding to the stale IP forever; the user had to manually toggle the VP to refresh. `sync_from_db` now re-evaluates the proxy target each cycle and restarts the VP when the IP or serial actually changes.

- **VP `queue_force_color_match` setting takes effect immediately.** Pre-fix: toggling the per-VP "Force exact color match" setting via the UI silently no-op'd because `sync_from_db`'s "changed" predicate didn't include the field. Now restarts the running instance on toggle.

- **VP MQTT client session errors elevated from DEBUG to WARNING.** Production never sees the message at DEBUG; legitimate slicer-side errors (TLS handshake failure, protocol violation) deserve to be visible in the default log level.

- **VP MQTT periodic status push: one-line per-minute counter per active slicer connection (#1548 follow-up).** Replaces the noisy per-tick log line with a single per-minute summary, while still surfacing connection-drop events at WARNING.

---

## Fixed

**UI / rendering**

- Print-run log, spool usage history, camera-token list, and SpoolBuddy device "last calibrated" timestamps now render in the browser's local timezone instead of UTC (#1602, reported by @maziggy, confirmed by @IndividualGhost1905 with a UTC+3 reproduction). Same shape as the #504 timezone-offset bug — four sites the #504 sweep didn't catch because they were added afterwards or were missed.

- Archive card's Print Time + accuracy badge are now consistent for multi-run / multi-plate archives (#1608). The card was showing one run's actual duration next to a whole-file estimate, producing badges like "+188%". Multi-run archives now show "Estimated 5h 6m" with no badge; single-run archives keep the badge.

- Cancelled prints have their own stats bucket and no longer drag down the Success Rate gauge (#1390 follow-up).

- Cancelled bucket icon now uses the semantic warning token (orange) instead of an unrelated palette colour (#1390 follow-up).

- Quick Stats: user-cancelled prints now have their own bucket and no longer drag down the Success Rate gauge (#1390 follow-up, reported by @IndividualGhost1905).

**Slicer / library**

- Sliced `.gcode.3mf` files now render in the 3D preview and expose a Preview-3D action in the file row (#1543, reported by @Vlado-Tarakan).

- External-folder `.gcode.3mf` files now show thumbnails, and every ingest path stores the same canonical `file_type` for sliced outputs (#1600, reported by @maziggy).

- Deleted local profiles no longer linger in the SliceModal preset dropdown; new manual "Refresh" button surfaces cloud-side deletions without waiting for the 5-minute cache (#1581, reported by @lloydcat).

- STL thumbnail noise on first generation: matplotlib cache + font_manager scan no longer log three WARNING lines on first STL upload (reported by @maziggy).

- Bulk-upload ZIPs of stub / empty STL files no longer spam the log with thousands of warnings (reported by @maziggy).

- Print filenames with FAT32-illegal characters now rejected at rename / upload / queue time instead of failing at FTP (#1540, reported by @anthonyma94).

**Archive / stats**

- Multi-plate `.gcode.3mf` archives and reprints no longer under-report filament, time, and cost — project stats and parser both fixed (#1593, reported by @needo37). 3-plate file printed plate-by-plate over 9 runs was showing 1/9th of the real material consumption.

- Source-3MF upload on "fallback" archives no longer crashes with HTTP 500 (and stops orphaning files outside the data volume) (#1531, reported by @d3nn3s08).

- Fallback archives now carry MQTT-derived filament type + colour when the 3MF can't be downloaded (#1533, reported by @JmanB52D), plus a follow-up fix for real prints (#1533 follow-up).

**Inventory / Spoolman**

- Transparent / clear filament now selectable and rendered as transparent end-to-end in the built-in inventory (#1545, reported by @Synec5, confirmed by @CMW-ISS).

- Assigning a spool no longer shows a profile-mismatch warning when only the slicer profile differs; the warning now states the AMS slot will be reconfigured (#1552, reported by @anthonyma94).

- External-spool usage is now tracked when the AMS has empty slots in between loaded ones (#1607, reported by @ahmtcnby).

- SpoolBuddy weight sync no longer silently lands on a stale local row when Spoolman is enabled (#1530, reported by @chesterakl).

- SpoolBuddy Tare status banner no longer sits at "Waiting for device..." forever (#1536, reported by @flom89).

**Virtual printer**

- Queue / Review / Archive virtual-printer modes now complete the TLS handshake on hardened-distro hosts (#1610). Real Bambu printers offer only the plain-RSA AES-GCM suites `AES256-GCM-SHA384` / `AES128-GCM-SHA256`; a system crypto policy that strips them left the slicer's ClientHello with no overlap. The #620 cipher-suite fix only patched the printer-facing context; this release patches every slicer-facing VP TLS context (bind / MQTT / proxy-server / FTP).

- VP "Send file" IP rewrite now also fires for VPs without a dedicated bind IP (#1429 follow-up, residual case confirmed by @Mape6).

- VP "Send file" no longer redirects from Bambuddy to the physical printer's SD card once the printer powers on, and the mode button labels now match the wire values stored in the DB (#1429).

- VP queue mode no longer blocks BambuStudio Send while the target printer is mid-print (#1558, reported by @phieb).

- VP `_pending_files` / temp-file leak fixed on every error path across the three file handlers.

- VP queue position now picks `MAX(position)+1` instead of hardcoded `1`; duplicate position=1 entries no longer pile up on non-empty queues.

- VP DELETE route now cleans orphan `PendingUpload` rows and the on-disk `upload_dir`; previously these accumulated.

- VP `MQTTBridge._refresh_loop` no longer leaks the raw_message_handler on exceptions in the IP-encoding branch.

- VP `sync_from_db` serialised by `asyncio.Lock`; concurrent PUT calls (browser racing the auto-save trigger) no longer race the inner start/stop.

- VP `_slicer_print_options` cache bounded at 128 entries with FIFO eviction.

- VP MQTT bridge sticky-key carry-forward now uses `copy.deepcopy`; a sticky key carried over from the previous cache no longer shares nested dicts with the next push.

- VP MQTT no longer drops idle slicer connections at exactly 60 s (#1548, reported by @hollajandro); honours client-negotiated keepalive instead of the hardcoded 60 s.

- VP diagnostic now probes both bind ports 3000 (plain) and 3002 (TLS).

- VP FTP `stop()` now awaits cancelled sessions instead of `sleep(0.1)`; a session mid-write or mid-TLS-handshake can no longer outlive the stop call.

- VP per-VP TLS certificate auto-regenerates when the shared CA is rotated.

- VP `tailscale.py::get_status` now catches `asyncio.TimeoutError`; a stuck Tailscale subprocess no longer surfaces an uncaught exception.

- VP `certificate.py` CA save uses correct parent directory; previously the CA write could fail because only the per-VP subdirectory had been created.

- VP `_extract_plate_id` failures now log at debug instead of swallowing silently; a malformed `Metadata/slice_info.config` is now traceable.

**Dispatch / prints**

- A1 no longer auto-replays the previous print after a power cycle when the library row's filename has a doubled `.gcode.3mf` (#1542). The dispatch SD-cleanup path now aligns with the upload path; doubled-extension library rows no longer leave ghost prints.

- Connected-edge reconciliation closes the missed-PRINT-COMPLETE loop that produced ghost replays on smart-plug power cycles (#1542 follow-up, reported by @vixussrl-ui).

- Paused prints no longer inflate maintenance hours (#1521, reported by @TempleClause). `track_printer_runtime` was counting both RUNNING and PAUSE states.

- Webhook printer-status / stop / cancel routes 500'd on every connected printer because the route treated the `PrinterState` dataclass as a dict (#1584).

**Cloud / auth**

- Bambu Cloud sign-in failures caused by an upstream Cloudflare challenge now surface an actionable message instead of "Invalid response from Bambu Cloud" (#1575, reported by @cliveflint).

- OIDC auto-provisioning now reads the standard `email` claim for `User.email` when `Email Claim` is set to a non-email identity claim (#1569, reported by @anderl1969).

**Notifications**

- ntfy notifications: honest User-Agent + actionable error when the server is behind a Cloudflare challenge (#1534, reported by @apizz). User-Agent is now `Bambuddy/<version>` instead of the bare httpx default.

**Maintenance**

- Custom maintenance type "documentation URL" now persists on create (#1596, reported by @BurntOutHylian — with the exact root cause pre-triaged in the issue body).

**Internal / CI**

- Path-traversal CI backstop now recognises markers on the closing-paren line (project-wide convention).

- Backend test sharded 4-way + `-n auto` for ~3.5× wall-clock speedup in CI; pip cache mount in the test image.

- Unit tests no longer re-run inside the test image (duplicated the parent CI job).

- Trivy DS-0026 silenced on `Dockerfile.test` via `HEALTHCHECK NONE` (see Security).

---

## Credits

External contributors this release: @TempleClause (#1418 / PR #1501 system theme detection), @vmhomelab (PR #1529 Windows installer), @samedyuksel (PR #1571 Turkish locale), and @hijae (PR #1587 Korean locale). 
Thank you!
MartinNYHC 1 vecka sedan
förälder
incheckning
f9c0a69c24
100 ändrade filer med 9522 tillägg och 1134 borttagningar
  1. 19 0
      .env.example
  2. 5 0
      .gitignore
  3. 3 0
      CHANGELOG.md
  4. 102 5
      SECURITY.md
  5. 8 0
      backend/app/api/routes/api_keys.py
  6. 154 35
      backend/app/api/routes/archives.py
  7. 39 15
      backend/app/api/routes/auth.py
  8. 243 59
      backend/app/api/routes/library.py
  9. 4 2
      backend/app/api/routes/maintenance.py
  10. 4 1
      backend/app/api/routes/metrics.py
  11. 65 2
      backend/app/api/routes/mfa.py
  12. 9 0
      backend/app/api/routes/print_queue.py
  13. 16 9
      backend/app/api/routes/printers.py
  14. 117 78
      backend/app/api/routes/projects.py
  15. 43 18
      backend/app/api/routes/settings.py
  16. 32 10
      backend/app/api/routes/slicer_presets.py
  17. 19 10
      backend/app/api/routes/spoolbuddy.py
  18. 9 4
      backend/app/api/routes/system.py
  19. 44 7
      backend/app/api/routes/virtual_printers.py
  20. 17 9
      backend/app/api/routes/webhook.py
  21. 78 6
      backend/app/api/routes/websocket.py
  22. 5 0
      backend/app/cli.py
  23. 387 29
      backend/app/core/auth.py
  24. 1 1
      backend/app/core/config.py
  25. 100 2
      backend/app/core/database.py
  26. 283 7
      backend/app/main.py
  27. 6 0
      backend/app/models/api_key.py
  28. 3 1
      backend/app/models/printer.py
  29. 31 5
      backend/app/models/virtual_printer.py
  30. 6 0
      backend/app/schemas/api_key.py
  31. 4 0
      backend/app/schemas/archive.py
  32. 2 2
      backend/app/schemas/settings.py
  33. 84 35
      backend/app/services/archive.py
  34. 3 16
      backend/app/services/background_dispatch.py
  35. 62 3
      backend/app/services/bambu_cloud.py
  36. 3 1
      backend/app/services/camera.py
  37. 19 2
      backend/app/services/failure_analysis.py
  38. 3 1
      backend/app/services/library_trash.py
  39. 6 2
      backend/app/services/local_backup.py
  40. 55 3
      backend/app/services/notification_service.py
  41. 2 10
      backend/app/services/print_scheduler.py
  42. 9 1
      backend/app/services/spool_tag_matcher.py
  43. 7 4
      backend/app/services/spoolman.py
  44. 3 1
      backend/app/services/spoolman_tracking.py
  45. 62 2
      backend/app/services/stl_thumbnail.py
  46. 14 2
      backend/app/services/usage_tracker.py
  47. 24 1
      backend/app/services/virtual_printer/bind_server.py
  48. 58 4
      backend/app/services/virtual_printer/certificate.py
  49. 15 2
      backend/app/services/virtual_printer/diagnostic.py
  50. 119 46
      backend/app/services/virtual_printer/ftp_server.py
  51. 197 24
      backend/app/services/virtual_printer/manager.py
  52. 169 23
      backend/app/services/virtual_printer/mqtt_bridge.py
  53. 233 13
      backend/app/services/virtual_printer/mqtt_server.py
  54. 5 0
      backend/app/services/virtual_printer/ssdp_server.py
  55. 7 2
      backend/app/services/virtual_printer/tailscale.py
  56. 71 6
      backend/app/services/virtual_printer/tcp_proxy.py
  57. 90 0
      backend/app/utils/filename.py
  58. 114 0
      backend/app/utils/safe_path.py
  59. 142 0
      backend/tests/integration/test_archives_api.py
  60. 276 2
      backend/tests/integration/test_auth_apikey_rbac.py
  61. 37 6
      backend/tests/integration/test_external_folders_api.py
  62. 55 4
      backend/tests/integration/test_library_api.py
  63. 25 0
      backend/tests/integration/test_maintenance_api.py
  64. 211 0
      backend/tests/integration/test_mfa_api.py
  65. 351 6
      backend/tests/integration/test_projects_api.py
  66. 33 0
      backend/tests/integration/test_spoolbuddy.py
  67. 25 3
      backend/tests/integration/test_system_api.py
  68. 31 18
      backend/tests/integration/test_virtual_printer_api.py
  69. 225 0
      backend/tests/integration/test_webhook_printer_status.py
  70. 146 0
      backend/tests/unit/services/test_archive_service.py
  71. 116 0
      backend/tests/unit/services/test_attach_timelapse_safe_path.py
  72. 134 2
      backend/tests/unit/services/test_bambu_cloud.py
  73. 146 0
      backend/tests/unit/services/test_notification_service.py
  74. 95 0
      backend/tests/unit/services/test_stl_thumbnail.py
  75. 281 43
      backend/tests/unit/services/test_virtual_printer.py
  76. 1 1
      backend/tests/unit/services/test_vp_diagnostic.py
  77. 251 0
      backend/tests/unit/test_archive_run_aggregation.py
  78. 293 0
      backend/tests/unit/test_fallback_archive_mqtt_filament.py
  79. 120 0
      backend/tests/unit/test_filename_validation.py
  80. 56 0
      backend/tests/unit/test_library_classify_file_type.py
  81. 160 0
      backend/tests/unit/test_library_file_type_backfill_migration.py
  82. 115 0
      backend/tests/unit/test_no_fail_open_in_auth.py
  83. 158 0
      backend/tests/unit/test_no_hardcoded_secrets.py
  84. 304 0
      backend/tests/unit/test_no_unsafe_path_joins.py
  85. 248 0
      backend/tests/unit/test_reconcile_stale_active_prints.py
  86. 226 0
      backend/tests/unit/test_route_auth_coverage.py
  87. 155 0
      backend/tests/unit/test_runtime_tracking_pause.py
  88. 124 0
      backend/tests/unit/test_safe_path.py
  89. 89 0
      backend/tests/unit/test_slicer_presets.py
  90. 152 0
      backend/tests/unit/test_usage_tracker.py
  91. 76 0
      backend/tests/unit/test_vp_certificate_rotation.py
  92. 137 0
      backend/tests/unit/test_vp_delete_cleanup.py
  93. 143 0
      backend/tests/unit/test_vp_ftp_stor.py
  94. 151 0
      backend/tests/unit/test_vp_mode_rename_migration.py
  95. 235 1
      backend/tests/unit/test_vp_mqtt_bridge.py
  96. 372 0
      backend/tests/unit/test_vp_mqtt_server.py
  97. 205 0
      backend/tests/unit/test_vp_tls_ciphers.py
  98. 19 1
      docker-compose.yml
  99. 109 524
      frontend/package-lock.json
  100. 2 2
      frontend/package.json

+ 19 - 0
.env.example

@@ -36,3 +36,22 @@ LOG_TO_FILE=true
 # also store the value separately (otherwise an encrypted backup cannot be
 # restored after key loss).
 # MFA_ENCRYPTION_KEY=
+
+# External library folders (GHSA-r2qv follow-up) — colon-separated list of
+# host paths that users are permitted to register as external library
+# folders via Settings → Library → "Add external folder".
+#
+# Empty (the default) means the external-folder feature is DISABLED:
+# attempts to register one return HTTP 400. Set this to one or more
+# absolute paths to opt in. Paths that fall inside Bambuddy's own
+# DATA_DIR / LOG_DIR / static dir are always rejected regardless of
+# this value.
+#
+# Example for a single NAS mount:
+#   BAMBUDDY_EXTERNAL_ROOTS=/mnt/nas/3d-prints
+# Example for two roots:
+#   BAMBUDDY_EXTERNAL_ROOTS=/mnt/nas/3d-prints:/srv/library
+#
+# In Docker, also bind-mount the host path into the container at the same
+# location (see docker-compose.yml for the matching volume snippet).
+# BAMBUDDY_EXTERNAL_ROOTS=

+ 5 - 0
.gitignore

@@ -62,6 +62,11 @@ node_modules/
 
 data/
 
+# Local-dev runtime caches (matplotlib MPLCONFIGDIR lands here when DATA_DIR
+# is unset, so base_dir resolves to the repo root). In Docker this sits
+# inside the /app/data volume and is already covered.
+.cache/
+
 # JWT secret file (should be in data dir, but protect project root too)
 .jwt_secret
 

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 3 - 0
CHANGELOG.md


+ 102 - 5
SECURITY.md

@@ -81,11 +81,108 @@ The following are **out of scope**:
 - Denial of service (DoS) attacks
 - Issues requiring physical access to the server
 
-## Acknowledgments
-
-We thank the following individuals for responsibly disclosing security issues:
-
-*No security issues have been reported yet.*
+## Bambuddy Security Stance
+
+The following rules apply to every PR that touches authentication,
+authorization, permission gating, secret handling, or any code that
+decides whether to allow or deny an action. They are not aspirational —
+each one is enforced by a CI test that fails the build on violation.
+
+### 1. Default-deny, allowlist over denylist
+
+At any security boundary, the safe default is to deny and the
+exceptions are listed explicitly. Denylists fail open on growth — every
+new resource added to the codebase is implicitly granted access until
+someone remembers to deny it. Allowlists fail closed: an unmapped new
+resource gets a 403, which is loud and recoverable.
+
+Concretely:
+
+- `_APIKEY_SCOPE_BY_PERMISSION` in `backend/app/core/auth.py` is the
+  load-bearing API-key authorization map. Every `Permission` enum value
+  must be either present here with a scope flag, or present in
+  `_APIKEY_DENIED_PERMISSIONS`. Unmapped permissions return 403.
+- Route auth dependencies are explicit, not implicit. A route without a
+  `Depends(require_*)` decorator must be listed in the route-audit
+  `PUBLIC_ROUTES` allowlist with a justification comment, or CI fails.
+
+### 2. Fail-closed in auth code
+
+No `except Exception:` (or bare `except:`) in authentication,
+authorization, or permission code may return a permissive value
+(`None`, `True`, an admin user, an empty filter that lets everything
+through, etc.). The catch-all either re-raises or returns a denial.
+This is CWE-636 "Not Failing Securely" — see
+<https://cwe.mitre.org/data/definitions/636.html>.
+
+The lint scope is `backend/app/core/auth.py`,
+`backend/app/core/permissions.py`,
+`backend/app/api/routes/auth*.py`. Any `except Exception:` block in
+those files must be tagged `# SEC-AUTH-EXC: <reason>` on the same
+line; CI fails otherwise. (We use a standalone marker rather than
+`# noqa: ...` because ruff reserves the latter syntax for its own
+error codes.)
+
+### 3. No hardcoded fallback secrets
+
+Production secrets (JWT signing keys, encryption keys, OAuth client
+secrets, API tokens) have no string-literal fallback in source. The
+codebase reads them from env vars or generates them on first run; if a
+secret is missing AND cannot be generated, the app refuses to start
+rather than booting with a known value. CI greps the source for
+`-change-in-production`-shaped strings and fails on any hit.
+
+### 4. Negative-path tests required for any auth change
+
+Any PR that adds or modifies an auth dependency, permission check, or
+scope flag includes tests for the negative paths:
+
+- "No credentials → 401"
+- "Wrong credentials → 401"
+- "Right credentials, wrong scope → 403"
+- "Expired / revoked credentials → 401"
+
+A test asserting the happy path passes is necessary but not sufficient.
+The failure modes are where the vulnerabilities live. The structural
+backstops above catch *categories* of regression; the negative-path
+tests catch *specific* regressions in the new code.
+
+### 5. Path joins under a trusted parent use the safe-join helper
+
+Anywhere a Bambuddy code path joins a string from outside the function's
+scope (request body, query/path param, `UploadFile.filename`, ZIP
+`namelist()` entry, tarfile member, **printer FTP-listing entry**) under
+a trusted directory, the join must route through
+`backend.app.utils.safe_path.safe_join_under(parent, *parts)`. The helper
+resolves the joined path and asserts it is a descendant of the parent —
+defeating both absolute-path collapse (`Path("/a") / "/b"` → `Path("/b")`)
+and `..` traversal.
+
+Sites that have an inline guard (an explicit resolve + `is_relative_to`,
+a basename-stripping helper like `_safe_filename`, or a pre-validated
+alphanumeric filter) carry a `# SEC-PATH-OK: <reason>` marker on the
+same line. CI walks **both** `backend/app/api/routes/` and
+`backend/app/services/` and fails the build on any
+``<dir-like> / <variable>`` join without either the helper or the
+marker. The services layer is in scope because it receives values from
+the routes verbatim and from external sources Bambuddy has no control
+over (the compromised-printer threat model: a malicious printer can
+serve crafted FTP-listing entries that flow straight into a path join).
+
+### Where these rules live in the codebase
+
+| Rule | Enforcement | Location |
+|------|-------------|----------|
+| 1. Allowlist over denylist (Permission) | `test_every_permission_has_a_classification` | `backend/tests/integration/test_auth_apikey_rbac.py` |
+| 1. Allowlist over denylist (routes) | `test_routes_have_explicit_auth_deps` | `backend/tests/unit/test_route_auth_coverage.py` |
+| 2. Fail-closed in auth code | `test_no_fail_open_in_auth_modules` | `backend/tests/unit/test_no_fail_open_in_auth.py` |
+| 3. No hardcoded fallback secrets | `test_no_hardcoded_secrets` | `backend/tests/unit/test_no_hardcoded_secrets.py` |
+| 4. Negative-path tests required | Reviewer responsibility (no automated CI gate yet) | PR review |
+| 5. Safe-join under trusted parent | `test_route_path_arithmetic_is_safe_joined_or_marked` | `backend/tests/unit/test_no_unsafe_path_joins.py` |
+
+If you are adding a CI rule, update this table. If you are removing a
+CI rule, you are removing a security backstop and the PR description
+must explain why.
 
 ---
 

+ 8 - 0
backend/app/api/routes/api_keys.py

@@ -63,6 +63,8 @@ async def create_api_key(
         can_queue=data.can_queue,
         can_control_printer=data.can_control_printer,
         can_read_status=data.can_read_status,
+        can_manage_library=data.can_manage_library,
+        can_manage_inventory=data.can_manage_inventory,
         can_access_cloud=data.can_access_cloud,
         can_update_energy_cost=data.can_update_energy_cost,
         printer_ids=data.printer_ids,
@@ -82,6 +84,8 @@ async def create_api_key(
         can_queue=api_key.can_queue,
         can_control_printer=api_key.can_control_printer,
         can_read_status=api_key.can_read_status,
+        can_manage_library=api_key.can_manage_library,
+        can_manage_inventory=api_key.can_manage_inventory,
         can_access_cloud=api_key.can_access_cloud,
         can_update_energy_cost=api_key.can_update_energy_cost,
         printer_ids=api_key.printer_ids,
@@ -131,6 +135,10 @@ async def update_api_key(
         api_key.can_control_printer = data.can_control_printer
     if data.can_read_status is not None:
         api_key.can_read_status = data.can_read_status
+    if data.can_manage_library is not None:
+        api_key.can_manage_library = data.can_manage_library
+    if data.can_manage_inventory is not None:
+        api_key.can_manage_inventory = data.can_manage_inventory
     if data.can_access_cloud is not None:
         # Same constraint as create — flipping cloud access on a legacy key
         # without an owner would be silently broken; reject at the route layer.

+ 154 - 35
backend/app/api/routes/archives.py

@@ -30,6 +30,7 @@ from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.utils.http import build_content_disposition
+from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.threemf_tools import (
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
@@ -148,7 +149,7 @@ def _apply_run_user_filter(conditions: list, created_by_id: int | None):
             conditions.append(PrintLogEntry.created_by_id == created_by_id)
 
 
-def compute_time_accuracy(archive: PrintArchive) -> dict:
+def compute_time_accuracy(archive: PrintArchive, run_aggregate: dict | None = None) -> dict:
     """Compute actual print time and accuracy for an archive.
 
     Returns dict with actual_time_seconds and time_accuracy.
@@ -156,8 +157,26 @@ def compute_time_accuracy(archive: PrintArchive) -> dict:
     - 100% = perfect estimate
     - >100% = print was faster than estimated
     - <100% = print took longer than estimated
+
+    When ``run_aggregate`` indicates the archive has more than one logged
+    run (multi-plate file printed plate-by-plate, or reprints), both
+    fields are suppressed: ``archive.started_at / completed_at`` reflect
+    the LATEST run only, while ``archive.print_time_seconds`` is the
+    whole-file estimate (post-#1593 the parser sums across plates), so
+    comparing the two describes different scopes. The card-rendering
+    frontend falls through to ``archive.print_time_seconds`` for the
+    time display and hides the badge when ``time_accuracy`` is null —
+    that's the desired "show estimate, no badge" presentation for
+    multi-run archives (#1608). Single-run archives keep the original
+    badge behaviour verbatim.
     """
-    result = {"actual_time_seconds": None, "time_accuracy": None}
+    result: dict[str, int | float | None] = {"actual_time_seconds": None, "time_accuracy": None}
+
+    # Multi-run archives: the per-run actual (started_at..completed_at on
+    # the archive row) is incommensurable with the whole-file estimate.
+    # Both fields are cleared so the card shows estimate + no badge.
+    if run_aggregate and (run_aggregate.get("run_count") or 0) > 1:
+        return result
 
     if archive.started_at and archive.completed_at and archive.status == "completed":
         actual_seconds = int((archive.completed_at - archive.started_at).total_seconds())
@@ -270,8 +289,11 @@ def archive_to_response(
         "created_by_username": archive.created_by.username if archive.created_by else None,
     }
 
-    # Add computed time accuracy fields
-    accuracy_data = compute_time_accuracy(archive)
+    # Add computed time accuracy fields. ``run_aggregate`` lets
+    # ``compute_time_accuracy`` suppress the badge for multi-run archives
+    # where the per-run actual / whole-file estimate scopes don't match
+    # (#1608).
+    accuracy_data = compute_time_accuracy(archive, run_aggregate)
     data.update(accuracy_data)
 
     if run_aggregate:
@@ -579,7 +601,10 @@ async def search_archives(
         query = query.limit(limit).offset(offset)
         result = await db.execute(query)
         archives = result.scalars().all()
-        return [archive_to_response(a) for a in archives]
+        # Load run aggregates so multi-run archives' time/accuracy badge is
+        # suppressed consistently with the main list endpoint (#1608).
+        run_aggregates = await _load_run_aggregates(db, [a.id for a in archives])
+        return [archive_to_response(a, run_aggregate=run_aggregates.get(a.id)) for a in archives]
 
     if not matched_ids:
         return []
@@ -606,7 +631,10 @@ async def search_archives(
     ordered_archives = [archives_dict[id] for id in matched_ids if id in archives_dict]
     paginated = ordered_archives[offset : offset + limit]
 
-    return [archive_to_response(a) for a in paginated]
+    # Load run aggregates so multi-run archives' time/accuracy badge is
+    # suppressed consistently with the main list endpoint (#1608).
+    run_aggregates = await _load_run_aggregates(db, [a.id for a in paginated])
+    return [archive_to_response(a, run_aggregate=run_aggregates.get(a.id)) for a in paginated]
 
 
 @router.post("/search/rebuild-index")
@@ -865,10 +893,23 @@ async def get_archive_stats(
     successful_prints = successful_result.scalar() or 0
 
     failed_result = await db.execute(
-        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status == "failed", *base_conditions)
+        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status.in_(("failed", "aborted")), *base_conditions)
     )
     failed_prints = failed_result.scalar() or 0
 
+    # User/system-stopped prints — stopped/cancelled/skipped are distinct from
+    # quality failures: the user (or the queue) interrupted them, the printer
+    # didn't detect a fault. Bucketed separately so the Success Rate gauge
+    # divides by completed + failed only (a cancelled print shouldn't drag
+    # the gauge down), while still being visible in the breakdown so they
+    # don't silently vanish from Total Prints (#1390).
+    cancelled_result = await db.execute(
+        select(func.count(PrintLogEntry.id)).where(
+            PrintLogEntry.status.in_(("stopped", "cancelled", "skipped")), *base_conditions
+        )
+    )
+    cancelled_prints = cancelled_result.scalar() or 0
+
     # Total elapsed time — PrintLogEntry stores duration_seconds directly so we
     # can sum it server-side. Rows missing duration fall back to the slicer
     # estimate from the archive (joined for that case only).
@@ -934,6 +975,23 @@ async def get_archive_stats(
             *base_conditions,
         )
     )
+    # Accuracy is meaningful only when the estimate roughly describes the
+    # work the run actually performed. Two shapes produce wildly-off ratios
+    # that are pure noise:
+    #   - multi-plate ``.gcode.3mf`` printed plate-by-plate: each run's
+    #     actual is one plate, the archive's estimate is the sum across
+    #     plates (post-#1593 parser fix), so the ratio is roughly N×100%
+    #     for an N-plate file. Pre-fix this shape was also broken, just
+    #     less dramatically — the estimate was plate-1-only so the ratio
+    #     was meaningless rather than N×.
+    #   - manual interventions / purge waste blowing the actual far past
+    #     the estimate.
+    # Clamp to the [50%, 200%] band so the printer-level average reflects
+    # real slicer-vs-reality drift, not multi-plate accounting or one-off
+    # outliers. Single-plate archives — the case the metric is actually
+    # designed for — stay fully included.
+    _ACCURACY_BAND_LO = 50.0
+    _ACCURACY_BAND_HI = 200.0
     average_accuracy = None
     accuracy_by_printer: dict[str, float] = {}
     accuracies: list[float] = []
@@ -946,6 +1004,8 @@ async def get_archive_stats(
         if not actual_seconds or not estimate_seconds:
             continue
         accuracy = (estimate_seconds / actual_seconds) * 100
+        if accuracy < _ACCURACY_BAND_LO or accuracy > _ACCURACY_BAND_HI:
+            continue
         accuracies.append(accuracy)
         printer_key = str(run_printer_id) if run_printer_id else "unknown"
         printer_accuracies.setdefault(printer_key, []).append(accuracy)
@@ -990,6 +1050,7 @@ async def get_archive_stats(
         total_prints=total_prints,
         successful_prints=successful_prints,
         failed_prints=failed_prints,
+        cancelled_prints=cancelled_prints,
         total_print_time_hours=round(total_time, 1),
         total_filament_grams=round(total_filament, 1),
         total_cost=round(total_cost, 2),
@@ -1382,7 +1443,11 @@ async def update_archive(
     )
     archive = result.scalar_one_or_none()
 
-    return archive_to_response(archive)
+    # Load run aggregate so the time/accuracy badge stays consistent with
+    # the list / detail endpoints when the frontend re-renders the card
+    # after a PATCH (#1608).
+    run_aggregates = await _load_run_aggregates(db, [archive.id]) if archive else {}
+    return archive_to_response(archive, run_aggregate=run_aggregates.get(archive.id) if archive else None)
 
 
 @router.post("/{archive_id}/favorite", response_model=ArchiveResponse)
@@ -2359,7 +2424,7 @@ async def process_timelapse(
                 filename = f"timelapse_{archive_id}_edited"
             if not filename.endswith(".mp4"):
                 filename += ".mp4"
-            output_path = archive_dir / filename
+            output_path = archive_dir / filename  # SEC-PATH-OK: filename alnum-filtered + .. rejected above
 
         success = await processor.process(
             output_path=output_path,
@@ -2430,7 +2495,7 @@ async def upload_photo(
 
     ext = Path(file.filename).suffix.lower()
     photo_filename = f"{uuid.uuid4().hex[:8]}{ext}"
-    photo_path = photos_dir / photo_filename
+    photo_path = photos_dir / photo_filename  # SEC-PATH-OK: photo_filename = uuid.uuid4().hex[:8] + ext
 
     # Save file
     content = await file.read()
@@ -2463,8 +2528,20 @@ async def get_photo(
     if not archive:
         raise HTTPException(404, "Archive not found")
 
+    # Membership check first — UUID-generated names on upload mean any URL
+    # filename that doesn't appear here is by definition not a real photo.
+    # Mirrors the delete handler below; previously this endpoint had no
+    # membership check at all and joined `filename` straight to disk.
+    if not archive.photos or filename not in archive.photos:
+        raise HTTPException(404, "Photo not found")
+
     archive_dir = settings.base_dir / Path(archive.file_path).parent
-    photo_path = archive_dir / "photos" / filename
+    photos_dir = archive_dir / "photos"
+    # Defence-in-depth: even though the membership check above already
+    # constrains `filename` to UUID-generated names from upload, the
+    # resolve + containment check guards against future code paths that
+    # might populate `archive.photos` from a less-trusted source.
+    photo_path = safe_join_under(photos_dir, filename)
 
     if not photo_path.exists():
         raise HTTPException(404, "Photo not found")
@@ -2498,9 +2575,10 @@ async def delete_photo(
     if not archive.photos or filename not in archive.photos:
         raise HTTPException(404, "Photo not found")
 
-    # Delete file
+    # Delete file — same defence-in-depth as get_photo above.
     archive_dir = settings.base_dir / Path(archive.file_path).parent
-    photo_path = archive_dir / "photos" / filename
+    photos_dir = archive_dir / "photos"
+    photo_path = safe_join_under(photos_dir, filename)
     if photo_path.exists():
         photo_path.unlink()
 
@@ -2946,7 +3024,9 @@ async def upload_archive(
 
     # Save uploaded file temporarily — strip directory components to prevent path traversal
     safe_filename = _safe_filename(file.filename)
-    temp_path = settings.archive_dir / "temp" / safe_filename
+    temp_path = (
+        settings.archive_dir / "temp" / safe_filename
+    )  # SEC-PATH-OK: safe_filename = _safe_filename(...) basename-stripped above
     temp_path.parent.mkdir(parents=True, exist_ok=True)
 
     try:
@@ -2994,7 +3074,9 @@ async def upload_archives_bulk(
             continue
 
         safe_filename = _safe_filename(file.filename)
-        temp_path = settings.archive_dir / "temp" / safe_filename
+        temp_path = (
+            settings.archive_dir / "temp" / safe_filename
+        )  # SEC-PATH-OK: safe_filename = _safe_filename(...) basename-stripped above
         temp_path.parent.mkdir(parents=True, exist_ok=True)
 
         try:
@@ -3625,7 +3707,9 @@ async def slice_archive(
             detail="Archive has no source file to slice",
         )
 
-    src_path = Path(settings.base_dir) / src_relative
+    src_path = (
+        Path(settings.base_dir) / src_relative
+    )  # SEC-PATH-OK: src_relative is archive.source_3mf_path from DB, set by _resolve_source_3mf_path which already does resolve+relative_to containment
     if not src_path.exists():
         raise HTTPException(status_code=404, detail="Archive source file missing on disk")
 
@@ -3891,6 +3975,53 @@ async def get_project_image(
 # =============================================================================
 
 
+def _resolve_source_3mf_path(archive: PrintArchive, source_filename: str) -> Path:
+    """Resolve where to write a source 3MF for ``archive``.
+
+    Normal archives nest the source under ``<archive_file_dir>/source/``.
+    "Fallback" archives (created in main.py when MQTT reports a print start
+    but Bambuddy never saw the source 3MF — cloud / Handy / pre-existing
+    SD-card prints) carry ``file_path=""``. Joining that with ``base_dir``
+    via the ``/`` operator silently yields ``base_dir`` itself, whose parent
+    is ``base_dir.parent`` — which sent the upload to ``/app/source/`` and
+    raised a 500 on the final ``relative_to`` (#1531). Fallback archives
+    now land under ``<base_dir>/archive/no_source/<archive_id>/`` instead,
+    which stays inside the data volume and remains addressable by every
+    read site that does ``base_dir / archive.source_3mf_path``.
+
+    The resolved directory is asserted to be inside ``base_dir`` even when
+    ``archive.file_path`` is populated, so a row corrupted by an old import
+    or manual SQL edit fails with a clear 500 instead of writing outside
+    the data volume.
+    """
+    if archive.file_path:
+        archive_file = settings.base_dir / archive.file_path
+        source_dir = archive_file.parent / "source"
+    else:
+        source_dir = settings.base_dir / "archive" / "no_source" / str(archive.id)
+
+    # Containment check via resolve() — catches absolute file_path, `..`
+    # traversal, and any other shape that escapes the data volume — but we
+    # return the *literal* source_dir below. Resolving the returned path
+    # would canonicalise away a symlinked DATA_DIR (legitimate on TrueNAS /
+    # QNAP / Synology storage pools, and any `-v /symlink:/app/data`
+    # mount), which would then make the caller's
+    # ``source_path.relative_to(settings.base_dir)`` raise because the
+    # left side is canonical and the right is the symlink path.
+    try:
+        source_dir.resolve().relative_to(settings.base_dir.resolve())
+    except ValueError as exc:
+        raise HTTPException(
+            500,
+            f"Archive {archive.id} resolves to a path outside the data directory; cannot attach source.",
+        ) from exc
+
+    source_dir.mkdir(parents=True, exist_ok=True)
+    return (
+        source_dir / source_filename
+    )  # SEC-PATH-OK: callers pass _safe_filename(...) basename-stripped; source_dir resolve+relative_to checked above
+
+
 @router.post("/{archive_id}/source")
 async def upload_source_3mf(
     archive_id: int,
@@ -3907,11 +4038,9 @@ async def upload_source_3mf(
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
 
-    # Get archive directory and create source subdirectory
-    file_path = settings.base_dir / archive.file_path
-    archive_dir = file_path.parent
-    source_dir = archive_dir / "source"
-    source_dir.mkdir(exist_ok=True)
+    # Save the source 3MF file - preserve original filename, strip directory components
+    source_filename = _safe_filename(file.filename)
+    source_path = _resolve_source_3mf_path(archive, source_filename)
 
     # Delete old source file if exists
     if archive.source_3mf_path:
@@ -3919,10 +4048,6 @@ async def upload_source_3mf(
         if old_source_path.exists():
             old_source_path.unlink()
 
-    # Save the source 3MF file - preserve original filename, strip directory components
-    source_filename = _safe_filename(file.filename)
-    source_path = source_dir / source_filename
-
     content = await file.read()
     # #1401: validate zip header on source 3MF uploads too — source files
     # are uploaded for reprint and slicing, so an invalid one breaks the
@@ -4114,11 +4239,9 @@ async def upload_source_3mf_by_name(
     if not archive:
         raise HTTPException(404, f"No archive found matching '{print_name}'")
 
-    # Get archive directory and create source subdirectory
-    file_path = settings.base_dir / archive.file_path
-    archive_dir = file_path.parent
-    source_dir = archive_dir / "source"
-    source_dir.mkdir(exist_ok=True)
+    # Save the source 3MF file - preserve original filename, strip directory components
+    source_filename = safe_filename
+    source_path = _resolve_source_3mf_path(archive, source_filename)
 
     # Delete old source file if exists
     if archive.source_3mf_path:
@@ -4126,10 +4249,6 @@ async def upload_source_3mf_by_name(
         if old_source_path.exists():
             old_source_path.unlink()
 
-    # Save the source 3MF file - preserve original filename, strip directory components
-    source_filename = safe_filename
-    source_path = source_dir / source_filename
-
     content = await file.read()
     # #1401: same zip-header check as the other upload routes — the
     # match-by-name endpoint is used by slicer post-processing scripts,
@@ -4215,7 +4334,7 @@ async def upload_f3d(
 
     # Save the F3D file - preserve original filename, strip directory components
     f3d_filename = _safe_filename(file.filename)
-    f3d_path = f3d_dir / f3d_filename
+    f3d_path = f3d_dir / f3d_filename  # SEC-PATH-OK: f3d_filename = _safe_filename(...) basename-stripped above
 
     content = await file.read()
     f3d_path.write_bytes(content)

+ 39 - 15
backend/app/api/routes/auth.py

@@ -25,6 +25,7 @@ from backend.app.core.auth import (
     authenticate_user,
     authenticate_user_by_email,
     create_access_token,
+    create_websocket_token,
     get_current_active_user,
     get_password_hash,
     get_user_by_email,
@@ -273,7 +274,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                     db.add(admin_user)
                     logger.info("Admin user added to session: %s", request.admin_username)
                     admin_created = True
-                except Exception as e:
+                except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); no user is created on error
                     await db.rollback()
                     logger.error("Failed to create admin user: %s", e, exc_info=True)
                     raise HTTPException(
@@ -294,7 +295,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
         return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
     except HTTPException:
         raise
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); setup state stays unchanged
         logger.error("Setup error: %s", e, exc_info=True)
         await db.rollback()
         raise HTTPException(
@@ -339,7 +340,7 @@ async def disable_auth(
         await db.commit()
         logger.info("Authentication disabled by admin user: %s", user.username)
         return {"message": "Authentication disabled successfully", "auth_enabled": False}
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); auth_enabled stays at its prior value
         await db.rollback()
         logger.error("Failed to disable authentication: %s", e, exc_info=True)
         raise HTTPException(
@@ -408,7 +409,7 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
                     if user and ldap_user:
                         # Update email and group mappings on each login
                         await _sync_ldap_user(db, user, ldap_user, ldap_config)
-        except Exception as e:
+        except Exception as e:  # SEC-AUTH-EXC: LDAP failure sets ldap_user=None, downstream local-auth path runs with its own credential check (no implicit grant)
             import logging
 
             logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
@@ -505,6 +506,29 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
     )
 
 
+@router.post("/ws-token")
+async def mint_websocket_token(
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.WEBSOCKET_CONNECT),
+):
+    """Mint a short-lived token for ``/api/v1/ws`` connections (GHSA-r2qv follow-up).
+
+    The WebSocket endpoint cannot read ``Authorization`` headers from
+    browsers (the WebSocket handshake does not let JS attach custom
+    headers), so we use the same opaque-token-in-query-param pattern
+    as ``/camera/stream`` — the token is minted here behind the standard
+    permission gate, then appended as ``?token=<value>`` on the
+    ``ws://...`` URL. The WebSocket endpoint validates it *before*
+    calling ``websocket.accept()``.
+
+    Returns ``{"token": <opaque string>}``. The token is valid for 60
+    minutes; the SPA refreshes it on reconnect if expired. API keys can
+    mint tokens too — their scope flags decide whether ``WEBSOCKET_CONNECT``
+    passes via the standard allowlist (``can_read_status`` covers it).
+    """
+    username = current_user.username if current_user is not None else None
+    return {"token": await create_websocket_token(username)}
+
+
 @router.get("/me", response_model=UserResponse)
 async def get_current_user_info(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
@@ -619,7 +643,7 @@ async def logout(
                 expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
                 try:
                     await revoke_jti(jti, expires_at, username)
-                except Exception as exc:
+                except Exception as exc:  # SEC-AUTH-EXC: JTI-revoke failure on logout is logged only; logout removes access, never grants it (token stays valid until natural expiry — degraded but never escalation)
                     _logger.error("Failed to revoke JTI on logout for user %s: %s", username, exc)
         except PyJWTError:
             client_ip = _get_client_ip(raw_request)
@@ -664,7 +688,7 @@ async def test_smtp_connection(
 
         logger.info(f"Test email sent successfully to {test_request.test_recipient}")
         return TestSMTPResponse(success=True, message="Test email sent successfully")
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: SMTP test diagnostic returns success=False; no auth-relevant outcome (route is admin-gated by SETTINGS_UPDATE upstream)
         logger.error("Failed to send test email: %s", e)
         return TestSMTPResponse(success=False, message="Failed to send test email")
 
@@ -698,7 +722,7 @@ async def save_smtp_config(
         await db.commit()
         logger.info(f"SMTP settings updated by admin user: {current_user.username if current_user else 'anonymous'}")
         return {"message": "SMTP settings saved successfully"}
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); SMTP settings unchanged on error
         await db.rollback()
         logger.error("Failed to save SMTP settings: %s", e)
         raise HTTPException(
@@ -743,7 +767,7 @@ async def enable_advanced_auth(
         await db.commit()
         logger.info(f"Advanced authentication enabled by admin user: {user.username}")
         return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); advanced-auth setting unchanged on error
         await db.rollback()
         logger.error("Failed to enable advanced authentication: %s", e)
         raise HTTPException(
@@ -777,7 +801,7 @@ async def disable_advanced_auth(
         await db.commit()
         logger.info(f"Advanced authentication disabled by admin user: {user.username}")
         return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); advanced-auth setting unchanged on error
         await db.rollback()
         logger.error("Failed to disable advanced authentication: %s", e)
         raise HTTPException(
@@ -826,7 +850,7 @@ async def _send_reset_email_or_delete_token(
     try:
         send_email(smtp_settings, to_email, subject, text_body, html_body)
         _logger.info("Password reset email sent (%s) to %s", log_label, to_email)
-    except Exception as exc:
+    except Exception as exc:  # SEC-AUTH-EXC: email-send failure → defensive token cleanup so a stuck token doesn't block re-request; no access granted, just frees future workflow
         _logger.error(
             "Password reset email failed (%s) to %s — deleting token to unblock re-request: %s",
             log_label,
@@ -842,7 +866,7 @@ async def _send_reset_email_or_delete_token(
                     )
                 )
                 await db.commit()
-        except Exception as db_exc:
+        except Exception as db_exc:  # SEC-AUTH-EXC: nested cleanup failure logged only; no access decision made in this branch (already handling a prior failure)
             _logger.error("Failed to delete reset token after send failure: %s", db_exc)
 
 
@@ -967,7 +991,7 @@ async def forgot_password(
                 "forgot_password",
             )
             _logger.info("Password reset email queued for %s", user.email)
-        except Exception as e:
+        except Exception as e:  # SEC-AUTH-EXC: forgot-password response is intentionally generic regardless of outcome (user-enumeration defence); email failure does not grant access
             _logger.error("Failed to send password reset email: %s", e)
             # Don't reveal error to caller for security
 
@@ -1114,7 +1138,7 @@ async def reset_user_password(
 
         _logger.info("Admin password reset link queued for user '%s' by admin '%s'", user.username, admin_user.username)
         return ResetPasswordResponse(message=f"Password reset link sent to {user.email}")
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); reset token state unchanged on error
         await db.rollback()
         _logger.error("Failed to send admin password reset for user '%s': %s", user.username, e)
         raise HTTPException(
@@ -1354,7 +1378,7 @@ async def search_ldap_directory(
 
     try:
         results = search_ldap_users(config, query, limit=25)
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: raise 503 (fail-closed); route gated upstream by USERS_CREATE permission so detail leak is admin-only
         _logger.exception("LDAP directory search failed")
         # Admin-only endpoint — surface the underlying reason so the operator
         # can fix it (auth_middleware already restricted access to USERS_CREATE).
@@ -1430,7 +1454,7 @@ async def provision_ldap_user(
     # "username doesn't exist in the directory" in the UI.
     try:
         ldap_user = lookup_ldap_user(config, username)
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: raise 503 (fail-closed); LDAP provision never succeeds on lookup failure
         _logger.exception("LDAP lookup failed during provision")
         raise HTTPException(
             status_code=status.HTTP_503_SERVICE_UNAVAILABLE,

+ 243 - 59
backend/app/api/routes/library.py

@@ -63,7 +63,8 @@ from backend.app.schemas.library import (
 )
 from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
-from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
+from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
 from backend.app.utils.threemf_tools import (
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
@@ -90,6 +91,26 @@ def get_library_files_dir() -> Path:
     return files_dir
 
 
+def classify_file_type(filename: str) -> str:
+    """Return the canonical ``LibraryFile.file_type`` for *filename*.
+
+    Compound extensions are preserved — a `.gcode.3mf` file (a sliced
+    output, still a 3MF zip on disk) is classified ``gcode.3mf`` rather
+    than ``3mf``. Pre-#1600 this was only done in the external-scan
+    path; the upload / ZIP-extract / in-process paths all stripped to
+    the trailing extension and stored ``3mf``, so the FE had to accept
+    both. Unified here so every ingest path stores the same value and
+    downstream gates (gcode download, file-type filter, thumbnail
+    extraction) only need to handle one canonical name per file family.
+    Files with no extension classify as ``unknown``.
+    """
+    lower = filename.lower()
+    if lower.endswith(".gcode.3mf"):
+        return "gcode.3mf"
+    ext = os.path.splitext(lower)[1]
+    return ext[1:] if ext else "unknown"
+
+
 def get_library_thumbnails_dir() -> Path:
     """Get the directory for library thumbnails."""
     thumbnails_dir = get_library_dir() / "thumbnails"
@@ -218,7 +239,7 @@ def _resolve_upload_destination(target_folder: LibraryFolder | None, filename: s
             )
         # Guard against path-traversal via a pathological filename — join then
         # verify the resolved destination is still inside the external dir.
-        dest = (ext_dir / filename).resolve()
+        dest = (ext_dir / filename).resolve()  # SEC-PATH-OK: resolve + relative_to containment check on next line
         try:
             dest.relative_to(ext_dir.resolve())
         except ValueError:
@@ -301,7 +322,7 @@ def _move_file_bytes(file: LibraryFile, target_folder: LibraryFolder | None) ->
             raise _MoveSkip("target_inaccessible", f"target path not accessible: {ext_dir}")
         if not os.access(ext_dir, os.W_OK):
             raise _MoveSkip("target_unwritable", f"target path not writable: {ext_dir}")
-        dest = (ext_dir / file.filename).resolve()
+        dest = (ext_dir / file.filename).resolve()  # SEC-PATH-OK: resolve + relative_to containment check on next line
         try:
             dest.relative_to(ext_dir.resolve())
         except ValueError:
@@ -432,7 +453,9 @@ async def save_3mf_bytes_to_library(
     # extension so downstream logic (ThreeMFParser, thumbnail viewer) works.
     ext = os.path.splitext(filename)[1].lower() or ".3mf"
     unique_filename = f"{uuid.uuid4().hex}{ext}"
-    file_path = get_library_files_dir() / unique_filename
+    file_path = (
+        get_library_files_dir() / unique_filename
+    )  # SEC-PATH-OK: unique_filename = uuid.uuid4().hex + ext, generated on the previous line
     with open(file_path, "wb") as fh:
         fh.write(file_bytes)
 
@@ -450,7 +473,7 @@ async def save_3mf_bytes_to_library(
             if thumb_data:
                 thumbs_dir = get_library_thumbnails_dir()
                 thumb_filename = f"{uuid.uuid4().hex}{thumb_ext}"
-                thumb_path = thumbs_dir / thumb_filename
+                thumb_path = thumbs_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + thumb_ext
                 with open(thumb_path, "wb") as fh:
                     fh.write(thumb_data)
                 thumbnail_path = str(thumb_path)
@@ -465,7 +488,7 @@ async def save_3mf_bytes_to_library(
         folder_id=folder_id,
         filename=filename,
         file_path=to_relative_path(file_path),
-        file_type=ext[1:] if ext else "unknown",
+        file_type=classify_file_type(filename),
         file_size=len(file_bytes),
         file_hash=file_hash,
         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
@@ -553,7 +576,7 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int
         from PIL import Image
 
         thumb_filename = f"{uuid.uuid4().hex}.png"
-        thumb_path = thumbnails_dir / thumb_filename
+        thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
 
         with Image.open(file_path) as img:
             # Convert to RGB if necessary (for PNG with transparency, etc.)
@@ -581,7 +604,9 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int
             file_size = file_path.stat().st_size
             if file_size < 500000:  # Less than 500KB
                 thumb_filename = f"{uuid.uuid4().hex}{file_path.suffix}"
-                thumb_path = thumbnails_dir / thumb_filename
+                thumb_path = (
+                    thumbnails_dir / thumb_filename
+                )  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + file_path.suffix
                 shutil.copy2(file_path, thumb_path)
                 return str(thumb_path)
         except OSError:
@@ -635,6 +660,14 @@ async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
             abs_path = to_absolute_path(stl_file.file_path)
             if not abs_path or not abs_path.exists():
                 continue
+            # Pre-skip files too small to contain even a single triangle.
+            # Bulk-uploaded ZIPs of stub STLs would otherwise trigger one
+            # trimesh.load() call + one debug log line per stub.
+            try:
+                if abs_path.stat().st_size < MIN_USABLE_STL_BYTES:
+                    continue
+            except OSError:
+                continue
             try:
                 thumb_path = generate_stl_thumbnail(abs_path, thumbnails_dir)
             except Exception as exc:  # noqa: BLE001 — never let one bad STL kill the rest
@@ -1073,20 +1106,73 @@ async def delete_folder(
 
 # ============ External Folder Endpoints ============
 
-# Blocked system directories that cannot be mounted
-_BLOCKED_PREFIXES = (
-    "/proc",
-    "/sys",
-    "/dev",
-    "/run",
-    "/boot",
-    "/sbin",
-    "/bin",
-    "/usr/sbin",
-    "/usr/bin",
-    "/lib",
-    "/etc",
-)
+# GHSA-r2qv follow-up (audit finding I1): external-folder mount path uses an
+# allowlist of operator-opted-in roots rather than the original denylist of
+# system directories. The denylist shape was fail-open-on-growth — anything
+# not enumerated (``/data`` containing other users' archives, ``/root``,
+# arbitrary NFS/SMB mounts, the Bambuddy ``LOG_DIR``) could be mounted by any
+# user with ``LIBRARY_UPLOAD``. The allowlist defaults to empty and is
+# extended via the ``BAMBUDDY_EXTERNAL_ROOTS`` env var (colon-separated
+# absolute paths). The route is additionally gated on ``SETTINGS_UPDATE``
+# (admin scope) rather than ``LIBRARY_UPLOAD`` because mounting host paths
+# is an operator-level capability that crosses user boundaries.
+
+
+# Bambuddy-owned data directories. Hardcode-rejected even if the operator
+# tries to add them to ``BAMBUDDY_EXTERNAL_ROOTS`` — mounting these would
+# allow reading other users' archives, log files, or the static assets path.
+def _bambuddy_reserved_roots() -> tuple[Path, ...]:
+    """Resolved Bambuddy-owned directories that may NEVER be mounted as an
+    external folder regardless of the operator's allowlist.
+
+    Resolved at call time because tests patch ``settings.base_dir`` /
+    ``settings.log_dir`` to a temp dir; resolving lazily picks up the
+    patched values rather than module-import-time values.
+    """
+    from backend.app.core.config import settings as app_settings
+
+    reserved = [app_settings.base_dir, app_settings.log_dir, app_settings.static_dir, app_settings.archive_dir]
+    return tuple(Path(p).resolve() for p in reserved if p is not None)
+
+
+def _allowed_external_roots() -> tuple[Path, ...]:
+    """Parse ``BAMBUDDY_EXTERNAL_ROOTS`` into resolved allowed roots.
+
+    Empty env var (the default) means external folders are disabled.
+    Operators opt in explicitly: ``BAMBUDDY_EXTERNAL_ROOTS=/mnt/library:/srv/3d``
+    Returns a tuple of resolved ``Path`` objects; entries that don't
+    resolve to absolute paths are silently dropped (operator error, not
+    a security boundary). Resolved lazily so tests can monkeypatch.
+    """
+    raw = os.environ.get("BAMBUDDY_EXTERNAL_ROOTS", "")
+    roots: list[Path] = []
+    for entry in raw.split(":"):
+        entry = entry.strip()
+        if not entry:
+            continue
+        try:
+            resolved = Path(entry).resolve()
+        except (OSError, RuntimeError):  # noqa: BLE001 — operator config error, not a security boundary
+            continue
+        if resolved.is_absolute():
+            roots.append(resolved)
+    return tuple(roots)
+
+
+def _path_within(child: Path, parent: Path) -> bool:
+    """Return True if ``child`` is ``parent`` or any descendant.
+
+    Uses ``Path.relative_to`` semantics (raises ``ValueError`` on miss)
+    instead of string ``startswith``, which would falsely match
+    ``/data-other`` against ``/data``. ``Path.is_relative_to`` is the
+    sanctioned form on Python 3.9+; both are available here.
+    """
+    try:
+        child.relative_to(parent)
+    except ValueError:
+        return False
+    return True
+
 
 # Supported file extensions for external folder scanning
 _SCANNABLE_EXTENSIONS = {
@@ -1107,15 +1193,53 @@ _SCANNABLE_EXTENSIONS = {
 
 
 def _validate_external_path(path_str: str) -> Path:
-    """Validate an external path is safe to mount."""
+    """Validate an external path is safe to mount.
+
+    Allowlist semantics:
+    1. Path must be absolute and resolve cleanly (symlink-escape rejected
+       implicitly by the resolved-startswith check below).
+    2. Path must fall under one of the roots enumerated in
+       ``BAMBUDDY_EXTERNAL_ROOTS``; empty allowlist (the default)
+       means external folders are not available on this deployment.
+    3. Path must NOT fall under any Bambuddy-owned directory (``base_dir``,
+       ``log_dir``, ``static_dir``, ``archive_dir``) — the reserved set
+       takes precedence over the allowlist, so an operator who accidentally
+       sets ``BAMBUDDY_EXTERNAL_ROOTS=/`` does not expose ``/data``.
+    4. Existence + directory-type + readability gates remain.
+    """
     path = Path(path_str).resolve()
 
     if not path.is_absolute():
         raise HTTPException(status_code=400, detail="Path must be absolute")
 
-    for prefix in _BLOCKED_PREFIXES:
-        if str(path).startswith(prefix):
-            raise HTTPException(status_code=400, detail=f"Cannot mount system directory: {prefix}")
+    allowed_roots = _allowed_external_roots()
+    if not allowed_roots:
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                "External folders are not enabled on this deployment. Ask the "
+                "operator to set BAMBUDDY_EXTERNAL_ROOTS=<colon-separated paths>."
+            ),
+        )
+
+    # Reserved (Bambuddy-owned) paths are rejected before the allowlist check
+    # so an over-broad allowlist (e.g. operator set "/" for testing) cannot
+    # expose Bambuddy's own data dir or log dir.
+    for reserved in _bambuddy_reserved_roots():
+        if _path_within(path, reserved):
+            raise HTTPException(
+                status_code=400,
+                detail=f"Cannot mount Bambuddy-managed directory: {reserved}",
+            )
+
+    if not any(_path_within(path, root) for root in allowed_roots):
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                f"Path '{path}' is not within an allowed external root. "
+                f"Allowed roots: {', '.join(str(r) for r in allowed_roots)}"
+            ),
+        )
 
     if not path.exists():
         raise HTTPException(status_code=400, detail=f"Path does not exist: {path}")
@@ -1134,7 +1258,14 @@ def _validate_external_path(path_str: str) -> Path:
 async def create_external_folder(
     data: ExternalFolderCreate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+    # GHSA-r2qv follow-up (I1): elevated from LIBRARY_UPLOAD to SETTINGS_UPDATE.
+    # Registering a host filesystem path as a Bambuddy library folder is an
+    # operator-level capability that crosses user boundaries (one user's
+    # registered external folder is visible to every other user via
+    # /api/v1/library/folders). LIBRARY_UPLOAD was always the wrong scope —
+    # SETTINGS_UPDATE is the admin-class gate that already protects every
+    # other host-affecting setting (SMTP, LDAP, cloud, smart plugs).
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.SETTINGS_UPDATE)),
 ):
     """Create an external folder that points to a host directory."""
     resolved = _validate_external_path(data.external_path)
@@ -1294,7 +1425,9 @@ async def scan_external_folder(
                             name=part,
                             parent_id=current_parent,
                             is_external=True,
-                            external_path=str(ext_path / current_path),
+                            external_path=str(
+                                ext_path / current_path
+                            ),  # SEC-PATH-OK: current_path built from Path(rel_dir).parts of an os.walk descent under ext_path
                             external_readonly=folder.external_readonly,
                             external_show_hidden=folder.external_show_hidden,
                         )
@@ -1310,7 +1443,9 @@ async def scan_external_folder(
             if not folder.external_show_hidden and filename.startswith("."):
                 continue
 
-            filepath = Path(dirpath) / filename
+            filepath = (
+                Path(dirpath) / filename
+            )  # SEC-PATH-OK: dirpath + filename from os.walk(ext_path); filesystem-discovered, not user input
             ext = filepath.suffix.lower()
 
             # Check for compound extensions like .gcode.3mf
@@ -1339,17 +1474,16 @@ async def scan_external_folder(
             except OSError:
                 continue
 
-            file_type = ext[1:] if ext else "unknown"
-            # For compound extensions, use the meaningful part
-            if file_type in ("3mf",) and len(filepath.suffixes) >= 2:
-                inner = filepath.suffixes[-2].lower()
-                if inner == ".gcode":
-                    file_type = "gcode.3mf"
+            file_type = classify_file_type(filename)
 
-            # Extract thumbnail for 3mf files
+            # Extract thumbnail for 3mf files (including .gcode.3mf sliced
+            # outputs — those are 3MF zips on disk and carry the same
+            # thumbnail Metadata/plate_1.png the parser reads). Pre-#1600
+            # the gate was `file_type == "3mf"` alone, so .gcode.3mf files
+            # in external folders silently got no thumbnail.
             thumbnail_path = None
             file_metadata = None
-            if file_type == "3mf":
+            if file_type in ("3mf", "gcode.3mf"):
                 try:
                     parser = ThreeMFParser(str(filepath))
                     raw_metadata = parser.parse()
@@ -1360,7 +1494,9 @@ async def scan_external_folder(
                         if thumb_data:
                             thumb_dir = get_library_thumbnails_dir()
                             thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}"
-                            thumb_full = thumb_dir / thumb_filename
+                            thumb_full = (
+                                thumb_dir / thumb_filename
+                            )  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + thumbnail_ext
                             thumb_full.write_bytes(thumb_data)
                             thumbnail_path = to_relative_path(thumb_full)
 
@@ -1393,7 +1529,7 @@ async def scan_external_folder(
                 if thumb_data:
                     thumb_dir = get_library_thumbnails_dir()
                     thumb_filename = f"{uuid.uuid4().hex}.png"
-                    thumb_full = thumb_dir / thumb_filename
+                    thumb_full = thumb_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
                     thumb_full.write_bytes(thumb_data)
                     thumbnail_path = to_relative_path(thumb_full)
 
@@ -1577,9 +1713,17 @@ async def upload_file(
             raise HTTPException(status_code=400, detail="Filename is required")
 
         filename = file.filename
+        # Reject FAT32/exFAT-incompatible filenames up front (#1540).
+        try:
+            validate_print_filename(filename)
+        except InvalidFilenameError as e:
+            raise HTTPException(status_code=400, detail=str(e)) from e
         ext = os.path.splitext(filename)[1].lower()
-        # Handle files without extension
-        file_type = ext[1:] if ext else "unknown"
+        # `file_type` is compound-aware (`gcode.3mf` for sliced outputs).
+        # `ext` stays the trailing extension because the on-disk filename
+        # uses it directly and the 3MF-parse branch below still gates on
+        # `ext == ".3mf"`, which is correct for both `.3mf` and `.gcode.3mf`.
+        file_type = classify_file_type(filename)
 
         # Verify folder exists if specified
         target_folder = None
@@ -1633,7 +1777,9 @@ async def upload_file(
                 # Save thumbnail if extracted
                 if thumbnail_data:
                     thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}"
-                    thumb_path = thumbnails_dir / thumb_filename
+                    thumb_path = (
+                        thumbnails_dir / thumb_filename
+                    )  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + thumbnail_ext
                     with open(thumb_path, "wb") as f:
                         f.write(thumbnail_data)
                     thumbnail_path = str(thumb_path)
@@ -1662,7 +1808,9 @@ async def upload_file(
                 thumbnail_data = extract_gcode_thumbnail(file_path)
                 if thumbnail_data:
                     thumb_filename = f"{uuid.uuid4().hex}.png"
-                    thumb_path = thumbnails_dir / thumb_filename
+                    thumb_path = (
+                        thumbnails_dir / thumb_filename
+                    )  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
                     with open(thumb_path, "wb") as f:
                         f.write(thumbnail_data)
                     thumbnail_path = str(thumb_path)
@@ -1674,9 +1822,16 @@ async def upload_file(
             thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
         elif ext == ".stl":
-            # Generate STL thumbnail if enabled
+            # Generate STL thumbnail if enabled. Same MIN_USABLE_STL_BYTES
+            # pre-skip as extract_zip_file — stubs / placeholders below this
+            # size can't contain a triangle so trimesh would return an empty
+            # mesh anyway.
             if generate_stl_thumbnails:
-                thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+                try:
+                    if file_path.stat().st_size >= MIN_USABLE_STL_BYTES:
+                        thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+                except OSError:
+                    pass
 
         # Create database entry (managed files store relative paths for portability;
         # external files store the absolute mount path — same shape as scan produces)
@@ -1862,11 +2017,13 @@ async def extract_zip_file(
                     # Extract file
                     filename = os.path.basename(zip_path)
                     ext = os.path.splitext(filename)[1].lower()
-                    file_type = ext[1:] if ext else "unknown"
+                    file_type = classify_file_type(filename)
 
                     # Generate unique filename for storage
                     unique_filename = f"{uuid.uuid4().hex}{ext}"
-                    file_path = get_library_files_dir() / unique_filename
+                    file_path = (
+                        get_library_files_dir() / unique_filename
+                    )  # SEC-PATH-OK: unique_filename = uuid.uuid4().hex + ext
 
                     # Extract and save file
                     file_content = zf.read(zip_path)
@@ -1891,7 +2048,9 @@ async def extract_zip_file(
 
                             if thumbnail_data:
                                 thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}"
-                                thumb_path = thumbnails_dir / thumb_filename
+                                thumb_path = (
+                                    thumbnails_dir / thumb_filename
+                                )  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + thumbnail_ext
                                 with open(thumb_path, "wb") as f:
                                     f.write(thumbnail_data)
                                 thumbnail_path = str(thumb_path)
@@ -1918,7 +2077,9 @@ async def extract_zip_file(
                             thumbnail_data = extract_gcode_thumbnail(file_path)
                             if thumbnail_data:
                                 thumb_filename = f"{uuid.uuid4().hex}.png"
-                                thumb_path = thumbnails_dir / thumb_filename
+                                thumb_path = (
+                                    thumbnails_dir / thumb_filename
+                                )  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
                                 with open(thumb_path, "wb") as f:
                                     f.write(thumbnail_data)
                                 thumbnail_path = str(thumb_path)
@@ -1929,8 +2090,12 @@ async def extract_zip_file(
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
                     elif ext == ".stl":
-                        # Generate STL thumbnail if enabled
-                        if generate_stl_thumbnails:
+                        # Generate STL thumbnail if enabled. Pre-skip files
+                        # below MIN_USABLE_STL_BYTES — they can't contain
+                        # even a single triangle, and bulk-uploaded ZIPs of
+                        # stub STLs would otherwise log one debug line per
+                        # file via the empty-mesh branch in trimesh.load.
+                        if generate_stl_thumbnails and len(file_content) >= MIN_USABLE_STL_BYTES:
                             thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
 
                     # Create database entry (store relative paths for portability)
@@ -3436,7 +3601,7 @@ async def slice_and_persist(
     base_name = model_filename.rsplit(".", 1)[0]
     out_filename = f"{base_name}.gcode.3mf"
     unique_name = f"{uuid.uuid4().hex}.gcode.3mf"
-    out_path = get_library_files_dir() / unique_name
+    out_path = get_library_files_dir() / unique_name  # SEC-PATH-OK: unique_name = uuid.uuid4().hex + ".gcode.3mf"
     out_path.write_bytes(result.content)
 
     # Extract thumbnail from the produced 3MF so the library card shows a
@@ -3553,9 +3718,13 @@ async def slice_and_persist_as_archive(
     timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
     printer_folder = str(source_archive.printer_id) if source_archive.printer_id is not None else "unassigned"
     archive_subdir = f"{timestamp}_{base_name}_sliced"
-    archive_dir = app_settings.archive_dir / printer_folder / archive_subdir
+    archive_dir = (
+        app_settings.archive_dir / printer_folder / archive_subdir
+    )  # SEC-PATH-OK: printer_folder = str(int|None), archive_subdir = f"{timestamp}_{base_name}_sliced" where base_name went through _safe_filename
     archive_dir.mkdir(parents=True, exist_ok=True)
-    out_path = archive_dir / out_filename
+    out_path = (
+        archive_dir / out_filename
+    )  # SEC-PATH-OK: out_filename = f"{base_name}.gcode.3mf" where base_name went through _safe_filename
     out_path.write_bytes(result.content)
 
     # Extract a thumbnail for the new archive card. Priority order:
@@ -3830,6 +3999,15 @@ async def print_library_file(
             detail="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.",
         )
 
+    # Filenames containing FAT32/exFAT-illegal characters would 553 at
+    # FTP upload time (#1540). Older rows may pre-date the rename-time
+    # validation, so reject the print attempt with an actionable message
+    # rather than silently renaming user data.
+    try:
+        validate_print_filename(lib_file.filename)
+    except InvalidFilenameError as e:
+        raise HTTPException(status_code=400, detail=str(e)) from e
+
     # Get the full file path
     file_path = Path(app_settings.base_dir) / lib_file.file_path
 
@@ -4007,9 +4185,13 @@ async def update_file(
             raise HTTPException(status_code=403, detail="You can only update your own files")
 
     if data.filename is not None:
-        # Validate filename doesn't contain path separators
-        if "/" in data.filename or "\\" in data.filename:
-            raise HTTPException(status_code=400, detail="Filename cannot contain path separators")
+        # Bambu printer SD cards are FAT32/exFAT; reject the same set Bambu
+        # Studio refuses on save so we fail here with a clear message
+        # instead of an obscure FTP 553 at print time (#1540).
+        try:
+            validate_print_filename(data.filename)
+        except InvalidFilenameError as e:
+            raise HTTPException(status_code=400, detail=str(e)) from e
         file.filename = data.filename
         # No print_name to keep in sync — library files display by filename,
         # and _without_print_name strips the embedded 3MF Title on import (#1489).
@@ -4227,8 +4409,10 @@ async def get_gcode(
 
     if file.file_type == "gcode":
         return FastAPIFileResponse(str(abs_path), media_type="text/plain")
-    elif file.file_type == "3mf":
-        # Extract gcode from 3mf
+    elif file.file_type in ("3mf", "gcode.3mf"):
+        # Extract gcode from 3mf zip container. `.gcode.3mf` sliced outputs
+        # carry the same `Metadata/plate_*.gcode` entries as a `.3mf`, so
+        # the unzip path is identical — just had to expand the gate.
         try:
             with zipfile.ZipFile(str(abs_path), "r") as zf:
                 # Find gcode file

+ 4 - 2
backend/app/api/routes/maintenance.py

@@ -126,7 +126,8 @@ async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
     """Calculate total active hours for a printer from runtime counter plus offset.
 
     Uses the runtime_seconds counter which tracks actual machine active time
-    (RUNNING and PAUSE states), including calibration, heating, and printing.
+    (RUNNING state only — paused time is excluded since maintenance intervals
+    measure mechanical wear, not wall-clock active time, see #1521).
     """
     # Get printer runtime and offset
     result = await db.execute(
@@ -208,6 +209,7 @@ async def create_maintenance_type(
         default_interval_hours=data.default_interval_hours,
         interval_type=data.interval_type,
         icon=data.icon,
+        wiki_url=data.wiki_url,
         is_system=False,
     )
     db.add(new_type)
@@ -714,7 +716,7 @@ async def set_printer_hours(
 
     The offset is calculated as: offset = total_hours - runtime_hours
     Where runtime_hours comes from the runtime_seconds counter that tracks
-    actual machine active time (RUNNING/PAUSE states).
+    actual machine active time (RUNNING state only — paused time excluded, #1521).
     """
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))

+ 4 - 1
backend/app/api/routes/metrics.py

@@ -1,6 +1,7 @@
 """Prometheus metrics endpoint for external monitoring."""
 
 import platform
+import secrets
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Response
 from sqlalchemy import func, select
@@ -75,7 +76,9 @@ async def get_metrics(
         if not authorization.startswith("Bearer "):
             raise HTTPException(status_code=401, detail="Bearer token required")
         provided_token = authorization[7:]  # Remove "Bearer " prefix
-        if provided_token != token:
+        # Constant-time comparison closes the byte-by-byte timing oracle that
+        # plain ``!=`` opens on a LAN-attached attacker (audit finding I2).
+        if not secrets.compare_digest(provided_token.encode("utf-8"), token.encode("utf-8")):
             raise HTTPException(status_code=401, detail="Invalid token")
 
     lines: list[str] = []

+ 65 - 2
backend/app/api/routes/mfa.py

@@ -562,6 +562,57 @@ def _resolve_provider_email(provider: OIDCProvider, claims: dict, provider_sub:
     return raw_email
 
 
+def _resolve_standard_email_for_user_record(provider: OIDCProvider, claims: dict, provider_sub: str) -> str | None:
+    """Resolve the standard 'email' claim for populating a newly-created User.email.
+
+    Issue #1569: when an operator sets email_claim to a non-email identity claim
+    (e.g. preferred_username on Authentik), the primary _resolve_provider_email
+    returns None because the identity value isn't email-shaped. This helper lets
+    the auto-create-users path still capture the user's real email from the
+    standard 'email' claim that the IdP usually sends alongside.
+
+    This is NOT a substitute for _resolve_provider_email and does NOT feed
+    auto_link_existing_accounts — that gate stays on the primary resolver, so
+    the GHSA Fall-B/C security guards remain intact.
+
+    Applies the same Fall A/B shape + email_verified logic as the primary
+    resolver does for the standard 'email' claim.
+    """
+    raw_claim_value = claims.get("email")
+    if raw_claim_value is not None and not isinstance(raw_claim_value, str):
+        logger.warning(
+            "OIDC provider %d: standard 'email' claim has unexpected type %s for sub=%r, ignoring",
+            provider.id,
+            type(raw_claim_value).__name__,
+            provider_sub,
+        )
+        return None
+    raw_email = raw_claim_value.lower().strip() if raw_claim_value else None
+    if not raw_email:
+        return None
+    if not _is_valid_email_shaped(raw_email):
+        logger.warning(
+            "OIDC provider %d: standard 'email' claim failed shape check for sub=%r, ignoring",
+            provider.id,
+            provider_sub,
+        )
+        return None
+    email_verified = claims.get("email_verified")
+    if provider.require_email_verified:
+        if email_verified is True:
+            return raw_email
+        logger.info(
+            "OIDC provider %d: ignoring fallback email for sub=%r because email_verified=%r",
+            provider.id,
+            provider_sub,
+            email_verified,
+        )
+        return None
+    if email_verified is False:
+        return None
+    return raw_email
+
+
 # ---------------------------------------------------------------------------
 # Settings helpers (email 2FA flag)
 # ---------------------------------------------------------------------------
@@ -1905,6 +1956,18 @@ async def oidc_callback(
                             raw = provider_sub[:30]
                     candidate = re.sub(r"[^a-zA-Z0-9._-]", "", raw)[:30] or "oidcuser"
 
+                    # Issue #1569: when email_claim is configured to a non-email
+                    # identity claim (e.g. preferred_username on Authentik), the
+                    # primary resolver returns None for the email field because the
+                    # identity value isn't email-shaped. Fall back to the standard
+                    # 'email' claim for User.email so the operator can split
+                    # username-from-preferred_username and email-from-email.
+                    # The auto-link gate above stays on provider_email, so the
+                    # GHSA Fall-B/C guards remain intact.
+                    user_email_for_storage = provider_email
+                    if user_email_for_storage is None and provider.email_claim != "email":
+                        user_email_for_storage = _resolve_standard_email_for_user_record(provider, claims, provider_sub)
+
                     username = candidate
                     counter = 1
                     while True:
@@ -1932,7 +1995,7 @@ async def oidc_callback(
 
                     new_user = User(
                         username=username,
-                        email=provider_email,
+                        email=user_email_for_storage,
                         # M-1: auth_source="oidc" prevents local password-reset flow
                         # for users who should only authenticate via OIDC.
                         auth_source="oidc",
@@ -1949,7 +2012,7 @@ async def oidc_callback(
                             user_id=new_user.id,
                             provider_id=provider_id,
                             provider_user_id=provider_sub,
-                            provider_email=provider_email,
+                            provider_email=user_email_for_storage,
                         )
                     )
                     await db.commit()

+ 9 - 0
backend/app/api/routes/print_queue.py

@@ -401,6 +401,15 @@ async def add_to_queue(
         library_file = result.scalar_one_or_none()
         if not library_file:
             raise HTTPException(400, "Library file not found")
+        # Bambu SD card is FAT32/exFAT — illegal filename chars would 553 at
+        # FTP upload time (#1540). Reject at queue time so the user gets the
+        # actionable error before waiting in queue.
+        from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
+
+        try:
+            validate_print_filename(library_file.filename)
+        except InvalidFilenameError as e:
+            raise HTTPException(400, str(e)) from e
 
     # Extract filament types for model-based assignment (used by scheduler for validation)
     required_filament_types = None

+ 16 - 9
backend/app/api/routes/printers.py

@@ -182,14 +182,19 @@ async def get_available_filaments(
                 tray_type = tray.get("tray_type")
                 if not tray_type:
                     continue
-                tray_color = tray.get("tray_color", "")
-                # Normalize color: remove alpha, add hash
-                hex_color = tray_color.replace("#", "")[:6] if tray_color else "808080"
-                color = f"#{hex_color}"
+                tray_color = tray.get("tray_color", "") or "808080"
+                # Preserve the full RRGGBBAA so transparent filament (alpha=00)
+                # reaches the frontend instead of collapsing to #000000 → black
+                # (#1545). Opaque colours still round-trip as #RRGGBB. The
+                # dedup key uses the 6-char RGB so two slots that share an RGB
+                # but differ only in alpha still merge.
+                stripped = tray_color.replace("#", "")
+                rgb = stripped[:6].lower() or "808080"
+                color = f"#{stripped}"
                 tray_info_idx = tray.get("tray_info_idx", "")
                 tray_sub_brands = tray.get("tray_sub_brands", "") or ""
 
-                key = (tray_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)
+                key = (tray_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
                 if key not in seen:
                     seen.add(key)
                     filaments.append(
@@ -207,15 +212,17 @@ async def get_available_filaments(
             vt_type = vt.get("tray_type")
             if not vt_type:
                 continue
-            vt_color = vt.get("tray_color", "")
-            hex_color = vt_color.replace("#", "")[:6] if vt_color else "808080"
-            color = f"#{hex_color}"
+            vt_color = vt.get("tray_color", "") or "808080"
+            # Same alpha-preserving handling as the AMS branch — see #1545.
+            stripped = vt_color.replace("#", "")
+            rgb = stripped[:6].lower() or "808080"
+            color = f"#{stripped}"
             tray_info_idx = vt.get("tray_info_idx", "")
             tray_sub_brands = vt.get("tray_sub_brands", "") or ""
             vt_id = int(vt.get("id", 254))
             extruder_id = (255 - vt_id) if ams_extruder_map else None
 
-            key = (vt_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)
+            key = (vt_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
             if key not in seen:
                 seen.add(key)
                 filaments.append(

+ 117 - 78
backend/app/api/routes/projects.py

@@ -20,6 +20,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
@@ -41,46 +42,73 @@ from backend.app.schemas.project import (
     TimelineEvent,
 )
 from backend.app.utils.http import build_content_disposition
+from backend.app.utils.safe_path import safe_join_under
 
 logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/projects", tags=["projects"])
 
 
+_FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
+
+
 async def compute_project_stats(
     db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
 ) -> ProjectStats:
-    """Compute statistics for a project."""
-    # Count total archives (distinct print jobs)
-    total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
-    total_archives = total_result.scalar() or 0
-
-    # Sum total items (using quantity field)
-    total_items_result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id)
-    )
-    total_items = total_items_result.scalar() or 0
-
-    # Count failed archives (number of print jobs) - includes all failure states
-    failed_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(
-            PrintArchive.project_id == project_id,
-            PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
+    """Compute statistics for a project.
+
+    Aggregates from ``print_log_entries`` joined to ``print_archives`` so
+    every actual run contributes — pre-fix this counted ``print_archives``
+    (one row per file), which under-reported every reprint by collapsing
+    runs back into the source file (#1593). The Archive Print Log view
+    already drives off the same source (``archives.py::list_archives_slim``),
+    so project stats now stay aligned with the per-archive numbers.
+
+    Orphan log entries (``archive_id IS NULL`` after archive deletion via
+    ``ON DELETE SET NULL``) are excluded by the inner join — they can't
+    be attributed to a project.
+    """
+    # Per-run aggregates from print_log_entries joined on archive_id so
+    # the WHERE filters by archives.project_id. Each run's duration,
+    # filament, cost, and energy come from the log row, not the source
+    # archive — so multi-plate 3MFs and reprints both count correctly.
+    log_stats_result = await db.execute(
+        select(
+            func.count(PrintLogEntry.id).label("total_runs"),
+            func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0).label("total_time"),
+            func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
+            func.coalesce(func.sum(PrintLogEntry.cost), 0).label("total_filament_cost"),
+            func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"),
+            func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
         )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(PrintArchive.project_id == project_id)
     )
-    failed_prints = failed_result.scalar() or 0
+    log_stats = log_stats_result.first()
+    total_archives = int(log_stats.total_runs or 0)
 
-    # Sum print time, filament, and energy
-    sums_result = await db.execute(
+    # Total items the project has produced or attempted: sum of quantity
+    # per run (each run contributes its archive's quantity). The total/
+    # completed/failed splits are all per-run, not per-file.
+    items_split_result = await db.execute(
         select(
-            func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
-            func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
-            func.coalesce(func.sum(PrintArchive.cost), 0).label("total_filament_cost"),
-            func.coalesce(func.sum(PrintArchive.energy_kwh), 0).label("total_energy"),
-            func.coalesce(func.sum(PrintArchive.energy_cost), 0).label("total_energy_cost"),
-        ).where(PrintArchive.project_id == project_id)
+            func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
+            func.coalesce(
+                func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
+                0,
+            ).label("completed_items"),
+            func.coalesce(
+                func.sum(case((PrintLogEntry.status.in_(_FAILURE_STATUSES), 1), else_=0)),
+                0,
+            ).label("failed_runs"),
+        )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(PrintArchive.project_id == project_id)
     )
-    sums = sums_result.first()
+    items_split = items_split_result.first()
+    total_items = int(items_split.total_items or 0)
+    completed_items = int(items_split.completed_items or 0)
+    failed_prints = int(items_split.failed_runs or 0)
 
     # Count queued items
     queued_result = await db.execute(
@@ -98,15 +126,6 @@ async def compute_project_stats(
     )
     in_progress_prints = in_progress_result.scalar() or 0
 
-    # Sum completed items (parts) - sum of quantities for actually printed jobs
-    completed_items_result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-            PrintArchive.project_id == project_id,
-            PrintArchive.status == "completed",
-        )
-    )
-    completed_items = int(completed_items_result.scalar() or 0)
-
     # Calculate progress for plates (target_count vs total_archives)
     progress_percent = None
     remaining_prints = None
@@ -140,13 +159,13 @@ async def compute_project_stats(
         failed_prints=int(failed_prints),
         queued_prints=queued_prints,
         in_progress_prints=in_progress_prints,
-        total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
-        total_filament_grams=round(sums.total_filament or 0, 2),
+        total_print_time_hours=round((log_stats.total_time or 0) / 3600, 2),
+        total_filament_grams=round(log_stats.total_filament or 0, 2),
         progress_percent=progress_percent,
         parts_progress_percent=parts_progress_percent,
-        estimated_cost=round((sums.total_filament_cost or 0), 2),
-        total_energy_kwh=round((sums.total_energy or 0), 3),
-        total_energy_cost=round((sums.total_energy_cost or 0), 3),
+        estimated_cost=round((log_stats.total_filament_cost or 0), 2),
+        total_energy_kwh=round((log_stats.total_energy or 0), 3),
+        total_energy_cost=round((log_stats.total_energy_cost or 0), 3),
         remaining_prints=remaining_prints,
         remaining_parts=remaining_parts,
         bom_total_items=bom_stats.total or 0,
@@ -171,20 +190,34 @@ async def list_projects(
     result = await db.execute(query)
     projects = result.scalars().all()
 
-    # Compute quick stats for each project
+    # Compute quick stats for each project. Same per-run aggregation as
+    # ``compute_project_stats`` — counts and quantities come from
+    # ``print_log_entries`` joined to ``print_archives`` so reprints and
+    # multi-plate prints contribute every run, not just the source file
+    # (#1593). Quick stats and the full stats endpoint must agree.
     response = []
     for project in projects:
-        # Get archive count (number of print jobs)
-        archive_count_result = await db.execute(
-            select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
-        )
-        archive_count = archive_count_result.scalar() or 0
-
-        # Get total items (sum of quantities)
-        total_items_result = await db.execute(
-            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id)
+        log_quick_result = await db.execute(
+            select(
+                func.count(PrintLogEntry.id).label("archive_count"),
+                func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
+                func.coalesce(
+                    func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
+                    0,
+                ).label("completed_count"),
+                func.coalesce(
+                    func.sum(case((PrintLogEntry.status.in_(_FAILURE_STATUSES), 1), else_=0)),
+                    0,
+                ).label("failed_count"),
+            )
+            .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+            .where(PrintArchive.project_id == project.id)
         )
-        total_items = int(total_items_result.scalar() or 0)
+        log_quick = log_quick_result.first()
+        archive_count = int(log_quick.archive_count or 0)
+        total_items = int(log_quick.total_items or 0)
+        completed_count = int(log_quick.completed_count or 0)
+        failed_count = int(log_quick.failed_count or 0)
 
         # Get queue count
         queue_count_result = await db.execute(
@@ -195,24 +228,6 @@ async def list_projects(
         )
         queue_count = queue_count_result.scalar() or 0
 
-        # Sum completed parts (quantities) - only actually printed jobs
-        completed_result = await db.execute(
-            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-                PrintArchive.project_id == project.id,
-                PrintArchive.status == "completed",
-            )
-        )
-        completed_count = int(completed_result.scalar() or 0)
-
-        # Sum failed parts (quantities) - includes all failure states
-        failed_result = await db.execute(
-            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-                PrintArchive.project_id == project.id,
-                PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
-            )
-        )
-        failed_count = int(failed_result.scalar() or 0)
-
         # Plates progress: archive_count / target_count
         progress_percent = None
         if project.target_count and project.target_count > 0:
@@ -686,9 +701,13 @@ async def list_project_archives(
     archives = result.scalars().all()
 
     # Import the response converter from archives module
-    from backend.app.api.routes.archives import archive_to_response
+    from backend.app.api.routes.archives import _load_run_aggregates, archive_to_response
+
+    # Load run aggregates so multi-run archives' time/accuracy badge is
+    # suppressed consistently with the main archives list endpoint (#1608).
+    run_aggregates = await _load_run_aggregates(db, [a.id for a in archives])
 
-    return [archive_to_response(a) for a in archives]
+    return [archive_to_response(a, run_aggregate=run_aggregates.get(a.id)) for a in archives]
 
 
 @router.get("/{project_id}/queue")
@@ -892,7 +911,7 @@ async def upload_attachment(
 
     # Generate unique filename
     unique_filename = f"{uuid.uuid4().hex}{ext}"
-    file_path = attachments_dir / unique_filename
+    file_path = attachments_dir / unique_filename  # SEC-PATH-OK: unique_filename = uuid.uuid4().hex + ext
 
     # Save file
     try:
@@ -964,7 +983,9 @@ async def download_attachment(
         raise HTTPException(status_code=404, detail="Attachment not found")
 
     # Check file exists
-    file_path = get_project_attachments_dir(project_id) / filename
+    file_path = (
+        get_project_attachments_dir(project_id) / filename
+    )  # SEC-PATH-OK: filename validated above (no /, \\, .., empty) + attachment membership check
     if not file_path.exists():
         raise HTTPException(status_code=404, detail="Attachment file not found")
 
@@ -1004,7 +1025,9 @@ async def delete_attachment(
     project.attachments = attachments if attachments else None
 
     # Delete file
-    file_path = get_project_attachments_dir(project_id) / filename
+    file_path = (
+        get_project_attachments_dir(project_id) / filename
+    )  # SEC-PATH-OK: filename validated above (no /, \\, .., empty) + attachment membership check
     if file_path.exists():
         try:
             os.remove(file_path)
@@ -1066,7 +1089,7 @@ async def upload_project_cover_image(
                 logger.warning("Failed to delete old cover image %s: %s", old_path, e)
 
     unique_filename = f"cover_{uuid.uuid4().hex}{ext}"
-    file_path = attachments_dir / unique_filename
+    file_path = attachments_dir / unique_filename  # SEC-PATH-OK: unique_filename = f"cover_{uuid.uuid4().hex}{ext}"
     try:
         with open(file_path, "wb") as f:
             content = await file.read()
@@ -1827,6 +1850,13 @@ async def import_project_file(
         if not folder_name:
             continue
 
+        # Containment check on the folder name — refuses absolute paths and
+        # ``..`` traversal in ``project.json[linked_folders[*].name]``. The
+        # previous code did ``library_dir / folder_name`` directly, which
+        # collapses to ``Path(folder_name)`` when folder_name is absolute
+        # and lets ``..`` escape after mkdir.
+        folder_path = safe_join_under(library_dir, folder_name)
+
         # Check if folder exists
         existing_result = await db.execute(
             select(LibraryFolder).where(
@@ -1853,7 +1883,6 @@ async def import_project_file(
             await db.flush()
 
             # Create folder on disk
-            folder_path = library_dir / folder_name
             folder_path.mkdir(parents=True, exist_ok=True)
 
         # Import files for this folder from ZIP
@@ -1868,8 +1897,18 @@ async def import_project_file(
             if not relative_path:
                 continue
 
-            # Write file to disk
-            file_disk_path = library_dir / folder_name / relative_path
+            # Containment check on the per-entry relative path. ZIP names
+            # can carry ``..`` segments by spec; without resolve + parent
+            # containment, ``files/<folder>/../../../etc/x`` escapes
+            # ``library_dir`` entirely. ``relative_path`` is split into
+            # parts because ``safe_join_under`` rejects parts that start
+            # with ``/``, and a single combined string would hide an
+            # embedded ``..`` segment behind a forward slash.
+            file_disk_path = safe_join_under(
+                library_dir,
+                folder_name,
+                *Path(relative_path).parts,
+            )
             file_disk_path.parent.mkdir(parents=True, exist_ok=True)
             file_disk_path.write_bytes(file_content)
 

+ 43 - 18
backend/app/api/routes/settings.py

@@ -361,12 +361,20 @@ async def get_ui_preferences(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/check-ffmpeg")
-async def check_ffmpeg():
-    """Check if ffmpeg is installed and available."""
+async def check_ffmpeg(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Check if ffmpeg is installed and available.
+
+    Gated on ``SETTINGS_READ`` (audit finding I4 — the binary path was
+    leaking the host filesystem layout to unauthenticated callers).
+    ``require_permission_if_auth_enabled`` returns ``None`` only when
+    auth is disabled (in which case there's no privacy boundary to
+    enforce); otherwise it raises 401/403 before we get here.
+    """
     from backend.app.services.camera import get_ffmpeg_path
 
     ffmpeg_path = get_ffmpeg_path()
-
     return {
         "installed": ffmpeg_path is not None,
         "path": ffmpeg_path,
@@ -565,7 +573,9 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
         for name, src_dir in dirs_to_backup:
             if src_dir.exists() and any(src_dir.iterdir()):
                 try:
-                    shutil.copytree(src_dir, temp_path / name)
+                    shutil.copytree(
+                        src_dir, temp_path / name
+                    )  # SEC-PATH-OK: name iterates the dirs_to_backup tuple of constant strings ("archive", "virtual_printer", ...)
                 except shutil.Error as e:
                     logger.warning("Some files in %s could not be copied: %s", name, e)
                 except PermissionError as e:
@@ -591,7 +601,9 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
 
         # Create ZIP
         if output_path is not None:
-            zip_file = output_path / filename
+            zip_file = (
+                output_path / filename
+            )  # SEC-PATH-OK: filename = f"bambuddy-backup-{datetime.now()...}.zip" generated in create_backup_zip itself
         else:
             fd, tmp = tempfile.mkstemp(suffix=".zip")
             os.close(fd)
@@ -861,7 +873,9 @@ async def restore_backup(
                     # Reject path-traversal payloads: any entry whose resolved
                     # path escapes temp_path would allow writing arbitrary files
                     # on the host (ZipSlip / CVE-2006-5456).
-                    dest = (temp_path / name).resolve()
+                    dest = (
+                        temp_path / name
+                    ).resolve()  # SEC-PATH-OK: is_relative_to containment check below before extractall
                     # is_relative_to (Python 3.9+) covers both relative
                     # path-traversal (../etc/passwd) and absolute-path overrides
                     # (/etc/passwd) — str.startswith was vulnerable to
@@ -995,7 +1009,9 @@ async def restore_backup(
 
             skipped_dirs = []
             for name, dest_dir in dirs_to_restore:
-                src_dir = temp_path / name
+                src_dir = (
+                    temp_path / name
+                )  # SEC-PATH-OK: name iterates the dirs_to_restore tuple of constant strings ("archive", "virtual_printer", ...)
                 if src_dir.exists():
                     logger.info("Restoring %s directory...", name)
                     try:
@@ -1108,10 +1124,14 @@ async def get_virtual_printer_settings(
     tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
     archive_name_source = await get_setting(db, "virtual_printer_archive_name_source")
 
+    from backend.app.models.virtual_printer import VP_MODE_ARCHIVE, normalize_vp_mode
+
     return {
         "enabled": enabled == "true" if enabled else False,
         "access_code_set": bool(access_code),
-        "mode": mode or "immediate",
+        # Normalize on read so older settings rows (with `immediate` /
+        # `print_queue`) come out as `archive` / `queue` for the frontend.
+        "mode": normalize_vp_mode(mode) or VP_MODE_ARCHIVE,
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "remote_interface_ip": remote_interface_ip or "",
@@ -1152,7 +1172,9 @@ async def update_virtual_printer_settings(
     # Get current values
     current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
     current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
-    current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
+    # Default to `archive` (the canonical name) but tolerate legacy `immediate`
+    # in the stored value — normalized later before validation.
+    current_mode = await get_setting(db, "virtual_printer_mode") or "archive"
     current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id = int(current_target_id_str) if current_target_id_str else None
@@ -1170,15 +1192,21 @@ async def update_virtual_printer_settings(
     new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
     new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
 
-    # Validate mode
-    # "review" is the new name for "queue" (pending review before archiving)
-    # "print_queue" archives and adds to print queue (unassigned)
-    # "proxy" is transparent TCP proxy to a real printer
-    if new_mode not in ("immediate", "queue", "review", "print_queue", "proxy"):
+    # Validate mode. Canonical wire values are `archive` / `review` / `queue`
+    # / `proxy`; legacy `immediate` and `print_queue` are accepted as aliases
+    # and translated before storage so support bundles stop showing the old
+    # confusing pair (#1429 mode-label discrepancy).
+    from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
+
+    canonical_mode = normalize_vp_mode(new_mode)
+    if canonical_mode not in VP_MODE_VALUES:
         return JSONResponse(
             status_code=400,
-            content={"detail": "Mode must be 'immediate', 'review', 'print_queue', or 'proxy'"},
+            content={
+                "detail": f"Mode must be one of: {', '.join(VP_MODE_VALUES)}",
+            },
         )
+    new_mode = canonical_mode
 
     # Validate archive_name_source
     if archive_name_source is not None and archive_name_source not in ("metadata", "filename"):
@@ -1186,9 +1214,6 @@ async def update_virtual_printer_settings(
             status_code=400,
             content={"detail": "archive_name_source must be 'metadata' or 'filename'"},
         )
-    # Normalize legacy "queue" to "review" for storage
-    if new_mode == "queue":
-        new_mode = "review"
 
     # Validate model
     if model is not None and model not in VIRTUAL_PRINTER_MODELS:

+ 32 - 10
backend/app/api/routes/slicer_presets.py

@@ -16,7 +16,7 @@ import json
 import logging
 import time
 
-from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
@@ -88,7 +88,9 @@ def _empty_slots() -> dict[str, list[UnifiedPreset]]:
     return {"printer": [], "process": [], "filament": []}
 
 
-async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dict[str, list[UnifiedPreset]], str]:
+async def _fetch_cloud_presets(
+    db: AsyncSession, user: User | None, *, refresh: bool = False
+) -> tuple[dict[str, list[UnifiedPreset]], str]:
     """Return (slots, cloud_status). Slots are empty when cloud_status != 'ok'.
 
     Defence-in-depth: even if a stored cloud_token survived a permission
@@ -96,6 +98,12 @@ async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dic
     treated as not-authenticated for this endpoint — the cloud tier never
     surfaces for them. This keeps the per-tier visibility consistent with the
     /cloud/* endpoint suite that already gates on CLOUD_AUTH.
+
+    ``refresh=True`` skips the in-process cache for this call (used by the
+    SliceModal's manual Refresh button so a user who just deleted a preset
+    in Bambu Studio / Handy can pick up the change without waiting for the
+    5-minute TTL to expire). The fresh result is still written back to the
+    cache so subsequent non-refresh callers benefit.
     """
     if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
         return _empty_slots(), "not_authenticated"
@@ -107,9 +115,10 @@ async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dic
     user_key = user.id if user is not None else 0
     cache_key = (user_key, _token_fingerprint(token))
     now = time.monotonic()
-    cached = _cloud_cache.get(cache_key)
-    if cached and now - cached[0] < _CLOUD_TTL_S:
-        return cached[1], "ok"
+    if not refresh:
+        cached = _cloud_cache.get(cache_key)
+        if cached and now - cached[0] < _CLOUD_TTL_S:
+            return cached[1], "ok"
 
     cloud = BambuCloudService(region=region)
     cloud.set_token(token)
@@ -227,11 +236,15 @@ def _first_scalar(value: object) -> str | None:
     return None
 
 
-async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
-    """Standard slicer-bundled profiles via the sidecar's /profiles/bundled."""
+async def _fetch_bundled_presets(db: AsyncSession, *, refresh: bool = False) -> dict[str, list[UnifiedPreset]]:
+    """Standard slicer-bundled profiles via the sidecar's /profiles/bundled.
+
+    ``refresh=True`` skips the in-process cache; see _fetch_cloud_presets for
+    the same shape and rationale.
+    """
     global _bundled_cache
     now = time.monotonic()
-    if _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
+    if not refresh and _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
         return _bundled_cache[1]
 
     api_url = await _resolve_slicer_api_url(db)
@@ -383,6 +396,15 @@ async def list_unified_presets(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
+    refresh: bool = Query(
+        False,
+        description=(
+            "Bypass the in-process cloud and bundled-preset caches for this "
+            "request. The SliceModal's Refresh button sets this so users who "
+            "deleted a preset in Bambu Studio or Bambu Handy don't have to "
+            "wait for the 5-minute cloud-cache TTL to expire."
+        ),
+    ),
 ) -> UnifiedPresetsResponse:
     """List slicer presets across cloud / local / standard tiers, deduped by name.
 
@@ -399,9 +421,9 @@ async def list_unified_presets(
     too — matching the slice route (#1182 follow-up).
     """
     cloud_token_user = current_user or api_key_cloud_owner
-    cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user)
+    cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user, refresh=refresh)
     local = await _fetch_local_presets(db)
-    standard = await _fetch_bundled_presets(db)
+    standard = await _fetch_bundled_presets(db, refresh=refresh)
 
     cloud, local, standard = _dedupe_by_name(cloud, local, standard)
 

+ 19 - 10
backend/app/api/routes/spoolbuddy.py

@@ -867,15 +867,28 @@ async def update_spool_weight(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
-    """Update spool's used weight from scale reading."""
+    """Update spool's used weight from scale reading.
+
+    Routes the update to whichever inventory backend Bambuddy is configured
+    for: Spoolman exclusively when ``spoolman_enabled`` is true, local DB
+    exclusively otherwise. The previous implementation tried local first and
+    only consulted Spoolman on a local-DB miss, which meant a stale local row
+    sharing a numeric id with a Spoolman spool would silently absorb the
+    update while the Spoolman row the user is actually looking at stayed
+    unchanged (#1530). Mirrors the routing already used by ``nfc/tag-scanned``.
+    """
     from backend.app.api.routes._spoolman_helpers import _safe_float
     from backend.app.models.spool import Spool
 
-    # Try local DB first — local spool IDs must not be forwarded to Spoolman.
-    db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-    spool = db_result.scalar_one_or_none()
+    sm_client = await _get_spoolman_client_or_none(db)
+
+    if sm_client is None:
+        # Local mode — exclusive update, no Spoolman fallback.
+        db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+        spool = db_result.scalar_one_or_none()
+        if not spool:
+            raise HTTPException(status_code=404, detail="Spool not found")
 
-    if spool:
         net_filament = max(0, req.weight_grams - spool.core_weight)
         spool.weight_used = max(0, spool.label_weight - net_filament)
         spool.last_scale_weight = req.weight_grams
@@ -889,11 +902,7 @@ async def update_spool_weight(
         )
         return {"status": "ok", "weight_used": spool.weight_used}
 
-    # Local miss — fall back to Spoolman when enabled.
-    sm_client = await _get_spoolman_client_or_none(db)
-    if sm_client is None:
-        raise HTTPException(status_code=404, detail="Spool not found")
-
+    # Spoolman mode — exclusive update, never touch local DB.
     async with _translate_spoolbuddy_errors():
         sm_spool = await sm_client.get_spool(req.spool_id)
 

+ 9 - 4
backend/app/api/routes/system.py

@@ -19,6 +19,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.smart_plug import SmartPlug
@@ -416,18 +417,22 @@ async def get_system_info(
     failed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
     printing_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing"))
 
-    # Total print time
+    # System-wide totals aggregate per-run from ``print_log_entries`` so
+    # reprints contribute each run and multi-plate sums are pulled from the
+    # measured per-run actuals — same source the per-archive stats and the
+    # project rollup use (#1593). Pre-fix this summed ``PrintArchive`` directly,
+    # which under-reported the same way the project page did (3 reprints of
+    # one file showed as one file's worth of filament/time).
     total_print_time = (
         await db.scalar(
-            select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.print_time_seconds.isnot(None))
+            select(func.sum(PrintLogEntry.duration_seconds)).where(PrintLogEntry.duration_seconds.isnot(None))
         )
         or 0
     )
 
-    # Total filament used
     total_filament = (
         await db.scalar(
-            select(func.sum(PrintArchive.filament_used_grams)).where(PrintArchive.filament_used_grams.isnot(None))
+            select(func.sum(PrintLogEntry.filament_used_grams)).where(PrintLogEntry.filament_used_grams.isnot(None))
         )
         or 0
     )

+ 44 - 7
backend/app/api/routes/virtual_printers.py

@@ -33,7 +33,7 @@ class TailscaleStatusResponse(BaseModel):
 class VirtualPrinterCreate(BaseModel):
     name: str = "Bambuddy"
     enabled: bool = False
-    mode: str = "immediate"
+    mode: str = "archive"
     model: str | None = None
     access_code: str | None = None
     target_printer_id: int | None = None
@@ -133,12 +133,14 @@ async def create_virtual_printer(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Create a new virtual printer."""
-    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
     from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
     from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
 
-    # Validate mode
-    if body.mode not in ("immediate", "review", "print_queue", "proxy"):
+    # Accept both canonical and legacy wire values so older clients (forks /
+    # mobile shortcuts / scripted setups) still work; normalize before write.
+    body.mode = normalize_vp_mode(body.mode) or body.mode
+    if body.mode not in VP_MODE_VALUES:
         return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
 
     # Validate model
@@ -335,10 +337,17 @@ async def update_virtual_printer(
     if not vp:
         return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
 
+    # Redact the access code before logging — model_dump otherwise includes
+    # the plaintext value at DEBUG, violating the project no-secrets-in-logs
+    # rule. Replace with a marker that still signals "the user changed it"
+    # vs "the user didn't touch this field".
+    _safe_body = body.model_dump(exclude_unset=True)
+    if "access_code" in _safe_body:
+        _safe_body["access_code"] = "***"
     logger.debug(
         "Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
         vp_id,
-        body.model_dump(exclude_unset=True),
+        _safe_body,
         vp.mode,
         vp.enabled,
         bool(vp.access_code),
@@ -350,9 +359,12 @@ async def update_virtual_printer(
     if body.name is not None:
         vp.name = body.name
     if body.mode is not None:
-        if body.mode not in ("immediate", "review", "print_queue", "proxy"):
+        from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
+
+        canonical_mode = normalize_vp_mode(body.mode) or body.mode
+        if canonical_mode not in VP_MODE_VALUES:
             return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
-        vp.mode = body.mode
+        vp.mode = canonical_mode
     if body.model is not None:
         if body.model not in VIRTUAL_PRINTER_MODELS:
             return JSONResponse(
@@ -492,10 +504,35 @@ async def delete_virtual_printer(
     # Stop instance if running
     await virtual_printer_manager.remove_instance(vp_id)
 
+    # Mark any PendingUpload rows that referenced this VP's upload_dir as
+    # discarded — without this the rows live on as phantom entries in
+    # /pending-uploads/ pointing at file paths that no longer exist, and
+    # the user only learns they're orphaned by trying to archive one and
+    # getting a flip-to-discarded on file-missing.
+    upload_prefix = str(virtual_printer_manager._base_dir / "uploads" / str(vp_id))
+    try:
+        from backend.app.models.pending_upload import PendingUpload
+
+        stale = await db.execute(select(PendingUpload).where(PendingUpload.file_path.startswith(upload_prefix)))
+        for pending in stale.scalars().all():
+            pending.status = "discarded"
+        await db.flush()
+    except Exception as e:
+        logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, e)
+
     # Delete from DB
     await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     await db.commit()
 
+    # Remove the on-disk upload directory after the DB commit succeeds, so
+    # a crash between commit and rmtree only leaves orphan files (vs orphan
+    # rows pointing at a now-missing tree).
+    upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
+    if upload_dir.exists():
+        import shutil
+
+        shutil.rmtree(upload_dir, ignore_errors=True)
+
     logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
 
     # Resync remaining services

+ 17 - 9
backend/app/api/routes/webhook.py

@@ -196,10 +196,13 @@ async def webhook_stop_print(
     check_printer_access(api_key, printer_id)
 
     status = printer_manager.get_status(printer_id)
-    if not status or not status.get("connected"):
+    # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
+    # (see backend/app/services/bambu_mqtt.py), not a dict — `.get(...)` on it
+    # raises AttributeError and surfaces as a generic 500 (#1584).
+    if not status or not status.connected:
         raise HTTPException(status_code=503, detail="Printer not connected")
 
-    if status.get("state") != "RUNNING":
+    if status.state != "RUNNING":
         raise HTTPException(status_code=409, detail="No print in progress")
 
     try:
@@ -224,10 +227,11 @@ async def webhook_cancel_print(
     check_printer_access(api_key, printer_id)
 
     status = printer_manager.get_status(printer_id)
-    if not status or not status.get("connected"):
+    # Same dataclass-not-dict shape as stop_print above (#1584).
+    if not status or not status.connected:
         raise HTTPException(status_code=503, detail="Printer not connected")
 
-    if status.get("state") not in ["RUNNING", "PAUSE"]:
+    if status.state not in ["RUNNING", "PAUSE"]:
         raise HTTPException(status_code=409, detail="No print to cancel")
 
     try:
@@ -260,14 +264,18 @@ async def webhook_get_printer_status(
 
     status = printer_manager.get_status(printer_id)
 
+    # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass —
+    # attribute access, not dict lookup. The previous `.get(...)` calls raised
+    # AttributeError and surfaced as a generic 500 for any printer that
+    # actually had a status row (#1584).
     return PrinterStatusResponse(
         id=printer.id,
         name=printer.name,
-        connected=status.get("connected", False) if status else False,
-        state=status.get("state") if status else None,
-        current_print=status.get("current_print") if status else None,
-        progress=status.get("progress") if status else None,
-        remaining_time=status.get("remaining_time") if status else None,
+        connected=status.connected if status else False,
+        state=status.state if status else None,
+        current_print=status.current_print if status else None,
+        progress=status.progress if status else None,
+        remaining_time=status.remaining_time if status else None,
     )
 
 

+ 78 - 6
backend/app/api/routes/websocket.py

@@ -1,7 +1,27 @@
+"""GHSA-r2qv follow-up — WebSocket auth gate.
+
+Previously ``/api/v1/ws`` accepted *any* network client and immediately
+streamed every ``printer_status`` / ``print_start`` / ``print_complete``
+/ ``archive_*`` / ``inventory_changed`` broadcast back to it. That is
+the GHSA-gc24 shape on a different protocol — anyone who could reach
+the HTTP port could subscribe to every printer event in the system.
+
+This endpoint now validates a short-lived token (minted by
+``POST /api/v1/auth/ws-token`` behind ``Permission.WEBSOCKET_CONNECT``)
+*before* ``websocket.accept()``. When auth is disabled, no token is
+required (the legacy SPA-friendly path). The token is reused across
+reconnects within its 60-minute window so a brief network blip does
+not require a round-trip to the auth router.
+"""
+
+from __future__ import annotations
+
 import logging
 
-from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 
+from backend.app.core.auth import is_auth_enabled, verify_websocket_token
+from backend.app.core.database import async_session
 from backend.app.core.websocket import ws_manager
 from backend.app.services.background_dispatch import background_dispatch
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
@@ -9,16 +29,68 @@ from backend.app.services.printer_manager import printer_manager, printer_state_
 logger = logging.getLogger(__name__)
 router = APIRouter()
 
+# 4401 mirrors the WebSocket "unauthorised" application close code
+# convention used by Sec-WebSocket-Protocol authors (private-use range
+# is 4000-4999 per RFC 6455). The SPA distinguishes 4401 from network
+# drops and refetches a token instead of retrying with the old one.
+_WS_CLOSE_UNAUTHORIZED = 4401
+
 
 @router.websocket("/ws")
-async def websocket_endpoint(websocket: WebSocket):
-    """WebSocket endpoint for real-time updates."""
-    logger.info("WebSocket client connecting...")
+async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(default=None)) -> None:
+    """WebSocket endpoint for real-time updates.
+
+    Connection auth (GHSA-r2qv follow-up):
+
+    - Auth disabled  → connect without a token, identical to the prior
+      behaviour (single-user / local-network deployments).
+    - Auth enabled   → ``?token=<value>`` query param must hold an
+      unexpired token minted via ``POST /api/v1/auth/ws-token``.
+      Missing / invalid / expired token → ``close(code=4401)`` *before*
+      ``accept()`` so no ``ws_manager.broadcast`` ever reaches the
+      caller (broadcasts walk ``active_connections`` blindly — letting
+      an unauthenticated socket into that list is a fan-out leak).
+
+    The auth check is fail-closed at every error path: a DB exception
+    while reading the ``auth_enabled`` setting closes the connection
+    rather than admitting the caller.
+    """
+    # Authenticate before accept() so an unauth caller never lands in
+    # ws_manager.active_connections (where broadcasts blindly fan out).
+    try:
+        async with async_session() as db:
+            auth_required = await is_auth_enabled(db)
+    except Exception:  # SEC-AUTH-EXC: DB failure on auth probe → fail-closed (refuse connect), matches is_auth_enabled itself which returns True on error
+        logger.error("WebSocket auth probe failed; refusing connection", exc_info=True)
+        await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+        return
+
+    principal: str | None = None
+    if auth_required:
+        if not token:
+            logger.info("WebSocket connect refused: no token (auth enabled)")
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
+        principal = await verify_websocket_token(token)
+        if principal is None:
+            logger.info("WebSocket connect refused: invalid or expired token")
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
+
+    # Token verified (or auth disabled); now safe to admit the connection.
+    logger.info("WebSocket client connecting (principal=%s)", principal if principal else "<anonymous>")
     await ws_manager.connect(websocket)
+    # Stash on connection state for any future per-message permission
+    # logic; today the message handlers are read-only and only respond
+    # to the requesting socket, so the stash is informational. The
+    # explicit attribute (rather than a side dict) means a future
+    # ``broadcast_to_principal()`` helper can filter on it without
+    # touching every call site.
+    websocket.state.bambuddy_principal = principal
     logger.info("WebSocket client connected")
 
     try:
-        # Send initial status of all printers
+        # Send initial status of all printers.
         statuses = printer_manager.get_all_statuses()
         for printer_id, state in statuses.items():
             await websocket.send_json(
@@ -39,7 +111,7 @@ async def websocket_endpoint(websocket: WebSocket):
             )
         logger.info("Sent initial status for %s printers", len(statuses))
 
-        # Keep connection alive and handle incoming messages
+        # Keep connection alive and handle incoming messages.
         while True:
             data = await websocket.receive_json()
 

+ 5 - 0
backend/app/cli.py

@@ -65,6 +65,11 @@ async def kiosk_bootstrap(
             can_queue=False,
             can_control_printer=False,
             can_read_status=True,
+            can_manage_library=False,
+            # SpoolBuddy kiosk writes NFC scans / scale readings / system
+            # commands via the /spoolbuddy/* routes — all gated by
+            # can_manage_inventory now, so the bundled key must opt in.
+            can_manage_inventory=True,
             printer_ids=None,
             enabled=True,
             expires_at=None,

+ 387 - 29
backend/app/core/auth.py

@@ -24,13 +24,120 @@ from backend.app.models.user import User
 
 logger = logging.getLogger(__name__)
 
-# SETTINGS_READ is intentionally not denied — the SpoolBuddy kiosk reads settings
-# via API key (e.g. to sync the UI language).
+# GHSA-r2qv-8222-hqg3 (CVSS 9.9) — API key permission enforcement is allowlist-based.
+#
+# Until 0.2.4.x, ``_check_apikey_permissions`` only consulted the admin denylist
+# below. The three documented scope flags on ``APIKey``
+# (``can_read_status`` / ``can_queue`` / ``can_control_printer`` / ``can_manage_library``)
+# were enforced only by ``check_permission()`` inside ``routes/webhook.py``;
+# every other route used ``require_permission_if_auth_enabled`` which fell
+# through to the denylist-only path, so an API key with all flags unchecked
+# could still stop prints, edit queue items, and read every endpoint not in
+# this set. ``require_any_permission_if_auth_enabled`` and
+# ``require_ownership_permission`` did not call this helper at all, so admin
+# "any-of" routes and ownership-modify routes were entirely ungated for API keys.
+#
+# Fix: ``_check_apikey_permissions`` now requires every requested permission to
+# be present in ``_APIKEY_SCOPE_BY_PERMISSION`` (allowlist), and gates on the
+# corresponding scope flag on the API key. Unmapped permissions = 403. This
+# means a Permission added to ``core/permissions.py`` without a matching entry
+# in ``_APIKEY_SCOPE_BY_PERMISSION`` is automatically denied for API keys —
+# the previous denylist shape allowed every new Permission to silently widen
+# the API-key surface.
+#
+# The denylist is retained for documentation / drift-detection only — its
+# entries also satisfy "not in the allowlist", so they fail closed regardless.
+#
+# Mapping rationale (see wiki/features/api-keys.md):
+#   can_read_status     → every ``*_READ`` + camera + stats + system + websocket
+#   can_queue           → queue write ops + archive reprint
+#   can_control_printer → physical printer + smart-plug control
+#   can_manage_library  → library upload/own + MakerWorld import (separate
+#                         trust level from queue management, hence its own flag)
+#   admin-only          → unmapped (default-deny); covers all create/update/
+#                         delete of admin resources, settings writes, user/
+#                         group/api-key/backup admin ops, discovery scan,
+#                         cloud auth, library ALL-ownership perms, purges
+_APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
+    # can_read_status — read-only access to status, history, and configuration
+    Permission.PRINTERS_READ: "can_read_status",
+    Permission.ARCHIVES_READ: "can_read_status",
+    Permission.QUEUE_READ: "can_read_status",
+    Permission.LIBRARY_READ: "can_read_status",
+    Permission.PROJECTS_READ: "can_read_status",
+    Permission.FILAMENTS_READ: "can_read_status",
+    Permission.INVENTORY_READ: "can_read_status",
+    Permission.INVENTORY_VIEW_ASSIGNMENTS: "can_read_status",
+    Permission.INVENTORY_FORECAST_READ: "can_read_status",
+    Permission.SMART_PLUGS_READ: "can_read_status",
+    Permission.CAMERA_VIEW: "can_read_status",
+    Permission.MAINTENANCE_READ: "can_read_status",
+    Permission.KPROFILES_READ: "can_read_status",
+    Permission.NOTIFICATIONS_READ: "can_read_status",
+    Permission.NOTIFICATION_TEMPLATES_READ: "can_read_status",
+    Permission.EXTERNAL_LINKS_READ: "can_read_status",
+    Permission.FIRMWARE_READ: "can_read_status",
+    Permission.AMS_HISTORY_READ: "can_read_status",
+    Permission.STATS_READ: "can_read_status",
+    Permission.STATS_FILTER_BY_USER: "can_read_status",
+    Permission.SYSTEM_READ: "can_read_status",
+    # SETTINGS_READ stays allowed via read-status so SpoolBuddy kiosks keep
+    # working (they need the UI-language setting via API key).
+    Permission.SETTINGS_READ: "can_read_status",
+    Permission.MAKERWORLD_VIEW: "can_read_status",
+    Permission.WEBSOCKET_CONNECT: "can_read_status",
+    # can_queue — queue write ops + reprint (which enqueues an existing archive)
+    Permission.QUEUE_CREATE: "can_queue",
+    Permission.QUEUE_UPDATE_OWN: "can_queue",
+    Permission.QUEUE_UPDATE_ALL: "can_queue",
+    Permission.QUEUE_DELETE_OWN: "can_queue",
+    Permission.QUEUE_DELETE_ALL: "can_queue",
+    Permission.QUEUE_REORDER: "can_queue",
+    Permission.ARCHIVES_REPRINT_OWN: "can_queue",
+    Permission.ARCHIVES_REPRINT_ALL: "can_queue",
+    # can_control_printer — physical-world side effects on hardware
+    Permission.PRINTERS_CONTROL: "can_control_printer",
+    Permission.PRINTERS_FILES: "can_control_printer",
+    Permission.PRINTERS_AMS_RFID: "can_control_printer",
+    Permission.PRINTERS_CLEAR_PLATE: "can_control_printer",
+    Permission.SMART_PLUGS_CONTROL: "can_control_printer",
+    # can_manage_library — file-manager scope (upload/rename/delete OWN library
+    # entries + MakerWorld import which downloads files into the library).
+    # Bulk/ALL-ownership library ops (UPDATE_ALL / DELETE_ALL / PURGE) stay
+    # admin-only because they cross the user boundary.
+    Permission.LIBRARY_UPLOAD: "can_manage_library",
+    Permission.LIBRARY_UPDATE_OWN: "can_manage_library",
+    Permission.LIBRARY_DELETE_OWN: "can_manage_library",
+    Permission.MAKERWORLD_IMPORT: "can_manage_library",
+    # can_manage_inventory — inventory write scope. Covers the documented
+    # spool/catalog/forecast write surface AND the SpoolBuddy kiosk endpoints
+    # (NFC scan, scale reading, system command/update) which used
+    # INVENTORY_UPDATE as a stand-in for "kiosk write" under the prior
+    # denylist model. Read-only inventory (INVENTORY_READ etc.) stays under
+    # can_read_status.
+    Permission.INVENTORY_CREATE: "can_manage_inventory",
+    Permission.INVENTORY_UPDATE: "can_manage_inventory",
+    Permission.INVENTORY_DELETE: "can_manage_inventory",
+    Permission.INVENTORY_FORECAST_WRITE: "can_manage_inventory",
+    # can_access_cloud — narrow opt-in scope, gated by the router-level
+    # ``_cloud_api_key_gate`` and additionally enforced here so the route-
+    # level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed
+    # when the flag is off (defence-in-depth).
+    Permission.CLOUD_AUTH: "can_access_cloud",
+}
+
+# Retained for documentation, drift-detection, and the prior "administrative
+# operations" error string. Entries here are also absent from
+# ``_APIKEY_SCOPE_BY_PERMISSION``, so they fail closed via the allowlist; the
+# denylist is a redundant explicit "these are admin" marker, not the load-
+# bearing security check.
 _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
     {
+        # Settings administration (cred storage; rewriting these reaches SMTP/LDAP/MQTT).
         Permission.SETTINGS_UPDATE,
         Permission.SETTINGS_BACKUP,
         Permission.SETTINGS_RESTORE,
+        # User / group / API-key administration.
         Permission.USERS_READ,
         Permission.USERS_CREATE,
         Permission.USERS_UPDATE,
@@ -43,22 +150,112 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.API_KEYS_UPDATE,
         Permission.API_KEYS_DELETE,
         Permission.API_KEYS_READ,
+        # GitHub backup admin + firmware OTA.
         Permission.GITHUB_BACKUP,
         Permission.GITHUB_RESTORE,
         Permission.FIRMWARE_UPDATE,
+        # Resource administration (printer/project/filament/maintenance/k-profile/etc CRUD).
+        # API keys with the operational scopes can read these resources via
+        # *_READ permissions but cannot mutate the catalog/registry itself.
+        Permission.PRINTERS_CREATE,
+        Permission.PRINTERS_UPDATE,
+        Permission.PRINTERS_DELETE,
+        Permission.ARCHIVES_CREATE,
+        Permission.ARCHIVES_UPDATE_OWN,
+        Permission.ARCHIVES_UPDATE_ALL,
+        Permission.ARCHIVES_DELETE_OWN,
+        Permission.ARCHIVES_DELETE_ALL,
+        Permission.ARCHIVES_PURGE,
+        Permission.LIBRARY_UPDATE_ALL,
+        Permission.LIBRARY_DELETE_ALL,
+        Permission.LIBRARY_PURGE,
+        Permission.PROJECTS_CREATE,
+        Permission.PROJECTS_UPDATE,
+        Permission.PROJECTS_DELETE,
+        Permission.FILAMENTS_CREATE,
+        Permission.FILAMENTS_UPDATE,
+        Permission.FILAMENTS_DELETE,
+        Permission.MAINTENANCE_CREATE,
+        Permission.MAINTENANCE_UPDATE,
+        Permission.MAINTENANCE_DELETE,
+        Permission.KPROFILES_CREATE,
+        Permission.KPROFILES_UPDATE,
+        Permission.KPROFILES_DELETE,
+        Permission.NOTIFICATIONS_CREATE,
+        Permission.NOTIFICATIONS_UPDATE,
+        Permission.NOTIFICATIONS_DELETE,
+        Permission.NOTIFICATIONS_USER_EMAIL,
+        Permission.NOTIFICATION_TEMPLATES_UPDATE,
+        Permission.EXTERNAL_LINKS_CREATE,
+        Permission.EXTERNAL_LINKS_UPDATE,
+        Permission.EXTERNAL_LINKS_DELETE,
+        Permission.SMART_PLUGS_CREATE,
+        Permission.SMART_PLUGS_UPDATE,
+        Permission.SMART_PLUGS_DELETE,
+        # Network scanning — operator only (no API-key scope for this).
+        Permission.DISCOVERY_SCAN,
     }
 )
 
 
-def _check_apikey_permissions(perm_strings: list[str]) -> None:
-    """Raise 403 if any required permission is admin-only (not accessible via API key)."""
-    denied = _APIKEY_DENIED_PERMISSIONS.intersection(perm_strings)
-    if denied:
+def _resolve_apikey_scope(perm_string: str) -> str | None:
+    """Return the scope-flag attribute name gating ``perm_string`` for API keys.
+
+    None when the permission is unmapped (= admin-only / not API-key-usable).
+    """
+    try:
+        perm = Permission(perm_string)
+    except ValueError:
+        return None
+    return _APIKEY_SCOPE_BY_PERMISSION.get(perm)
+
+
+def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, require_any: bool = False) -> None:
+    """Raise 403 unless ``api_key`` is allowed to use ``perm_strings``.
+
+    Allowlist semantics: every requested permission MUST be present in
+    ``_APIKEY_SCOPE_BY_PERMISSION`` AND its scope flag must be True on
+    ``api_key``. Unmapped permissions = administrative = 403.
+
+    By default ALL requested permissions must pass (mirrors
+    ``require_permission`` / ``require_permission_if_auth_enabled``).
+    When ``require_any=True``, only one needs to pass (mirrors
+    ``require_any_permission_if_auth_enabled``).
+    """
+    if not perm_strings:
+        # Defensive: empty perm list means the dep is auth-only, not perm-gated.
+        # Routes never call us with [] today, but if they did, returning here
+        # would silently allow — instead, fail closed.
         raise HTTPException(
             status_code=status.HTTP_403_FORBIDDEN,
-            detail="API keys cannot be used for administrative operations",
+            detail="API keys cannot be used for unspecified permissions",
         )
 
+    last_failure: HTTPException | None = None
+    for perm_str in perm_strings:
+        scope_attr = _resolve_apikey_scope(perm_str)
+        if scope_attr is None:
+            failure = HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="API keys cannot be used for administrative operations",
+            )
+        elif not getattr(api_key, scope_attr, False):
+            failure = HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=f"API key does not have '{scope_attr}' permission",
+            )
+        else:
+            failure = None
+
+        if failure is None and require_any:
+            return  # at least one passed
+        if failure is not None and not require_any:
+            raise failure
+        last_failure = failure
+
+    if require_any and last_failure is not None:
+        raise last_failure
+
 
 def require_energy_cost_update():
     """Dependency for ``POST /settings/electricity-price`` (#1356).
@@ -307,6 +504,72 @@ async def create_camera_stream_token() -> str:
     return token
 
 
+WEBSOCKET_TOKEN_EXPIRE_MINUTES = 60
+
+
+async def create_websocket_token(username: str | None) -> str:
+    """Create a short-lived token for ``/api/v1/ws`` connections.
+
+    Mirrors the camera-stream-token pattern: opaque random string stored
+    in ``auth_ephemeral_tokens`` with type ``"websocket"`` so the WS
+    endpoint can verify it *before* calling ``websocket.accept()``.
+
+    Records the issuing principal in the ``username`` field — for JWT
+    callers this is the actual username, for API-keyed callers this is
+    the empty string (handled in the route layer; we accept None at this
+    interface so the auth-disabled path doesn't have to fabricate one).
+
+    The 60-minute expiry matches camera tokens: long enough to survive
+    page reloads / brief disconnects, short enough that a leaked token
+    is not a credential.
+    """
+    now = datetime.now(timezone.utc)
+    expires_at = now + timedelta(minutes=WEBSOCKET_TOKEN_EXPIRE_MINUTES)
+    token = secrets.token_urlsafe(24)
+    async with async_session() as db:
+        # Prune expired tokens opportunistically (same shape as camera).
+        await db.execute(
+            delete(AuthEphemeralToken).where(
+                AuthEphemeralToken.token_type == "websocket",
+                AuthEphemeralToken.expires_at < now,
+            )
+        )
+        db.add(
+            AuthEphemeralToken(
+                token=token,
+                token_type="websocket",
+                username=username or "",
+                expires_at=expires_at,
+            )
+        )
+        await db.commit()
+    return token
+
+
+async def verify_websocket_token(token: str) -> str | None:
+    """Verify a WebSocket connect token.
+
+    Returns the recorded ``username`` (possibly ``""`` for API-key
+    callers, never ``None`` on success) when the token is valid, or
+    ``None`` when it is missing / expired / unknown. The token is
+    NOT consumed — a single page reload should not need a new round
+    trip to mint a replacement.
+    """
+    now = datetime.now(timezone.utc)
+    async with async_session() as db:
+        result = await db.execute(
+            select(AuthEphemeralToken).where(
+                AuthEphemeralToken.token == token,
+                AuthEphemeralToken.token_type == "websocket",
+                AuthEphemeralToken.expires_at > now,
+            )
+        )
+        row = result.scalar_one_or_none()
+        if row is None:
+            return None
+        return row.username or ""
+
+
 async def verify_camera_stream_token(token: str) -> bool:
     """Verify a camera stream token is valid (reusable — does not consume it).
 
@@ -551,7 +814,7 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
                 api_key.last_used = datetime.now(timezone.utc)
                 await db.commit()
                 return api_key
-    except Exception as e:
+    except Exception as e:  # SEC-AUTH-EXC: validation failure returns None; every caller treats None as "invalid key" → 401 (fail-closed)
         logger.warning("API key validation error: %s", e)
     return None
 
@@ -742,19 +1005,92 @@ def require_role(required_role: str):
 
 
 def require_admin_if_auth_enabled():
-    """Dependency factory that requires admin role if auth is enabled."""
+    """Dependency factory that requires admin role if auth is enabled.
+
+    GHSA-r2qv follow-up (audit pattern P3): explicitly fail-closed for API
+    keys. The previous implementation chained on ``require_auth_if_enabled``
+    which returns ``None`` for *both* "auth disabled" *and* "valid API
+    key" — the inner ``admin_checker`` then treated ``None`` as auth-
+    disabled and admitted the caller. If any route had ever adopted this
+    dep, any API key with no scope flags set would have satisfied an
+    admin requirement.
+
+    Today no route uses this dep, but rather than leave the footgun
+    armed, the dep is rewritten to distinguish the two cases by
+    consulting ``is_auth_enabled`` directly and rejecting API-keyed
+    requests with 403. "Admin" requires a user-identity role, which API
+    keys do not carry.
+    """
 
     async def admin_checker(
-        current_user: Annotated[User | None, Depends(require_auth_if_enabled)] = None,
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     ) -> User | None:
-        if current_user is None:
-            return None  # Auth not enabled, allow access
-        if current_user.role != "admin":
-            raise HTTPException(
-                status_code=status.HTTP_403_FORBIDDEN,
-                detail="Requires admin role",
-            )
-        return current_user
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return None  # Auth disabled — no role to check.
+
+            # Reject API-keyed requests up front: admin is a user-role
+            # concept, not a key-scope concept. The right path for
+            # admin-equivalent API-key access is a specific Permission
+            # (e.g. SETTINGS_UPDATE) gated by the allowlist, not the
+            # admin role.
+            if x_api_key or (credentials and credentials.credentials.startswith("bb_")):
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail="Admin operations require a user role; API keys cannot be admins",
+                )
+
+            # Standard JWT path: validate and require admin role.
+            if credentials is None:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Authentication required",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+            try:
+                payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
+                username: str = payload.get("sub")
+                if username is None:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+                jti: str | None = payload.get("jti")
+                if not jti or await is_jti_revoked(jti):
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+                iat: int | float | None = payload.get("iat")
+            except JWTError:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+            if not _is_token_fresh(iat, user):
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+            if user.role != "admin":
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail="Requires admin role",
+                )
+            return user
 
     return admin_checker
 
@@ -930,7 +1266,7 @@ def require_permission(*permissions: str | Permission):
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
+                    _check_apikey_permissions(api_key, perm_strings)
                     return None  # API key valid, allow access
 
             credentials_exception = HTTPException(
@@ -947,7 +1283,7 @@ def require_permission(*permissions: str | Permission):
             if token.startswith("bb_"):
                 api_key = await _validate_api_key(db, token)
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
+                    _check_apikey_permissions(api_key, perm_strings)
                     return None  # API key valid, allow access
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1020,7 +1356,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
+                    _check_apikey_permissions(api_key, perm_strings)
                     return None  # API key valid, allow access
 
             # Check for Bearer token (could be JWT or API key)
@@ -1030,7 +1366,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        _check_apikey_permissions(perm_strings)
+                        _check_apikey_permissions(api_key, perm_strings)
                         return None  # API key valid, allow access
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1120,6 +1456,10 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
+                    # GHSA-r2qv-8222-hqg3: previously returned None unconditionally,
+                    # letting any valid API key satisfy admin "any-of" route
+                    # dependencies. require_any → at-least-one must pass the scope check.
+                    _check_apikey_permissions(api_key, perm_strings, require_any=True)
                     return None
 
             if credentials is not None:
@@ -1127,6 +1467,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
+                        _check_apikey_permissions(api_key, perm_strings, require_any=True)
                         return None
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1223,10 +1564,17 @@ def require_ownership_permission(
 ):
     """Dependency factory for ownership-based permission checks.
 
-    - User with `all_permission` can modify any item
-    - User with `own_permission` can only modify items where created_by_id == user.id
-    - Ownerless items (created_by_id = null) require `all_permission`
-    - API keys (via X-API-Key header or Bearer bb_xxx) get full access (can_modify_all=True)
+    - User with ``all_permission`` can modify any item
+    - User with ``own_permission`` can only modify items where created_by_id == user.id
+    - Ownerless items (created_by_id = null) require ``all_permission``
+    - API keys (via X-API-Key header or Bearer bb_xxx) must satisfy the
+      ``all_permission``'s API-key scope flag (e.g. ``can_queue`` for
+      ``QUEUE_UPDATE_ALL``) and then receive ``can_modify_all=True``.
+      OWN/ALL ownership pairs map to the same scope flag in
+      ``_APIKEY_SCOPE_BY_PERMISSION`` so checking ``all_permission`` is the
+      correct gate; API keys have no per-row ownership identity. Pre-
+      GHSA-r2qv-8222-hqg3 fix this returned ``(None, True)`` for any valid
+      key with no scope check — see ``core/auth.py`` allowlist commentary.
 
     Returns:
         A dependency function that returns (user, can_modify_all).
@@ -1250,11 +1598,20 @@ def require_ownership_permission(
             if not auth_enabled:
                 return None, True  # Auth disabled, allow all
 
-            # Check for API key first (X-API-Key header)
+            # GHSA-r2qv-8222-hqg3: previously API keys received (None, True)
+            # unconditionally on ownership-modify routes — a "queue-only" key
+            # could delete any user's archives, library files, queue items.
+            # OWN and ALL ownership perms both map to the same scope flag
+            # (e.g. both QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL → can_queue),
+            # so checking ``all_perm`` against the api_key's scope is the
+            # correct gate. API keys don't have per-row ownership identity, so
+            # on pass we keep can_modify_all=True (preserves prior intent,
+            # narrows access to keys with the right scope flag).
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    return None, True  # API key valid, allow all
+                    _check_apikey_permissions(api_key, [all_perm])
+                    return None, True
 
             # Check for Bearer token (could be JWT or API key)
             if credentials is not None:
@@ -1263,7 +1620,8 @@ def require_ownership_permission(
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        return None, True  # API key valid, allow all
+                        _check_apikey_permissions(api_key, [all_perm])
+                        return None, True
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Invalid API key",

+ 1 - 1
backend/app/core/config.py

@@ -6,7 +6,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.4.4"
+APP_VERSION = "0.2.4.5"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 

+ 100 - 2
backend/app/core/database.py

@@ -405,6 +405,26 @@ async def _safe_execute(conn, sql):
             raise
 
 
+async def _api_keys_column_exists(conn, column_name: str) -> bool:
+    """Return True if the named column exists on ``api_keys``.
+
+    Used to gate one-shot data backfills that must run only on the migration
+    that adds a column — without this, repeating the UPDATE on every startup
+    would silently overwrite values the user later edited in the UI.
+    Dialect-specific because SQLite has no information_schema.
+    """
+    from sqlalchemy import text
+
+    if is_sqlite():
+        result = await conn.execute(text("PRAGMA table_info(api_keys)"))
+        return any(row[1] == column_name for row in result)
+    result = await conn.execute(
+        text("SELECT 1 FROM information_schema.columns WHERE table_name = 'api_keys' AND column_name = :col"),
+        {"col": column_name},
+    )
+    return result.scalar_one_or_none() is not None
+
+
 async def _migrate_normalize_printer_ids(conn) -> None:
     from sqlalchemy import text
 
@@ -1639,9 +1659,18 @@ async def run_migrations(conn):
 
                     result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
                     row = result.fetchone()
-                    old_mode = row[0] if row else "immediate"
+                    old_mode = row[0] if row else "archive"
+                    # Translate to canonical wire values (#1429 mode-label
+                    # discrepancy): legacy `immediate` → `archive`, legacy
+                    # `print_queue` → `queue`. The historical `queue` alias
+                    # for `review` predates the canonical rename and is
+                    # preserved (existing user intent was "pending review").
                     if old_mode == "queue":
                         old_mode = "review"
+                    elif old_mode == "immediate":
+                        old_mode = "archive"
+                    elif old_mode == "print_queue":
+                        old_mode = "queue"
 
                     result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_model'"))
                     row = result.fetchone()
@@ -1671,7 +1700,7 @@ async def run_migrations(conn):
                         {
                             "name": "Bambuddy",
                             "enabled": old_enabled,
-                            "mode": old_mode or "immediate",
+                            "mode": old_mode or "archive",
                             "model": old_model,
                             "access_code": old_access_code,
                             "target_id": old_target_id,
@@ -1786,6 +1815,41 @@ async def run_migrations(conn):
             {"old": old_val, "new": new_val},
         )
 
+    # Migration: Rename VP mode wire values to match the user-facing labels
+    # (#1429 follow-up). The UI button "Archive" had always saved `immediate`
+    # and "Queue" had always saved `print_queue` — a mismatch that showed up
+    # confusingly in every support bundle. The button labels stay; the wire
+    # value is what changes. Idempotent: re-running the UPDATE on canonical
+    # values is a no-op. SQLite and Postgres both accept this statement
+    # unchanged (string literal comparison, no driver-specific syntax).
+    vp_mode_renames = [("immediate", "archive"), ("print_queue", "queue")]
+    for old_val, new_val in vp_mode_renames:
+        await conn.execute(
+            text("UPDATE virtual_printers SET mode = :new WHERE mode = :old"),
+            {"old": old_val, "new": new_val},
+        )
+        await conn.execute(
+            text("UPDATE settings SET value = :new WHERE key = 'virtual_printer_mode' AND value = :old"),
+            {"old": old_val, "new": new_val},
+        )
+
+    # Migration: Unify `LibraryFile.file_type` across ingest paths (#1600).
+    # Pre-#1600, only the external-folder scan path stored `gcode.3mf` for
+    # sliced outputs — the upload, ZIP-extract, and in-process paths all
+    # stripped to the trailing `.3mf` and stored `3mf`, so the same file
+    # family was split between two values depending on how it was ingested.
+    # Going forward `classify_file_type()` is canonical; this backfill flips
+    # existing legacy `3mf` rows whose filename ends in `.gcode.3mf` to the
+    # canonical compound name. Idempotent (post-update rows no longer match
+    # `file_type = '3mf'`) and dialect-neutral (`LOWER` + `LIKE` work the
+    # same under SQLite and Postgres).
+    await conn.execute(
+        text(
+            "UPDATE library_files SET file_type = 'gcode.3mf' "
+            "WHERE file_type = '3mf' AND LOWER(filename) LIKE '%.gcode.3mf'"
+        )
+    )
+
     # Migration: Add per-user Bambu Cloud credential columns
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_email VARCHAR(255)")
@@ -2186,6 +2250,40 @@ async def run_migrations(conn):
         "ALTER TABLE api_keys ADD COLUMN can_update_energy_cost BOOLEAN DEFAULT FALSE",
     )
 
+    # GHSA-r2qv-8222-hqg3 (CVE-2026-pending, CVSS 9.9): split file-management out
+    # of the implicit "any API key" grant into an explicit scope flag. The
+    # allowlist-based ``_check_apikey_permissions`` (see ``core/auth.py``) routes
+    # LIBRARY_UPLOAD / LIBRARY_UPDATE_OWN / LIBRARY_DELETE_OWN / MAKERWORLD_IMPORT
+    # through this flag. DEFAULT TRUE matches the existing "queue + read" trust
+    # baseline; backfill mirrors can_queue so a key the user previously created as
+    # "queue-only" retains the file-upload step its queue workflow already used,
+    # while a hardened "read-only" key (can_queue=False) does not silently gain a
+    # new write capability on upgrade. Backfill is gated on column non-existence
+    # so user-edited values are never overwritten on subsequent startup.
+    column_existed = await _api_keys_column_exists(conn, "can_manage_library")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_manage_library BOOLEAN DEFAULT TRUE",
+    )
+    if not column_existed:
+        async with conn.begin_nested():
+            await conn.execute(text("UPDATE api_keys SET can_manage_library = can_queue"))
+
+    # Same shape: SpoolBuddy NFC/scale/system endpoints plus manual inventory
+    # writes split out of the implicit "any API key" grant. Backfill mirrors
+    # ``can_queue`` so the bundled SpoolBuddy kiosk key (created via the CLI
+    # with can_queue=False) does NOT silently gain inventory writes — but
+    # the CLI override sets the new flag True explicitly, since the kiosk
+    # itself is the legitimate writer (see ``cli.py``).
+    column_existed = await _api_keys_column_exists(conn, "can_manage_inventory")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_manage_inventory BOOLEAN DEFAULT TRUE",
+    )
+    if not column_existed:
+        async with conn.begin_nested():
+            await conn.execute(text("UPDATE api_keys SET can_manage_inventory = can_queue"))
+
     # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
     # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's
     # "WHERE deleted_at IS NOT NULL" stay cheap as the table grows.

+ 283 - 7
backend/app/main.py

@@ -330,6 +330,14 @@ logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, l
 # Track active prints: {(printer_id, filename): archive_id}
 _active_prints: dict[tuple[int, str], int] = {}
 
+# Per-printer "connected" edge tracker. Used by `on_printer_status_change`
+# to fire `reconcile_stale_active_prints` exactly once per (re)connection
+# (#1542 follow-up — power-cycle ghost prints). The value is True after
+# the first connected status update for that connection; transitions back
+# to False whenever we observe `state.connected = False` so the next
+# reconnect re-arms reconciliation. Keyed by printer_id.
+_printer_reconciled_since_connect: dict[int, bool] = {}
+
 # Track expected prints from reprint/scheduled (skip auto-archiving for these)
 # {(printer_id, filename): archive_id}
 _expected_prints: dict[tuple[int, str], int] = {}
@@ -628,6 +636,89 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
     return stored_ams_mapping
 
 
+def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None = None) -> dict[str, str]:
+    """Best-effort filament metadata from the MQTT print-start snapshot.
+
+    Used when the 3MF can't be downloaded (P1S/A1/P2S firmwares lock the
+    file during print, see #1533) so the fallback PrintArchive still has
+    enough filament info to support the inventory views and AMS-expansion
+    planning the operator opens it for. Returns a dict with optional
+    ``filament_type`` and ``filament_color`` keys in the same
+    comma-separated format the 3MF extractor produces, so the rest of the
+    codebase treats the fallback archive identically to a normal one.
+
+    ``ams_mapping`` is the slicer's slot-per-print-filament list captured
+    from the MQTT print payload (global tray IDs, possibly -1 for VT-tray
+    entries). When supplied, only the slots actually consumed by this
+    print contribute. Without it the function falls back to every loaded
+    AMS slot — less accurate but still useful.
+
+    Accepts both the raw inner payload (``{"ams": {"ams": [...]}, ...}``)
+    that the unit tests pass directly, AND the on_print_start callback
+    shape (``{"raw_data": {"ams": {"ams": [...]}, ...}, ...}``) the
+    bambu_mqtt service hands to main.py at runtime. The original
+    ``_extract_filament_data_from_mqtt(data)`` shipped in #1533 only
+    handled the inner shape and silently returned ``{}`` for every real
+    print start, leaving fallback archives' filament fields NULL — the
+    exact regression the fix was meant to close. Reported with a log
+    proving the AMS state was right there at
+    ``data["raw_data"]["ams"]["ams"][0]["tray"][0]`` (#1533 follow-up).
+    """
+    result: dict[str, str] = {}
+    # Look at the on_print_start wrapper first, then the inner shape.
+    raw_data = (data or {}).get("raw_data")
+    ams_root = (raw_data or {}).get("ams") if isinstance(raw_data, dict) else None
+    if not isinstance(ams_root, dict):
+        ams_root = (data or {}).get("ams") or {}
+    ams_units = ams_root.get("ams") if isinstance(ams_root, dict) else None
+    if not isinstance(ams_units, list) or not ams_units:
+        return result
+
+    # Map global tray id (unit * 4 + tray) → (type, color).
+    loaded: dict[int, tuple[str, str]] = {}
+    for unit in ams_units:
+        if not isinstance(unit, dict):
+            continue
+        try:
+            unit_id = int(unit.get("id", 0))
+        except (TypeError, ValueError):
+            continue
+        for tray in unit.get("tray") or []:
+            if not isinstance(tray, dict):
+                continue
+            try:
+                tray_id = int(tray.get("id", 0))
+            except (TypeError, ValueError):
+                continue
+            ttype = (tray.get("tray_type") or "").strip()
+            tcolor = (tray.get("tray_color") or "").strip().upper()
+            if not ttype:
+                continue  # Empty / unloaded slot.
+            loaded[unit_id * 4 + tray_id] = (ttype, tcolor)
+
+    if not loaded:
+        return result
+
+    if ams_mapping:
+        used_ids = [int(x) for x in ams_mapping if isinstance(x, (int, float)) and int(x) >= 0]
+        filaments = [loaded[g] for g in used_ids if g in loaded]
+        if not filaments:
+            return result  # Mapping points entirely at slots we have no data for.
+    else:
+        filaments = [loaded[g] for g in sorted(loaded.keys())]
+
+    types_joined = ",".join(f[0] for f in filaments)
+    colors_joined = ",".join(f[1] for f in filaments if f[1])
+
+    # Column limits per backend/app/models/archive.py: filament_type=50,
+    # filament_color=200.
+    if types_joined:
+        result["filament_type"] = types_joined[:50]
+    if colors_joined:
+        result["filament_color"] = colors_joined[:200]
+    return result
+
+
 def _maybe_start_layer_timelapse(printer, printer_id: int, archive_id: int) -> bool:
     """Start a layer-timelapse session for *archive_id* when the printer has
     an external camera configured. Returns True if a session was started.
@@ -715,6 +806,26 @@ _nozzle_count_updated: set[int] = set()
 
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
+    # Connected-edge reconciliation (#1542 follow-up). When the printer
+    # transitions disconnected → connected — which covers both Bambuddy
+    # startup (no prior connection) and a mid-session MQTT reconnect — fire
+    # `reconcile_stale_active_prints` exactly once for this connection so
+    # any archive still in `status="printing"` that can't actually be
+    # running anymore (printer IDLE / different subtask / empty subtask)
+    # gets a synthesised PRINT COMPLETE. Without this, a print that
+    # finished during a disconnect window + a smart-plug power cycle
+    # leaves the .3mf on the SD card and the firmware ghost-replays it on
+    # next boot. Reconciliation runs concurrently — it must not block the
+    # WebSocket dedup / broadcast logic below, and the connected edge is
+    # marked True BEFORE the await so concurrent status updates inside
+    # the same connection don't re-trigger reconciliation.
+    if state.connected and not _printer_reconciled_since_connect.get(printer_id, False):
+        _printer_reconciled_since_connect[printer_id] = True
+        asyncio.create_task(reconcile_stale_active_prints(printer_id))
+    elif not state.connected and _printer_reconciled_since_connect.get(printer_id, False):
+        # Re-arm so the next reconnect triggers reconciliation again.
+        _printer_reconciled_since_connect[printer_id] = False
+
     # Only broadcast if something meaningful changed (reduce WebSocket spam)
     # Include rounded temperatures to detect meaningful temp changes (within 1 degree)
     temps = state.temperatures or {}
@@ -2592,6 +2703,14 @@ async def on_print_start(printer_id: int, data: dict):
                     if mc_remaining and isinstance(mc_remaining, (int, float)) and mc_remaining > 0:
                         fallback_print_time = int(mc_remaining * 60)
 
+                # Best-effort filament metadata from MQTT — see
+                # _extract_filament_data_from_mqtt. Without this the fallback
+                # archive's filament fields stayed NULL even though the AMS
+                # state at print start was sitting right there in `data`.
+                # The slicer's ams_mapping (when present) narrows the result
+                # to slots actually used by the print (#1533).
+                mqtt_filament_meta = _extract_filament_data_from_mqtt(data, _get_start_ams_mapping(data, None))
+
                 # Create minimal archive entry
                 fallback_archive = PrintArchive(
                     printer_id=printer_id,
@@ -2603,6 +2722,8 @@ async def on_print_start(printer_id: int, data: dict):
                     status="printing",
                     started_at=datetime.now(timezone.utc),
                     subtask_id=subtask_id,
+                    filament_type=mqtt_filament_meta.get("filament_type"),
+                    filament_color=mqtt_filament_meta.get("filament_color"),
                     extra_data={"no_3mf_available": True, "original_subtask": subtask_name, "_print_data": data},
                 )
 
@@ -3070,6 +3191,136 @@ async def on_print_running_observed(printer_id: int, data: dict):
     await _capture_timelapse_baseline_at_start(printer, printer_id, logger)
 
 
+def _is_active_archive_stale(archive, state) -> tuple[bool, str]:
+    """Return ``(is_stale, reason)`` for an archive in ``status="printing"``
+    against the printer's current MQTT state.
+
+    Reconciliation triggers (#1542 follow-up — recovers from missed PRINT
+    COMPLETE events, typically a print finishing during an MQTT disconnect
+    window followed by a smart-plug power cycle):
+
+      1. Printer state is terminal (IDLE / FINISH / FAILED). The print is
+         provably not running anymore — only branch that should fire under
+         normal disconnect-then-reconnect timing.
+      2. Printer has a different ``subtask_id`` than the archive. Bambu
+         firmware mints a fresh ``subtask_id`` for each print, including the
+         ghost replay it runs after a power cycle from a leftover SD file —
+         so a mismatch unambiguously means the in-DB archive is no longer
+         the print on the printer.
+      3. Printer is running but ``subtask_name`` is empty. The printer
+         doesn't know what it's running; the archive's reference to it is
+         already broken.
+
+    Conservative on purpose: PAUSE / PREPARE / SLICING and any RUNNING state
+    with matching subtask_id+subtask_name is left alone. The cost of a false
+    positive is a single misreported "aborted" status that the next real
+    PRINT COMPLETE would have overwritten with the correct status anyway.
+    The cost of a false negative is the ghost-print loop in #1542.
+    """
+    current_state = (state.state or "").upper()
+    if current_state in ("IDLE", "FINISH", "FAILED"):
+        return True, f"printer state {current_state}"
+    # Below here the printer is in a running / pre-running state (RUNNING /
+    # PAUSE / PREPARE / SLICING / etc.) — decide based on subtask identity.
+    current_subtask_id = (state.subtask_id or "").strip()
+    if archive.subtask_id and current_subtask_id and archive.subtask_id != current_subtask_id:
+        return True, f"subtask_id changed ({archive.subtask_id!r} → {current_subtask_id!r})"
+    current_subtask_name = (state.subtask_name or "").strip()
+    if not current_subtask_name:
+        return True, "printer subtask_name empty"
+    return False, ""
+
+
+async def reconcile_stale_active_prints(printer_id: int) -> int:
+    """Synthesise ``on_print_complete`` for archives whose print can't be
+    running on the printer anymore.
+
+    Called once per MQTT (re)connection (from on_printer_status_change when
+    the connected edge flips False → True) and at Bambuddy startup (from
+    the FastAPI lifespan). Without this, a print that completes during a
+    disconnect window — followed by a smart-plug-driven power cycle — leaves
+    the ``.3mf`` on the SD card, the firmware auto-replays it on next boot,
+    and Bambuddy fires a fresh PRINT START for the ghost rather than the
+    SD cleanup that PRINT COMPLETE was supposed to run. Repeats every
+    power cycle until the operator notices (#1542 follow-up). Reconciliation
+    closes the loop by faking the missed PRINT COMPLETE — the existing
+    cleanup chain handles SD-file deletion, status updates, usage tracking,
+    and notifications.
+
+    Synthesised ``status="aborted"`` is the conservative label: we have no
+    proof the print finished successfully (and no progress evidence to
+    promote to ``"completed"``). The real PRINT COMPLETE callback, if it
+    fires later, overwrites the status with the correct value.
+
+    Returns the number of archives reconciled.
+    """
+    state = printer_manager.get_status(printer_id)
+    if not state:
+        return 0
+    # Don't reconcile while disconnected — we'd be making a decision against
+    # stale cached state. The connected → reconcile edge handles this.
+    if not state.connected:
+        return 0
+
+    from backend.app.models.archive import PrintArchive
+
+    reconciled = 0
+    async with async_session() as db:
+        result = await db.execute(
+            select(PrintArchive).where(
+                PrintArchive.printer_id == printer_id,
+                PrintArchive.status == "printing",
+            )
+        )
+        active = list(result.scalars().all())
+
+    if not active:
+        return 0
+
+    logger = logging.getLogger(__name__)
+    for archive in active:
+        is_stale, reason = _is_active_archive_stale(archive, state)
+        if not is_stale:
+            continue
+        logger.info(
+            "[RECONCILE] Printer %s: synthesising missed PRINT COMPLETE for archive %s (%s) — %s",
+            printer_id,
+            archive.id,
+            archive.filename,
+            reason,
+        )
+        # Synthesised payload: minimal fields the on_print_complete chain
+        # needs. `_reconciled` marker lets downstream code distinguish this
+        # from a real MQTT-driven completion if it ever needs to (e.g. for
+        # metrics / debug logging). raw_data is the live printer state so
+        # the usage tracker can compare end-of-print remain% against the
+        # captured start values.
+        try:
+            await on_print_complete(
+                printer_id,
+                {
+                    "status": "aborted",
+                    "filename": archive.filename,
+                    "subtask_name": archive.print_name or "",
+                    "subtask_id": archive.subtask_id or "",
+                    "raw_data": state.raw_data or {},
+                    "_reconciled": True,
+                },
+            )
+            reconciled += 1
+        except Exception as e:
+            # Catch-all: a reconciliation failure must not block the
+            # printer's normal status flow. The archive stays in
+            # ``status="printing"`` and the next reconnect retries.
+            logger.warning(
+                "[RECONCILE] on_print_complete synthesis failed for archive %s: %s",
+                archive.id,
+                e,
+            )
+
+    return reconciled
+
+
 async def on_print_complete(printer_id: int, data: dict):
     """Handle print completion - update the archive status."""
     import time
@@ -3246,24 +3497,43 @@ async def on_print_complete(printer_id: int, data: dict):
                 if archive:
                     archive_id = archive.id
 
-    # Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374)
-    # The print scheduler uploads files to the SD card root (/). Some printers (e.g. P1S)
+    # Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374, #1542)
+    # The print scheduler uploads files to the SD card root (/). Some printers (e.g. P1S, A1)
     # auto-start files found in root on power cycle, causing ghost prints.
     # Must run before the archive_id early-return so it executes even when archiving is disabled.
     try:
         if subtask_name:
+            archive_filename: str | None = None
             async with async_session() as db:
+                from backend.app.models.archive import PrintArchive
                 from backend.app.models.printer import Printer
 
                 result = await db.execute(select(Printer).where(Printer.id == printer_id))
                 printer = result.scalar_one_or_none()
+                if archive_id:
+                    archive_row = await db.execute(select(PrintArchive.filename).where(PrintArchive.id == archive_id))
+                    archive_filename = archive_row.scalar_one_or_none()
 
             if printer:
                 from backend.app.services.bambu_ftp import delete_file_async
-
-                # Try both .3mf and .gcode extensions — the printer may have either
+                from backend.app.utils.filename import derive_remote_filename
+
+                # Primary candidate: the exact path the dispatcher uploaded to
+                # (derived from archive.filename via the same rule as upload).
+                # Without it, a library row that ended up with a doubled
+                # .gcode.3mf (#1542) leaves the real file behind because the
+                # subtask_name + ext fallbacks below don't match what's on the
+                # SD card. Fallbacks remain for archive-less prints (subtask
+                # never resolved to an archive) and for older naming variants.
+                candidate_paths: list[str] = []
+                if archive_filename:
+                    candidate_paths.append(f"/{derive_remote_filename(archive_filename)}")
                 for ext in (".3mf", ".gcode"):
-                    remote_path = f"/{subtask_name}{ext}"
+                    fallback = f"/{subtask_name}{ext}"
+                    if fallback not in candidate_paths:
+                        candidate_paths.append(fallback)
+
+                for remote_path in candidate_paths:
                     # Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends
                     for attempt in range(1, 4):
                         try:
@@ -4380,7 +4650,13 @@ RUNTIME_TRACKING_INTERVAL = 30  # Update every 30 seconds
 
 
 async def track_printer_runtime():
-    """Background task to track printer active runtime (RUNNING/PAUSE states)."""
+    """Background task to track printer active runtime (RUNNING state only).
+
+    PAUSE is intentionally excluded — the runtime counter feeds hours-based
+    maintenance intervals (rod lubrication, belt checks, nozzle cleaning)
+    which track mechanical wear. Pause time has no motion and no wear, so
+    counting it inflates maintenance warnings (#1521).
+    """
     logger = logging.getLogger(__name__)
 
     # Wait for MQTT connections to establish on startup
@@ -4418,7 +4694,7 @@ async def track_printer_runtime():
                 new_runtime = runtime_secs
                 new_last_update = last_update
 
-                if state.state in ("RUNNING", "PAUSE"):
+                if state.state == "RUNNING":
                     if last_update:
                         lu = last_update if last_update.tzinfo else last_update.replace(tzinfo=timezone.utc)
                         elapsed = (now - lu).total_seconds()

+ 6 - 0
backend/app/models/api_key.py

@@ -30,6 +30,12 @@ class APIKey(Base):
     can_queue: Mapped[bool] = mapped_column(Boolean, default=True)  # Add to queue
     can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False)  # Start/stop/cancel
     can_read_status: Mapped[bool] = mapped_column(Boolean, default=True)  # Query status
+    can_manage_library: Mapped[bool] = mapped_column(
+        Boolean, default=True
+    )  # Upload/rename/delete own library files + MakerWorld import
+    can_manage_inventory: Mapped[bool] = mapped_column(
+        Boolean, default=True
+    )  # Inventory write ops (incl. SpoolBuddy kiosk NFC/scale/system)
     can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False)  # Read /cloud/* on the owner's behalf
     # Narrowly-scoped settings write: only POST /settings/electricity-price.
     # Lets HA/Tibber-style automations push dynamic tariff updates without

+ 3 - 1
backend/app/models/printer.py

@@ -20,7 +20,9 @@ class Printer(Base):
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
     print_hours_offset: Mapped[float] = mapped_column(Float, default=0.0)  # Baseline hours to add
-    runtime_seconds: Mapped[int] = mapped_column(default=0)  # Accumulated active runtime (RUNNING/PAUSE states)
+    runtime_seconds: Mapped[int] = mapped_column(
+        default=0
+    )  # Accumulated active runtime (RUNNING state only — see #1521)
     last_runtime_update: Mapped[datetime | None] = mapped_column(
         DateTime, nullable=True
     )  # Last time runtime was updated

+ 31 - 5
backend/app/models/virtual_printer.py

@@ -5,6 +5,34 @@ from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
 
+# Canonical VP mode values. The legacy values `immediate` (→ archive) and
+# `print_queue` (→ queue) shipped before the UI labels were aligned with the
+# wire format. `normalize_vp_mode()` translates input from either form and
+# the DB migration in `core/database.py` rewrites existing rows once at boot.
+VP_MODE_ARCHIVE = "archive"
+VP_MODE_REVIEW = "review"
+VP_MODE_QUEUE = "queue"
+VP_MODE_PROXY = "proxy"
+VP_MODE_VALUES = (VP_MODE_ARCHIVE, VP_MODE_REVIEW, VP_MODE_QUEUE, VP_MODE_PROXY)
+
+# Legacy → canonical map. Kept narrow on purpose so unrelated typos surface
+# instead of getting silently re-pointed at a default.
+_VP_MODE_ALIASES = {
+    "immediate": VP_MODE_ARCHIVE,
+    "print_queue": VP_MODE_QUEUE,
+}
+
+
+def normalize_vp_mode(value: str | None) -> str | None:
+    """Map legacy wire values (`immediate`, `print_queue`) to canonical names.
+
+    Returns `None` unchanged so callers can decide whether to apply a default.
+    Returns unknown values unchanged so validators still see them and reject.
+    """
+    if value is None:
+        return None
+    return _VP_MODE_ALIASES.get(value, value)
+
 
 class VirtualPrinter(Base):
     """Virtual printer configuration for multi-instance support."""
@@ -14,13 +42,11 @@ class VirtualPrinter(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100), default="Bambuddy")
     enabled: Mapped[bool] = mapped_column(Boolean, default=False)
-    mode: Mapped[str] = mapped_column(String(20), default="immediate")  # immediate|review|print_queue|proxy
-    auto_dispatch: Mapped[bool] = mapped_column(
-        Boolean, server_default="true"
-    )  # print_queue mode: auto-start or manual
+    mode: Mapped[str] = mapped_column(String(20), default=VP_MODE_ARCHIVE)  # archive|review|queue|proxy
+    auto_dispatch: Mapped[bool] = mapped_column(Boolean, server_default="true")  # queue mode: auto-start or manual
     queue_force_color_match: Mapped[bool] = mapped_column(
         Boolean, server_default="false"
-    )  # print_queue mode: pin per-slot type+color from the 3MF onto the queue
+    )  # queue mode: pin per-slot type+color from the 3MF onto the queue
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # filament loaded (#1188).
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)

+ 6 - 0
backend/app/schemas/api_key.py

@@ -10,6 +10,8 @@ class APIKeyCreate(BaseModel):
     can_queue: bool = True
     can_control_printer: bool = False
     can_read_status: bool = True
+    can_manage_library: bool = True  # Upload / rename / delete own library files + MakerWorld import
+    can_manage_inventory: bool = True  # Inventory writes — SpoolBuddy NFC/scale/system, manual stock edits via API
     can_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
     can_update_energy_cost: bool = False  # POST /settings/electricity-price only (#1356)
     printer_ids: list[int] | None = None  # null = all printers
@@ -23,6 +25,8 @@ class APIKeyUpdate(BaseModel):
     can_queue: bool | None = None
     can_control_printer: bool | None = None
     can_read_status: bool | None = None
+    can_manage_library: bool | None = None
+    can_manage_inventory: bool | None = None
     can_access_cloud: bool | None = None
     can_update_energy_cost: bool | None = None
     printer_ids: list[int] | None = None
@@ -40,6 +44,8 @@ class APIKeyResponse(BaseModel):
     can_queue: bool
     can_control_printer: bool
     can_read_status: bool
+    can_manage_library: bool
+    can_manage_inventory: bool
     can_access_cloud: bool
     can_update_energy_cost: bool
     printer_ids: list[int] | None

+ 4 - 0
backend/app/schemas/archive.py

@@ -147,6 +147,10 @@ class ArchiveStats(BaseModel):
     total_prints: int
     successful_prints: int
     failed_prints: int
+    # User/system-stopped prints (PrintLogEntry.status in stopped/cancelled/
+    # skipped). Defaulted so older clients that don't send this field still
+    # validate against historical fixtures.
+    cancelled_prints: int = 0
     total_print_time_hours: float
     total_filament_grams: float
     total_cost: float

+ 2 - 2
backend/app/schemas/settings.py

@@ -113,8 +113,8 @@ class AppSettings(BaseModel):
     virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
     virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
     virtual_printer_mode: str = Field(
-        default="immediate",
-        description="Mode: 'immediate' (archive now), 'review' (pending review), or 'print_queue' (add to print queue)",
+        default="archive",
+        description="Mode: 'archive' (archive now), 'review' (pending review), 'queue' (add to print queue), or 'proxy' (relay to real printer)",
     )
     virtual_printer_archive_name_source: str = Field(
         default="metadata",

+ 84 - 35
backend/app/services/archive.py

@@ -16,6 +16,7 @@ from backend.app.core.config import settings
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
+from backend.app.utils.safe_path import PathTraversalError, safe_join_under
 
 logger = logging.getLogger(__name__)
 
@@ -187,49 +188,78 @@ class ThreeMFParser:
                             self.metadata["sliced_for_model"] = normalized
                         break
 
-                # Find the plate element (single-plate exports only have one plate)
-                plate = root.find(".//plate")
-
-                if plate is not None:
-                    # Extract metadata from plate element
+                # Loop every <plate> so multi-plate exports get summed file-level
+                # totals. Pre-fix, this used `root.find(".//plate")` which
+                # returned only the first plate — file-level `print_time_seconds`
+                # / `filament_used_grams` reflected plate 1 alone, and the
+                # archive card / project rollup under-reported by the number
+                # of plates (#1593). Per-plate breakdown is still served by
+                # the dedicated `/plates` endpoint.
+                plates = root.findall(".//plate")
+                summed_time = 0
+                summed_grams = 0.0
+                any_time_seen = False
+                any_grams_seen = False
+
+                for plate in plates:
+                    # Plate-level fields that only make sense at the file
+                    # level when there's exactly one plate. ``plate_number``
+                    # / ``_plate_index`` describe which plate the export
+                    # represents — meaningless for an all-plates 3MF, so we
+                    # only record them in the single-plate case. ``bed_type``
+                    # is also single-valued; we take the first plate's value
+                    # as a best-effort default for the archive metadata.
+                    plate_index_value: int | None = None
                     for meta in plate.findall("metadata"):
                         key = meta.get("key")
                         value = meta.get("value")
                         if key == "index" and value:
-                            # Extract plate index - this tells us which plate was exported
                             try:
-                                extracted_index = int(value)
-                                # Set plate_number if not already set from filename
-                                if not self.plate_number:
-                                    self.plate_number = extracted_index
-                                # Store in metadata for print_name generation
-                                self.metadata["_plate_index"] = extracted_index
+                                plate_index_value = int(value)
                             except ValueError:
                                 pass  # Skip non-numeric plate index
                         elif key == "prediction" and value:
-                            self.metadata["print_time_seconds"] = int(value)
+                            try:
+                                summed_time += int(value)
+                                any_time_seen = True
+                            except ValueError:
+                                pass
                         elif key == "weight" and value:
-                            self.metadata["filament_used_grams"] = float(value)
-                        elif key == "curr_bed_type" and value:
-                            self.metadata["bed_type"] = value
-
-                    # Extract printable objects for skip object functionality
-                    # Objects are stored as <object identify_id="123" name="Part1" skipped="false" />
-                    printable_objects = {}
-                    for obj in plate.findall("object"):
-                        identify_id = obj.get("identify_id")
-                        name = obj.get("name")
-                        skipped = obj.get("skipped", "false")
-
-                        # Only include objects that are not pre-skipped
-                        if identify_id and name and skipped.lower() != "true":
                             try:
-                                printable_objects[int(identify_id)] = name
+                                summed_grams += float(value)
+                                any_grams_seen = True
                             except ValueError:
-                                pass  # Skip objects with non-numeric identify_id
+                                pass
+                        elif key == "curr_bed_type" and value and "bed_type" not in self.metadata:
+                            self.metadata["bed_type"] = value
 
-                    if printable_objects:
-                        self.metadata["printable_objects"] = printable_objects
+                    # Per-plate object lists are only kept at the file level
+                    # when there's one plate — the skip-object affordance
+                    # operates on the plate being printed, which is the
+                    # `/plates` endpoint's job for multi-plate exports.
+                    if len(plates) == 1:
+                        if plate_index_value is not None:
+                            if not self.plate_number:
+                                self.plate_number = plate_index_value
+                            self.metadata["_plate_index"] = plate_index_value
+
+                        printable_objects: dict[int, str] = {}
+                        for obj in plate.findall("object"):
+                            identify_id = obj.get("identify_id")
+                            name = obj.get("name")
+                            skipped = obj.get("skipped", "false")
+                            if identify_id and name and skipped.lower() != "true":
+                                try:
+                                    printable_objects[int(identify_id)] = name
+                                except ValueError:
+                                    pass  # Skip objects with non-numeric identify_id
+                        if printable_objects:
+                            self.metadata["printable_objects"] = printable_objects
+
+                if any_time_seen:
+                    self.metadata["print_time_seconds"] = summed_time
+                if any_grams_seen:
+                    self.metadata["filament_used_grams"] = round(summed_grams, 2)
 
                 # Get filament info from filaments ACTUALLY USED in the print
                 # slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
@@ -1084,7 +1114,9 @@ class ArchiveService:
         archive_name = f"{timestamp}_{display_stem}"
         # Use "unassigned" folder for archives without a printer
         printer_folder = str(printer_id) if printer_id is not None else "unassigned"
-        archive_dir = settings.archive_dir / printer_folder / archive_name
+        archive_dir = (
+            settings.archive_dir / printer_folder / archive_name
+        )  # SEC-PATH-OK: printer_folder = str(int|None) → digits or "unassigned"; archive_name = f"{timestamp}_{display_stem}" where resolve_display_stem strips path components via Path(filename).name
         archive_dir.mkdir(parents=True, exist_ok=True)
 
         # Copy 3MF file with an explicit fsync'd loop (avoids a sendfile
@@ -1448,12 +1480,29 @@ class ArchiveService:
             return False
 
         # Get archive directory
-        file_path = settings.base_dir / archive.file_path
+        file_path = (
+            settings.base_dir / archive.file_path
+        )  # SEC-PATH-OK: archive.file_path is DB-stored, set by archive_print() under settings.archive_dir
         archive_dir = file_path.parent
 
         # Save timelapse - use thread pool to avoid blocking event loop
-        # (timelapse files can be 100MB+, sync write blocks for seconds)
-        timelapse_file = archive_dir / filename
+        # (timelapse files can be 100MB+, sync write blocks for seconds).
+        # `filename` ultimately comes from a printer's FTP listing (compromised-
+        # printer threat model) or a query param on /archives/{id}/timelapse/select;
+        # the safe-join helper rejects ``..`` segments and absolute paths so a
+        # crafted name can't escape the archive directory. Use http=False so a
+        # service-layer reject surfaces as a return False (matching the existing
+        # not-found contract) rather than a 400 raised from inside a background
+        # task.
+        try:
+            timelapse_file = safe_join_under(archive_dir, filename, http=False)
+        except PathTraversalError:
+            logger.warning(
+                "Refusing to attach timelapse with unsafe filename %r to archive %s",
+                filename,
+                archive_id,
+            )
+            return False
         await asyncio.to_thread(timelapse_file.write_bytes, timelapse_data)
 
         # Update archive record

+ 3 - 16
backend/app/services/background_dispatch.py

@@ -32,6 +32,7 @@ from backend.app.services.bambu_ftp import (
     with_ftp_retry,
 )
 from backend.app.services.printer_manager import printer_manager
+from backend.app.utils.filename import derive_remote_filename
 
 logger = logging.getLogger(__name__)
 
@@ -580,14 +581,7 @@ class BackgroundDispatchService:
             if not file_path.exists():
                 raise RuntimeError("Archive file not found")
 
-            base_name = archive.filename
-            if base_name.endswith(".gcode.3mf"):
-                base_name = base_name[:-10]
-            elif base_name.endswith(".3mf"):
-                base_name = base_name[:-4]
-            remote_filename = f"{base_name}.3mf"
-            # Sanitize: firmware parses ftp://{filename} as a URL, spaces break it
-            remote_filename = remote_filename.replace(" ", "_")
+            remote_filename = derive_remote_filename(archive.filename)
             remote_path = f"/{remote_filename}"
 
             ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
@@ -784,14 +778,7 @@ class BackgroundDispatchService:
 
             await db.flush()
 
-            base_name = lib_file.filename
-            if base_name.endswith(".gcode.3mf"):
-                base_name = base_name[:-10]
-            elif base_name.endswith(".3mf"):
-                base_name = base_name[:-4]
-            remote_filename = f"{base_name}.3mf"
-            # Sanitize: firmware parses ftp://{filename} as a URL, spaces break it
-            remote_filename = remote_filename.replace(" ", "_")
+            remote_filename = derive_remote_filename(lib_file.filename)
             remote_path = f"/{remote_filename}"
 
             ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()

+ 62 - 3
backend/app/services/bambu_cloud.py

@@ -22,6 +22,50 @@ BAMBU_API_BASE_CN = "https://api.bambulab.cn"
 # introduce ourselves as official Bambu Studio.
 _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
 
+# Cloudflare protection on Bambu Lab's edge intermittently returns interstitials /
+# challenges instead of the JSON the API normally produces (issue #1575). The
+# parse error that results is opaque — these helpers detect the CF markers so
+# we can surface an actionable message instead of "Invalid response from Bambu Cloud".
+_CF_INTERSTITIAL_USER_MESSAGE = (
+    "Bambu Cloud is temporarily blocking automated requests from your network. "
+    "This is a Cloudflare protection on Bambu Lab's side, not a Bambuddy issue. "
+    "Please wait a few minutes and try again. If it persists, signing in to "
+    "bambulab.com once from a browser on the same network usually clears the "
+    "challenge."
+)
+
+
+def _detect_cloudflare_challenge(response) -> str | None:
+    """Return a user-actionable message when the response is a Cloudflare
+    challenge / mitigation page instead of the JSON the API normally returns.
+
+    Triggers on any of:
+      - body contains "Just a moment..." (CF interactive challenge title)
+      - body contains "challenges.cloudflare.com" (CF turnstile widget src)
+      - HTTP 403 with a "cf-mitigated" response header (CF blocked)
+      - HTTP 503 with a "cf-ray" response header (CF Under Attack mode)
+
+    Returns None when the response doesn't look like a CF challenge — callers
+    fall through to their existing error path.
+    """
+    try:
+        body = response.text or ""
+    except Exception:
+        body = ""
+    if "Just a moment..." in body or "challenges.cloudflare.com" in body:
+        return _CF_INTERSTITIAL_USER_MESSAGE
+    try:
+        status = int(getattr(response, "status_code", 0) or 0)
+    except (TypeError, ValueError):
+        status = 0
+    headers = getattr(response, "headers", {}) or {}
+    if status == 403 and "cf-mitigated" in headers:
+        return _CF_INTERSTITIAL_USER_MESSAGE
+    if status == 503 and "cf-ray" in headers:
+        return _CF_INTERSTITIAL_USER_MESSAGE
+    return None
+
+
 # The `/v1/iot-service/api/slicer/setting` endpoint requires a `version` query
 # parameter in the XX.YY.ZZ.WW format Bambu Studio releases use (without it the
 # API returns HTTP 400 "field 'version' is not set"; non-matching formats like
@@ -114,7 +158,16 @@ class BambuCloudService:
                 },
             )
 
-            data = response.json()
+            try:
+                data = response.json()
+            except Exception as json_err:
+                logger.error("Failed to parse login response: %s, body: %s", json_err, response.text[:500])
+                cf_message = _detect_cloudflare_challenge(response)
+                return {
+                    "success": False,
+                    "needs_verification": False,
+                    "message": cf_message or "Invalid response from Bambu Cloud",
+                }
             logger.debug(
                 f"Login response: status={response.status_code}, loginType={data.get('loginType')}, hasTfaKey={'tfaKey' in data}"
             )
@@ -170,7 +223,12 @@ class BambuCloudService:
                 },
             )
 
-            data = response.json()
+            try:
+                data = response.json()
+            except Exception as json_err:
+                logger.error("Failed to parse email-verify response: %s, body: %s", json_err, response.text[:500])
+                cf_message = _detect_cloudflare_challenge(response)
+                return {"success": False, "message": cf_message or "Invalid response from Bambu Cloud"}
             logger.debug("Email verify response: status=%s, hasToken=%s", response.status_code, "accessToken" in data)
 
             if response.status_code == 200 and "accessToken" in data:
@@ -230,7 +288,8 @@ class BambuCloudService:
                 data = response.json()
             except Exception as json_err:
                 logger.error("Failed to parse TOTP response: %s, body: %s", json_err, response.text[:500])
-                return {"success": False, "message": "Invalid response from Bambu Cloud"}
+                cf_message = _detect_cloudflare_challenge(response)
+                return {"success": False, "message": cf_message or "Invalid response from Bambu Cloud"}
 
             # Token might be in accessToken, token field, or cookies
             access_token = data.get("accessToken") or data.get("token")

+ 3 - 1
backend/app/services/camera.py

@@ -651,7 +651,9 @@ async def capture_finish_photo(
     # Generate filename with timestamp
     timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
     filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
-    output_path = photos_dir / filename
+    output_path = (
+        photos_dir / filename
+    )  # SEC-PATH-OK: filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg" generated above
 
     success = await capture_camera_frame(
         ip_address=ip_address,

+ 19 - 2
backend/app/services/failure_analysis.py

@@ -75,6 +75,11 @@ class FailureAnalysisService:
         total_result = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*base_filter)))
         total_prints = total_result.scalar() or 0
 
+        successful_result = await self.db.execute(
+            select(func.count(PrintLogEntry.id)).where(and_(*base_filter, PrintLogEntry.status == "completed"))
+        )
+        successful_prints = successful_result.scalar() or 0
+
         failed_result = await self.db.execute(
             select(func.count(PrintLogEntry.id)).where(
                 and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
@@ -82,7 +87,14 @@ class FailureAnalysisService:
         )
         failed_prints = failed_result.scalar() or 0
 
-        failure_rate = (failed_prints / total_prints * 100) if total_prints > 0 else 0
+        # Failure rate divides by quality-outcome prints only — a cancelled or
+        # skipped print is neither a success nor a failure of the printer, so
+        # including it in the denominator silently lowered the displayed rate
+        # whenever the user stopped jobs (#1390). Total Prints (the absolute
+        # count incl. cancelled) is still returned separately for the "X / Y
+        # prints failed" caption.
+        outcome_prints = successful_prints + failed_prints
+        failure_rate = (failed_prints / outcome_prints * 100) if outcome_prints > 0 else 0
 
         # Failures by reason
         reason_result = await self.db.execute(
@@ -188,6 +200,9 @@ class FailureAnalysisService:
             ]
 
             week_total = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*week_filter)))
+            week_successful = await self.db.execute(
+                select(func.count(PrintLogEntry.id)).where(and_(*week_filter, PrintLogEntry.status == "completed"))
+            )
             week_failed = await self.db.execute(
                 select(func.count(PrintLogEntry.id)).where(
                     and_(*week_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
@@ -195,8 +210,10 @@ class FailureAnalysisService:
             )
 
             total = week_total.scalar() or 0
+            successful = week_successful.scalar() or 0
             failed = week_failed.scalar() or 0
-            rate = (failed / total * 100) if total > 0 else 0
+            week_outcome = successful + failed
+            rate = (failed / week_outcome * 100) if week_outcome > 0 else 0
 
             trend_data.append(
                 {

+ 3 - 1
backend/app/services/library_trash.py

@@ -64,7 +64,9 @@ def _to_absolute_path(relative_path: str | None) -> Path | None:
     path = Path(relative_path)
     if path.is_absolute():
         return path
-    return Path(app_settings.base_dir) / path
+    return (
+        Path(app_settings.base_dir) / path
+    )  # SEC-PATH-OK: relative_path is LibraryFile.file_path / LibraryFile.thumbnail_path — DB-stored, internally generated by the upload pipeline
 
 
 def _age_cutoff(now: datetime, older_than_days: int) -> datetime:

+ 6 - 2
backend/app/services/local_backup.py

@@ -223,7 +223,9 @@ class LocalBackupService:
         if not filename.startswith("bambuddy-backup-") or not filename.endswith(".zip"):
             return None
         backup_dir = self._resolve_backup_dir(path_setting)
-        target = backup_dir / filename
+        target = (
+            backup_dir / filename
+        )  # SEC-PATH-OK: filename rejected above on /, \\, .., plus startswith "bambuddy-backup-" + endswith ".zip" gate
         if not target.exists():
             return None
         return target
@@ -253,7 +255,9 @@ class LocalBackupService:
             return {"success": False, "message": "Invalid filename"}
 
         backup_dir = self._resolve_backup_dir(path_setting)
-        target = backup_dir / filename
+        target = (
+            backup_dir / filename
+        )  # SEC-PATH-OK: filename rejected above on /, \\, .., plus startswith "bambuddy-backup-" + endswith ".zip" gate below
 
         if not target.exists():
             return {"success": False, "message": "Backup not found"}

+ 55 - 3
backend/app/services/notification_service.py

@@ -20,6 +20,47 @@ from backend.app.models.notification_template import NotificationTemplate
 
 logger = logging.getLogger(__name__)
 
+# Honest User-Agent — matches the convention used by every other outbound
+# httpx client in the codebase (bambu_cloud, makerworld, firmware_check,
+# inventory). Previously this client leaked python-httpx/<version>, which
+# was both inconsistent with the rest of the project and a more obvious
+# bot signature for upstream WAFs.
+_USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
+
+
+def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
+    """Return True if ``response`` looks like a Cloudflare mitigation
+    interstitial (JS challenge / managed challenge / block page) rather
+    than a legitimate response passed through Cloudflare.
+
+    Self-hosted servers behind Cloudflare (Tunnel, "Bot Fight Mode", or
+    "Under Attack" mode) intercept non-browser clients at the edge and
+    return a challenge HTML page instead of forwarding to the origin —
+    so we never reach the user's actual ntfy / webhook backend.
+    Cloudflare cannot be defeated from a Python client; the user has to
+    add a security-skip rule on their side. We detect the shape so the
+    UI can tell them that, instead of dumping the raw HTML.
+
+    Detection deliberately does NOT rely on ``Server: cloudflare`` alone
+    — Cloudflare adds that header to every response it proxies (success
+    AND legitimate origin errors), so a real 401 "wrong token" from a
+    CF-fronted ntfy would false-positive into a misleading "your CF is
+    blocking" message. Reliable signals: the ``cf-mitigated`` header
+    (set only when CF actively mitigates) and the challenge body shape.
+    """
+    if response.headers.get("cf-mitigated"):
+        return True
+    content_type = (response.headers.get("content-type") or "").lower()
+    if "html" not in content_type:
+        return False
+    body = (response.text or "")[:1024].lower()
+    # "Just a moment..." is Cloudflare's universal challenge-page title
+    # (managed challenge, JS challenge, Under Attack mode). Combined with
+    # an HTML content-type this is unambiguous — no legitimate ntfy or
+    # webhook backend returns HTML with that title. ``cf-chl-*`` and
+    # ``challenge-platform`` cover newer / non-default CF templates.
+    return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
+
 
 class NotificationService:
     """Service for sending notifications through various providers."""
@@ -33,7 +74,10 @@ class NotificationService:
     async def _get_client(self) -> httpx.AsyncClient:
         """Get or create HTTP client."""
         if self._http_client is None or self._http_client.is_closed:
-            self._http_client = httpx.AsyncClient(timeout=30.0)
+            self._http_client = httpx.AsyncClient(
+                timeout=30.0,
+                headers={"User-Agent": _USER_AGENT},
+            )
         return self._http_client
 
     async def close(self):
@@ -264,8 +308,16 @@ class NotificationService:
 
         if response.status_code in (200, 204):
             return True, "Message sent successfully"
-        else:
-            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+        if _looks_like_cloudflare_challenge(response):
+            return False, (
+                f"HTTP {response.status_code} — ntfy server is behind a Cloudflare "
+                "challenge. Bambuddy was served the JS challenge page instead of "
+                "reaching ntfy. Cloudflare cannot be solved from a backend; add a "
+                "Cloudflare security-skip rule for this hostname, disable Bot "
+                "Fight Mode, or front the server with Cloudflare Access using a "
+                "service token. (#1534)"
+            )
+        return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
     async def _send_pushover(
         self, config: dict, title: str, message: str, image_data: bytes | None = None

+ 2 - 10
backend/app/services/print_scheduler.py

@@ -32,6 +32,7 @@ from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager, supports_drying
 from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.utils.filename import derive_remote_filename
 from backend.app.utils.printer_models import normalize_printer_model
 
 logger = logging.getLogger(__name__)
@@ -2013,18 +2014,9 @@ class PrintScheduler:
             except Exception as e:
                 logger.warning("Queue item %s: G-code injection failed, using original: %s", item.id, e)
 
-        # Upload file to printer via FTP
-        # Use a clean filename to avoid issues with double extensions like .gcode.3mf
-        base_name = filename
-        if base_name.endswith(".gcode.3mf"):
-            base_name = base_name[:-10]  # Remove .gcode.3mf
-        elif base_name.endswith(".3mf"):
-            base_name = base_name[:-4]  # Remove .3mf
-        remote_filename = f"{base_name}.3mf"
-        # Sanitize: firmware parses ftp://{filename} as a URL, spaces break it
-        remote_filename = remote_filename.replace(" ", "_")
         # Upload to root directory (not /cache/) - the start_print command references
         # files by name only (ftp://{filename}), so they must be in the root
+        remote_filename = derive_remote_filename(filename)
         remote_path = f"/{remote_filename}"
 
         # Get FTP retry settings

+ 9 - 1
backend/app/services/spool_tag_matcher.py

@@ -100,7 +100,15 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     rgba = tray_color if tray_color else None
     color_name = None
 
-    if rgba and len(rgba) >= 6:
+    # Transparent filament (#1545): the AMS reports alpha=00 for clear spools.
+    # Skip the catalog lookup — the catalog only stores RGB so 000000 would
+    # resolve to "Black" (or whatever else lives at that RGB), which is exactly
+    # the bug the cream rewrite in parse_ams_tray used to paper over. Store
+    # "Clear" directly and let the frontend's resolveSpoolColorName +
+    # hexToColorName render the swatch as a checkerboard.
+    if rgba and len(rgba) == 8 and rgba[6:8].lower() == "00":
+        color_name = "Clear"
+    elif rgba and len(rgba) >= 6:
         hex_prefix = f"#{rgba[:6].upper()}"
         cat_query = (
             select(ColorCatalogEntry)

+ 7 - 4
backend/app/services/spoolman.py

@@ -909,10 +909,13 @@ class SpoolmanClient:
             logger.debug("Skipping tray with empty color")
             return None
 
-        # Handle transparent/natural filament (RRGGBBAA with alpha=00)
-        # Replace with cream color that represents how natural PLA actually looks
-        if tray_color == "00000000":
-            tray_color = "F5E6D3FF"  # Light cream/natural color
+        # Transparent filament (alpha=00) used to be rewritten to a cream
+        # "natural PLA" colour before being stored, because the swatch
+        # renderer couldn't show alpha. The swatch now paints a checkerboard
+        # underlay for translucent rgbas (see filamentSwatchHelpers.ts), so
+        # we pass `00000000` through verbatim — the inventory row keeps the
+        # AMS-reported colour and the frontend resolves the name to "Clear"
+        # via getColorName (#1545).
 
         # Get sub_brands, falling back to tray_type
         tray_sub_brands = tray_data.get("tray_sub_brands", "")

+ 3 - 1
backend/app/services/spoolman_tracking.py

@@ -212,7 +212,9 @@ async def store_print_data(
         return
 
     # Get 3MF file path
-    full_path = app_settings.base_dir / file_path
+    full_path = (
+        app_settings.base_dir / file_path
+    )  # SEC-PATH-OK: file_path is archive.file_path / library_file.file_path — DB-stored, internally generated
     if not full_path.exists():
         logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
         return

+ 62 - 2
backend/app/services/stl_thumbnail.py

@@ -4,11 +4,52 @@ Generates thumbnail images from STL files using trimesh and matplotlib.
 """
 
 import logging
+import os
 import uuid
 from pathlib import Path
 
 logger = logging.getLogger(__name__)
 
+# Matplotlib's font_manager emits one INFO line per font on first import
+# while it builds its cache, including a noisy "Failed to extract font
+# properties from NotoColorEmoji.ttf" for the COLR/COLR1 emoji format it
+# doesn't support. These are not actionable — demote to WARNING so real
+# font issues still surface but the first STL upload doesn't produce a
+# multi-line matplotlib preamble in the journal.
+logging.getLogger("matplotlib.font_manager").setLevel(logging.WARNING)
+
+
+def _configure_matplotlib_cache() -> None:
+    """Point matplotlib's config/cache directory at a writable persistent path.
+
+    Without this, matplotlib falls back to ``/tmp/matplotlib-XXXXXX`` whenever
+    ``$HOME/.config/matplotlib`` isn't writable — which is the case under
+    Bambuddy's container / systemd-service deployments where ``$HOME`` is set
+    to a non-writable path. The fallback emits a WARNING on every cold start
+    AND loses the font cache on host reboot, so font_manager rebuilds it
+    every time → another batch of INFO lines.
+
+    Setting ``MPLCONFIGDIR`` to ``settings.base_dir / .cache / matplotlib``
+    eliminates both: the warning never fires, and the cache survives across
+    restarts so the per-font scan only runs once per deployment.
+    Idempotent — respects an externally-set ``MPLCONFIGDIR`` if the operator
+    chose their own path.
+    """
+    if os.environ.get("MPLCONFIGDIR"):
+        return
+    try:
+        from backend.app.core.config import settings
+
+        cache_dir = Path(settings.base_dir) / ".cache" / "matplotlib"
+        cache_dir.mkdir(parents=True, exist_ok=True)
+        os.environ["MPLCONFIGDIR"] = str(cache_dir)
+    except Exception as exc:
+        # Best-effort. If settings isn't importable or the mkdir fails (read-only
+        # FS, permission denied), let matplotlib fall back to /tmp with its
+        # built-in warning — same as today's behaviour, no worse.
+        logger.debug("Could not configure MPLCONFIGDIR: %s", exc)
+
+
 # Bambu green color for rendering
 BAMBU_GREEN = "#00AE42"
 BACKGROUND_COLOR = "#1a1a1a"
@@ -16,6 +57,14 @@ BACKGROUND_COLOR = "#1a1a1a"
 # Maximum vertices before simplification
 MAX_VERTICES = 100000
 
+# Minimum STL file size that could possibly contain a usable mesh:
+# - Binary STL with one triangle: 80B header + 4B count + 50B triangle = 134B
+# - ASCII STL with one triangle: header + "facet ... endfacet" + footer ≈ 150B
+# Files below this are stubs / placeholders / corrupted; trimesh would return an
+# empty mesh anyway. Pre-skipping at the call sites suppresses the warning storm
+# bulk-uploaded ZIPs of small test STLs used to produce.
+MIN_USABLE_STL_BYTES = 200
+
 
 def generate_stl_thumbnail(
     stl_path: Path,
@@ -39,6 +88,10 @@ def generate_stl_thumbnail(
     thumbnails_dir = Path(thumbnails_dir)
 
     try:
+        # Must precede the matplotlib import — MPLCONFIGDIR is read at
+        # matplotlib import time, not on subsequent attribute access.
+        _configure_matplotlib_cache()
+
         import matplotlib
         import trimesh
 
@@ -52,7 +105,14 @@ def generate_stl_thumbnail(
         mesh = trimesh.load(str(stl_path), force="mesh")
 
         if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
-            logger.warning("Failed to load STL or empty mesh: %s", stl_path)
+            # Demoted from warning to debug: this is a per-file content
+            # observation (the STL is empty / stub / corrupted), not an
+            # actionable error. The caller proceeds correctly with no
+            # thumbnail. The call sites also pre-skip files below
+            # MIN_USABLE_STL_BYTES so the common stub-STL case never gets
+            # this far — this branch now catches only the rare "large
+            # enough but trimesh still can't parse it" case.
+            logger.debug("Failed to load STL or empty mesh: %s", stl_path)
             return None
 
         # Simplify large meshes for performance
@@ -122,7 +182,7 @@ def generate_stl_thumbnail(
 
         # Save thumbnail
         thumb_filename = f"{uuid.uuid4().hex}.png"
-        thumb_path = thumbnails_dir / thumb_filename
+        thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
 
         fig.savefig(
             thumb_path,

+ 14 - 2
backend/app/services/usage_tracker.py

@@ -1223,14 +1223,26 @@ async def _track_from_3mf(
                 if isinstance(mapped, int) and mapped >= 0:
                     global_tray_id = mapped
             # Position-based default: sort available tray IDs so external spools (254/255)
-            # naturally follow standard AMS trays, matching slicer slot numbering
+            # naturally follow standard AMS trays, matching slicer slot numbering.
+            #
+            # Filter out AMS slots that have no spool loaded (empty `tray_type`) —
+            # BambuStudio/OrcaSlicer compact the slot list when assigning filaments
+            # and don't expose empty AMS slots to the user, so the slicer's 3MF
+            # slot N maps to the Nth *loaded* tray, not the Nth physical position.
+            # Without this filter a "3 AMS slots loaded + 1 empty + external"
+            # layout routes the slicer's 4th filament to the empty AMS slot
+            # instead of the external (#1607), and the external's spool usage
+            # never gets recorded. vt_tray entries are already filtered the
+            # same way inside `build_ams_tray_lookup` (line 174 checks
+            # `tray_type`), so this just mirrors that for the AMS side.
             if global_tray_id is None:
                 _state = printer_manager.get_status(printer_id)
                 _raw = getattr(_state, "raw_data", None) if _state else None
                 if _raw:
                     from backend.app.services.spoolman_tracking import build_ams_tray_lookup
 
-                    available_trays = sorted(build_ams_tray_lookup(_raw).keys())
+                    _lookup = build_ams_tray_lookup(_raw)
+                    available_trays = sorted(gid for gid, info in _lookup.items() if info.get("tray_type"))
                     if slot_id <= len(available_trays):
                         global_tray_id = available_trays[slot_id - 1]
             # Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools)

+ 24 - 1
backend/app/services/virtual_printer/bind_server.py

@@ -64,6 +64,11 @@ class BindServer:
 
         self._servers: list[asyncio.Server] = []
         self._running = False
+        # Set after at least one bind port is listening — see ftp_server.py
+        # for rationale. Bind server is best-effort across BIND_PORTS, so
+        # "ready" means "at least one port bound", matching the existing
+        # serve_forever path.
+        self.ready = asyncio.Event()
 
     def _create_tls_context(self) -> ssl.SSLContext | None:
         """Create SSL context for the TLS bind port (3002)."""
@@ -72,6 +77,15 @@ class BindServer:
         ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
         ctx.load_cert_chain(str(self.cert_path), str(self.key_path))
         ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        # Match real Bambu printer cipher behaviour: include the plain-RSA
+        # AES-GCM suites the slicer's bind/connect path expects. On hardened
+        # distros (Fedora / RHEL with `update-crypto-policies`, hardened Alpine
+        # builds) the OpenSSL `DEFAULT` list strips these suites, leaving no
+        # overlap with the slicer's ClientHello and producing `code=-1` on the
+        # slicer side (#1610). Same fix the #620 client-side patch applied to
+        # `tcp_proxy.py::_create_client_ssl_context`; the bind-server / server
+        # side needs it too.
+        ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
         ctx.verify_mode = ssl.CERT_NONE
         return ctx
 
@@ -122,6 +136,7 @@ class BindServer:
             if not self._servers:
                 logger.error("Bind server: could not bind to any port")
                 return
+            self.ready.set()
 
             # Serve all successfully bound ports
             await asyncio.gather(*(s.serve_forever() for s in self._servers))
@@ -137,6 +152,7 @@ class BindServer:
         """Stop the bind server."""
         logger.info("Stopping bind server")
         self._running = False
+        self.ready.clear()
 
         for server in self._servers:
             try:
@@ -176,7 +192,14 @@ class BindServer:
                 logger.warning("Bind server: unexpected command from %s: %s", client_id, request)
                 return
 
-            # Build response
+            # Build response. `sequence_id` is an INTEGER counter chosen by
+            # the printer side (not an echo of the slicer's string seq_id).
+            # The protocol docstring at the top of this file documents the
+            # asymmetry: slicer sends `"20000"` (string), printer replies
+            # with an int. The hardcoded 3021 mirrors real-firmware-captured
+            # value; an earlier audit suggesting we echo the slicer's seq_id
+            # was wrong and would have broken slicers that validate the
+            # type (int vs string).
             response = {
                 "login": {
                     "bind": "free",

+ 58 - 4
backend/app/services/virtual_printer/certificate.py

@@ -72,10 +72,59 @@ class CertificateService:
             Tuple of (cert_path, key_path)
         """
         if self.cert_path.exists() and self.key_path.exists():
-            logger.debug("Using existing virtual printer certificates")
-            return self.cert_path, self.key_path
+            if self._cert_matches_current_ca():
+                logger.debug("Using existing virtual printer certificates")
+                return self.cert_path, self.key_path
+            logger.warning(
+                "Existing per-VP certificate's issuer doesn't match the current CA "
+                "(likely a CA rotation since the cert was signed). Regenerating "
+                "to keep the slicer's imported CA in sync with the served chain."
+            )
         return self.generate_certificates()
 
+    def _cert_matches_current_ca(self) -> bool:
+        """Check whether the on-disk per-VP cert was signed by the current CA.
+
+        Slicers that import the shared CA validate the per-VP cert against it.
+        If the CA has been rotated since the per-VP cert was signed, the chain
+        is broken even though both files exist on disk. ``ensure_certificates``
+        uses this to decide whether to regenerate.
+
+        Uses real signature verification — Bambuddy's auto-generated CAs all
+        share the same Subject DN ("Virtual Printer CA"), so a DN-only compare
+        would incorrectly return True even after rotation.
+        """
+        try:
+            if not self.ca_cert_path.exists():
+                # No CA yet — let generate_certificates create one and the
+                # matching per-VP chain.
+                return False
+            cert_pem = self.cert_path.read_bytes()
+            cert = x509.load_pem_x509_certificate(cert_pem)
+            ca_pem = self.ca_cert_path.read_bytes()
+            ca_cert = x509.load_pem_x509_certificate(ca_pem)
+            from cryptography.exceptions import InvalidSignature
+            from cryptography.hazmat.primitives.asymmetric import padding
+
+            try:
+                ca_cert.public_key().verify(
+                    cert.signature,
+                    cert.tbs_certificate_bytes,
+                    padding.PKCS1v15(),
+                    cert.signature_hash_algorithm,
+                )
+                return True
+            except InvalidSignature:
+                return False
+        except (OSError, ValueError) as e:
+            logger.debug("CA-match probe failed for %s: %s", self.cert_path, e)
+            return False
+        except Exception as e:
+            # Any unexpected exception during verification → treat as mismatch
+            # and regenerate. Safer than reusing a cert we can't validate.
+            logger.debug("CA-match verification failed for %s: %s", self.cert_path, e)
+            return False
+
     def _load_existing_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate] | None:
         """Try to load existing CA certificate and key.
 
@@ -123,8 +172,13 @@ class CertificateService:
         # Generate new CA
         ca_key, ca_cert = self._generate_ca_certificate()
 
-        # Save CA certificate and key
-        self.cert_dir.mkdir(parents=True, exist_ok=True)
+        # Save CA certificate and key. ``ca_key_path`` and ``ca_cert_path``
+        # resolve under ``shared_ca_dir`` (which may differ from cert_dir),
+        # so the parent we need to mkdir is the CA file's parent — not
+        # cert_dir. Previously this created the per-VP subdirectory while
+        # the writes targeted the parent CA dir, which works only because
+        # the manager pre-creates both — the method itself was latent.
+        self.ca_key_path.parent.mkdir(parents=True, exist_ok=True)
         self.ca_key_path.write_bytes(
             ca_key.private_bytes(
                 encoding=serialization.Encoding.PEM,

+ 15 - 2
backend/app/services/virtual_printer/diagnostic.py

@@ -26,6 +26,7 @@ logger = logging.getLogger(__name__)
 PORT_FTPS = 990  # implicit FTPS — slicer file upload
 PORT_MQTT = 8883  # MQTT over TLS — control + status
 PORT_BIND = 3002  # bind/detect (TLS) — slicer discovery handshake
+PORT_BIND_PLAIN = 3000  # bind/detect (plain) — legacy / some slicer models
 
 _PORT_PROBE_TIMEOUT = 2.0
 
@@ -134,14 +135,26 @@ async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult:
         )
         checks.append(DiagnosticCheck(id="port_bind", status="skip", params={"port": PORT_BIND}))
     else:
-        ftp_ok, mqtt_ok, bind_ok = await asyncio.gather(
+        # The non-proxy bind server listens on BOTH 3000 (plain) and 3002
+        # (TLS) per bind_server.py BIND_PORTS — slicers pick either path.
+        # Probing only 3002 missed half-dead VPs where one listener failed
+        # to start and the other succeeded; report port_bind as pass only
+        # when both probes succeed.
+        ftp_ok, mqtt_ok, bind_tls_ok, bind_plain_ok = await asyncio.gather(
             _check_port(bind_ip, PORT_FTPS),
             _check_port(bind_ip, PORT_MQTT),
             _check_port(bind_ip, PORT_BIND),
+            _check_port(bind_ip, PORT_BIND_PLAIN),
         )
         checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftp_ok else "fail", params={"port": PORT_FTPS}))
         checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail", params={"port": PORT_MQTT}))
-        checks.append(DiagnosticCheck(id="port_bind", status="pass" if bind_ok else "fail", params={"port": PORT_BIND}))
+        checks.append(
+            DiagnosticCheck(
+                id="port_bind",
+                status="pass" if (bind_tls_ok and bind_plain_ok) else "fail",
+                params={"port": PORT_BIND, "port_plain": PORT_BIND_PLAIN},
+            )
+        )
 
     # --- TLS certificate ---
     # When running, the cert chain must exist on disk for the slicer's TLS

+ 119 - 46
backend/app/services/virtual_printer/ftp_server.py

@@ -8,6 +8,7 @@ immediately upon connection, before any FTP commands are exchanged.
 """
 
 import asyncio
+import hmac
 import logging
 import os
 import random
@@ -24,6 +25,14 @@ logger = logging.getLogger(__name__)
 # Requires CAP_NET_BIND_SERVICE or root.
 FTP_PORT = 990
 
+# Hard cap on a single upload. 4 GiB covers the largest realistic
+# multi-plate .gcode.3mf and rejects runaway / malicious clients before
+# they can exhaust the disk or OOM the host. STOR still buffers the
+# whole file in memory before write_bytes — peak RSS ~2x file size during
+# the b''.join — so the cap also caps that peak. If real users hit it
+# with a legitimate file, raise here.
+MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024  # 4 GiB
+
 
 class FTPSession:
     """Handles a single FTP client session."""
@@ -162,7 +171,10 @@ class FTPSession:
     async def cmd_PASS(self, arg: str) -> None:
         """Handle PASS command."""
         if self.username and self.username.lower() == "bblp":
-            if arg == self.access_code:
+            # ``hmac.compare_digest`` is constant-time — keeps the auth check
+            # from leaking the access code via response timing under network
+            # jitter. LAN-only threat is marginal; this is the standard fix.
+            if hmac.compare_digest(arg, self.access_code):
                 self.authenticated = True
                 await self.send(230, "Login successful")
                 logger.info("%sFTP login from %s", self._log_prefix, self.remote_ip)
@@ -380,7 +392,19 @@ class FTPSession:
             await asyncio.sleep(0.1)
 
     async def cmd_STOR(self, arg: str) -> None:
-        """Handle STOR command - receive file upload."""
+        """Handle STOR command - receive file upload.
+
+        Streams each chunk directly to disk inside the receive loop instead
+        of buffering the whole file in a ``list[bytes]`` and joining at the
+        end. Wire protocol unchanged — same 150/226/426 sequence, same
+        single-write target path (no ``.part`` or atomic rename), no new
+        verbs, no concurrency guard. The visible behaviour difference is
+        that the destination file grows progressively during upload rather
+        than appearing all-at-once on completion; slicers don't LIST during
+        STOR, so this isn't observable. Peak RSS for a multi-GB upload
+        drops from ~2× file size to one chunk (64 KiB).
+        ``MAX_UPLOAD_BYTES`` cap kept — purely server-internal DoS guard.
+        """
         if not self.authenticated:
             await self.send(530, "Not logged in")
             return
@@ -390,7 +414,9 @@ class FTPSession:
             return
 
         filename = Path(arg).name  # Sanitize filename
-        file_path = self.upload_dir / filename
+        file_path = (
+            self.upload_dir / filename
+        )  # SEC-PATH-OK: filename = Path(arg).name strips every path component above
 
         logger.info("FTP receiving file: %s from %s", filename, self.remote_ip)
 
@@ -410,22 +436,23 @@ class FTPSession:
             await self._close_data_connection()
             return
 
-        # Receive data
-        data_content: list[bytes] = []
+        # Receive + stream to disk
         total_received = 0
+        write_failed: Exception | None = None
         try:
-            while True:
-                chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)
-                if not chunk:
-                    break
-                data_content.append(chunk)
-                total_received += len(chunk)
-                logger.debug("FTP received chunk: %s bytes (total: %s)", len(chunk), total_received)
+            with file_path.open("wb") as f:
+                while True:
+                    chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)
+                    if not chunk:
+                        break
+                    total_received += len(chunk)
+                    if total_received > MAX_UPLOAD_BYTES:
+                        raise OSError(f"upload exceeded size cap ({total_received} > {MAX_UPLOAD_BYTES} bytes)")
+                    f.write(chunk)
+                    logger.debug("FTP received chunk: %s bytes (total: %s)", len(chunk), total_received)
         except TimeoutError:
             logger.error("FTP data transfer timeout after %s bytes for %s", total_received, filename)
-            await self.send(426, "Transfer timeout")
-            await self._close_data_connection()
-            return
+            write_failed = TimeoutError("Transfer timeout")
         except Exception as e:
             logger.error(
                 "FTP data transfer error after %s bytes for %s: %s(%s)",
@@ -434,32 +461,32 @@ class FTPSession:
                 type(e).__name__,
                 e,
             )
-            await self.send(426, f"Transfer failed: {e}")
-            await self._close_data_connection()
-            return
+            write_failed = e
 
         # Close data connection
         await self._close_data_connection()
 
-        # Write file
-        try:
-            total_size = sum(len(c) for c in data_content)
-            file_path.write_bytes(b"".join(data_content))
-            logger.info("FTP saved file: %s (%s bytes)", file_path, total_size)
-            await self.send(226, "Transfer complete")
+        if write_failed is not None:
+            # Drop the partial file so it doesn't masquerade as a complete
+            # upload — buffer-then-write never had a partial-file footprint.
+            try:
+                file_path.unlink(missing_ok=True)
+            except OSError:
+                pass
+            await self.send(426, f"Transfer failed: {write_failed}")
+            return
 
-            # Notify callback
-            if self.on_file_received:
-                try:
-                    result = self.on_file_received(file_path, self.remote_ip)
-                    if asyncio.iscoroutine(result):
-                        await result
-                except Exception as e:
-                    logger.error("File received callback error: %s", e)
+        # Confirm + notify
+        logger.info("FTP saved file: %s (%s bytes)", file_path, total_received)
+        await self.send(226, "Transfer complete")
 
-        except Exception as e:
-            logger.error("Failed to save file %s: %s", file_path, e)
-            await self.send(550, "Failed to save file")
+        if self.on_file_received:
+            try:
+                result = self.on_file_received(file_path, self.remote_ip)
+                if asyncio.iscoroutine(result):
+                    await result
+            except Exception as e:
+                logger.error("File received callback error: %s", e)
 
     async def cmd_SIZE(self, arg: str) -> None:
         """Handle SIZE command."""
@@ -514,7 +541,18 @@ class FTPSession:
         await self.send(257, f'"{arg}" directory created')
 
     async def cmd_LIST(self, arg: str) -> None:
-        """Handle LIST command - list directory contents."""
+        """Handle LIST command - list directory contents.
+
+        Intentionally answers 150 + 226 without opening the passive data
+        channel. Bambuddy is an upload-only VP — no slicer in capture logs
+        actually issues LIST during the project_file flow, so the
+        no-data-conn ack is what every observed slicer accepts. A previous
+        audit recommended opening + closing the data conn for protocol
+        purity; reverted because (a) the bug was theoretical, (b) slicer
+        compatibility matters more than RFC purity here, and (c) adding
+        NLST/MLSD alongside changes the "supported verbs" surface in a way
+        we cannot regression-test without every supported slicer build.
+        """
         if not self.authenticated:
             await self.send(530, "Not logged in")
             return
@@ -526,8 +564,14 @@ class FTPSession:
 class VirtualPrinterFTPServer:
     """Implicit FTPS server that accepts uploads from slicers."""
 
+    # Passive-mode data port range. Widened from 50000-50100 (101 ports) to
+    # 50000-51000 (1001 ports) so concurrent transfers across multiple VPs
+    # — particularly when a VP falls back to bind 0.0.0.0 (manager.py picks
+    # this when bind_ip is unset) — don't collide. With 101 ports and 10
+    # random pick attempts per session, birthday-style collisions hit
+    # under load; 1001 ports gives multi-VP setups headroom.
     PASSIVE_PORT_MIN = 50000
-    PASSIVE_PORT_MAX = 50100
+    PASSIVE_PORT_MAX = 51000
 
     def __init__(
         self,
@@ -562,6 +606,12 @@ class VirtualPrinterFTPServer:
         self.vp_name = vp_name
         self._server: asyncio.Server | None = None
         self._running = False
+        # Set after the socket is bound and the server is accepting connections,
+        # so VirtualPrinterInstance.start_server can wait for readiness before
+        # reporting is_running=True. Without this, a caller racing the start
+        # could probe the port and see "connection refused" while is_running
+        # already says yes.
+        self.ready = asyncio.Event()
         self._ssl_context: ssl.SSLContext | None = None
         self._active_sessions: list[asyncio.Task] = []
         # Override PASV response IP for Docker bridge mode / NAT environments
@@ -579,14 +629,33 @@ class VirtualPrinterFTPServer:
         cache_dir = self.upload_dir / "cache"
         cache_dir.mkdir(exist_ok=True)
 
-        # Create SSL context for implicit FTPS (TLS from byte 0)
+        # Create SSL context for implicit FTPS (TLS from byte 0).
+        # Pinned to TLS 1.2 only. Allowing 1.3 broke BambuStudio mid-upload
+        # in the field (session_reused=True on data channel via PSK + libcurl
+        # CURLE_PARTIAL_FILE / RST after ~80 KiB; "server did not report OK,
+        # got 426"). Real Bambu printers also serve their FTPS at 1.2 only,
+        # and the slicer expects to match that. A future slicer drop of 1.2
+        # is a problem to solve when it actually happens; until then 1.2 is
+        # mandatory for compat.
         self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
         self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
         self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
         self._ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
 
-        # Use standard TLS settings for compatibility
-        self._ssl_context.set_ciphers("HIGH:!aNULL:!MD5:!RC4")
+        # Keep the historical `HIGH:!aNULL:!MD5:!RC4` baseline so the cipher
+        # set stays a strict superset of what shipped before (the previous
+        # set offered ~58 extra suites — CCM, ARIA, CAMELLIA, DSS variants —
+        # that no Bambu slicer is known to pick, but the
+        # [[feedback_dont_remove_compat_pinning]] HARD RULE says don't
+        # narrow a compat surface without proof). The two explicit additions
+        # cover the #1610 case on hardened distros (Fedora / RHEL with
+        # `update-crypto-policies`, hardened Alpine builds) where the system
+        # policy strips the plain-RSA `AES256-GCM-SHA384` / `AES128-GCM-SHA256`
+        # suites from `HIGH` — without them present the slicer's FTPS
+        # ClientHello (which mimics the cipher set real Bambu printers offer)
+        # finds no overlap and the handshake aborts. Listing them explicitly
+        # survives any system policy that strips them from `HIGH`.
+        self._ssl_context.set_ciphers("HIGH:AES256-GCM-SHA384:AES128-GCM-SHA256:!aNULL:!MD5:!RC4")
 
         logger.info("FTP SSL context created with standard settings")
 
@@ -599,6 +668,7 @@ class VirtualPrinterFTPServer:
                 ssl=self._ssl_context,  # This makes it implicit FTPS!
             )
             self._running = True
+            self.ready.set()
 
             logger.info("Implicit FTPS server started on port %s", self.port)
             logger.info(
@@ -657,14 +727,17 @@ class VirtualPrinterFTPServer:
         """Stop the FTPS server."""
         logger.info("Stopping FTP server")
         self._running = False
-
-        # Cancel all active sessions first
-        for task in self._active_sessions[:]:  # Copy list to avoid modification during iteration
+        self.ready.clear()
+
+        # Cancel all active sessions and AWAIT cancellation. Previously
+        # this slept 0.1 s and called it good — a session mid-write,
+        # mid-TLS handshake, or holding a 60 s data-read could easily
+        # outlive that and then ``_server.close()`` would run while the
+        # underlying sockets were still in use.
+        for task in self._active_sessions[:]:
             task.cancel()
-
-        # Wait briefly for sessions to clean up
         if self._active_sessions:
-            await asyncio.sleep(0.1)
+            await asyncio.gather(*self._active_sessions, return_exceptions=True)
 
         self._active_sessions.clear()
 

+ 197 - 24
backend/app/services/virtual_printer/manager.py

@@ -12,6 +12,11 @@ from pathlib import Path
 from typing import TYPE_CHECKING
 
 from backend.app.core.config import settings as app_settings
+from backend.app.models.virtual_printer import (
+    VP_MODE_ARCHIVE,
+    VP_MODE_QUEUE,
+    normalize_vp_mode,
+)
 from backend.app.services.virtual_printer.bind_server import BindServer
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
@@ -47,6 +52,8 @@ VIRTUAL_PRINTER_MODELS = {
     "N1": "A1 Mini",  # A1 Mini
     # H2 Series
     "O1D": "H2D",  # H2D
+    "O1E": "H2D Pro",  # H2D Pro
+    "O2D": "H2D Pro",  # H2D Pro
     "O1C": "H2C",  # H2C
     "O1C2": "H2C",  # H2C (dual nozzle variant)
     "O1S": "H2S",  # H2S
@@ -77,6 +84,8 @@ MODEL_SERIAL_PREFIXES = {
     "N1": "03000A",  # A1 Mini
     # H2 Series
     "O1D": "09400A",  # H2D
+    "O1E": "09400A",  # H2D Pro (same prefix family as H2D)
+    "O2D": "09400A",  # H2D Pro
     "O1C": "09400A",  # H2C
     "O1C2": "09400A",  # H2C (dual nozzle variant)
     "O1S": "09400A",  # H2S
@@ -88,6 +97,14 @@ DISPLAY_NAME_TO_MODEL_CODE = {v: k for k, v in VIRTUAL_PRINTER_MODELS.items()}
 # Default model
 DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001"  # X1C
 
+# Bound on per-instance ``_slicer_print_options`` cache size. The slicer's
+# project_file MQTT command stashes one dict per filename; the
+# corresponding ``_add_to_print_queue`` pop only fires when the file
+# upload completes. Failed / cancelled / non-3MF uploads orphan their
+# stash. The bound triggers FIFO eviction in ``on_print_command`` once
+# the dict fills, so a long-running VP can't leak unbounded state.
+_SLICER_OPTIONS_CACHE_LIMIT = 128
+
 
 def _get_serial_for_model(model: str, serial_suffix: str) -> str:
     """Get serial number for the given model and suffix."""
@@ -125,7 +142,11 @@ class VirtualPrinterInstance:
     ):
         self.id = vp_id
         self.name = name
-        self.mode = mode
+        # Normalize on construction so the rest of the code only compares
+        # canonical values, even when a legacy DB row hasn't been migrated
+        # yet (e.g. fresh-from-disk during the boot window before the
+        # one-shot migration in `core/database.py` has executed).
+        self.mode = normalize_vp_mode(mode) or VP_MODE_ARCHIVE
         self.model = model
         self.access_code = access_code
         self.serial_suffix = serial_suffix
@@ -222,9 +243,14 @@ class VirtualPrinterInstance:
 
         self._pending_files[file_path.name] = file_path
 
-        if self.mode == "immediate":
+        # Accept both canonical (`archive`/`queue`) and legacy
+        # (`immediate`/`print_queue`) wire values so a stale row that hasn't
+        # been migrated yet still dispatches correctly. Migration in
+        # `core/database.py` rewrites existing rows once at boot.
+        mode = normalize_vp_mode(self.mode)
+        if mode == VP_MODE_ARCHIVE:
             await self._archive_file(file_path, source_ip)
-        elif self.mode == "print_queue":
+        elif mode == VP_MODE_QUEUE:
             await self._add_to_print_queue(file_path, source_ip)
         else:
             await self._queue_file(file_path, source_ip)
@@ -241,6 +267,12 @@ class VirtualPrinterInstance:
         # against the same field during the upload window.
         if self._mqtt and file_path.suffix.lower() == ".3mf":
             self._mqtt.set_gcode_state("FINISH", filename=file_path.name, prepare_percent="100")
+            # FINISH is the terminal state for the upload cycle per #1280
+            # (commit 0d6171dc). The Print-flow slicer's in-flight-job lock
+            # releases on FINISH; resetting to IDLE 2 s later would re-confuse
+            # the slicer that just unwedged. Earlier audit suggesting the
+            # IDLE reset was wrong — staying at FINISH is the designed
+            # behaviour. The next upload's PREPARE→FINISH cycle starts fresh.
 
     async def on_print_command(self, filename: str, data: dict) -> None:
         """Handle print command from MQTT.
@@ -249,14 +281,27 @@ class VirtualPrinterInstance:
         `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`) so the
         VP-queue path can inherit them when adding the item to the queue,
         rather than falling back to the global default settings (#1403).
-        Only queue mode consumes the capture; immediate / review / proxy
+        Only queue mode consumes the capture; archive / review / proxy
         modes ignore the print command, so we skip the stash there to keep
         the dict from accumulating one entry per print over the VP's
         uptime.
         """
         logger.info("[VP %s] Print command for: %s", self.name, filename)
-        if self.mode != "print_queue":
+        if normalize_vp_mode(self.mode) != VP_MODE_QUEUE:
             return
+        # Drop the oldest stash if the cache is growing — happens when the
+        # slicer sends project_file for a filename whose FTP upload was
+        # rejected / cancelled / non-3MF, so _add_to_print_queue's pop
+        # never fires. With no bound, a long-running VP accumulates one
+        # dict per such mismatch.
+        if len(self._slicer_print_options) >= _SLICER_OPTIONS_CACHE_LIMIT:
+            try:
+                stale_key = next(iter(self._slicer_print_options))
+                self._slicer_print_options.pop(stale_key, None)
+                self._slicer_print_options_events.pop(stale_key, None)
+                logger.debug("[VP %s] Evicted stale slicer options for %s", self.name, stale_key)
+            except StopIteration:
+                pass
         self._slicer_print_options[filename] = dict(data)
         event = self._slicer_print_options_events.get(filename)
         if event:
@@ -277,6 +322,7 @@ class VirtualPrinterInstance:
                 pass
             return
 
+        archived = False
         try:
             from backend.app.api.routes.settings import get_setting
             from backend.app.services.archive import ArchiveService
@@ -298,15 +344,29 @@ class VirtualPrinterInstance:
                 if archive:
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
                     await self._broadcast_archive_created(archive)
-                    try:
-                        file_path.unlink()
-                    except OSError:
-                        pass
-                    self._pending_files.pop(file_path.name, None)
+                    archived = True
                 else:
                     logger.error("Failed to archive file: %s", file_path.name)
         except Exception as e:
             logger.error("Error archiving file: %s", e)
+        finally:
+            # Always release the in-flight marker and delete the temp file —
+            # previously the failure paths only logged and the next upload of
+            # the same name was silently rejected with "already uploading",
+            # the upload_dir filled up indefinitely, and the slicer received
+            # a clean 226 even though no archive existed (#audit-R2-1).
+            self._pending_files.pop(file_path.name, None)
+            if archived:
+                try:
+                    file_path.unlink()
+                except OSError:
+                    pass
+            else:
+                # Drop the failed temp file so it doesn't accumulate.
+                try:
+                    file_path.unlink(missing_ok=True)
+                except OSError:
+                    pass
 
     async def _queue_file(self, file_path: Path, source_ip: str) -> None:
         """Queue file for user review."""
@@ -354,9 +414,19 @@ class VirtualPrinterInstance:
                 db.add(pending)
                 await db.commit()
                 logger.info("[VP %s] Queued: %s - %s", self.name, pending.id, file_path.name)
-                self._pending_files.pop(file_path.name, None)
         except Exception as e:
             logger.error("Error queueing file: %s", e)
+            # Queue insert failed — drop the temp file so it doesn't
+            # accumulate. The file is unreachable without the DB row.
+            try:
+                file_path.unlink(missing_ok=True)
+            except OSError:
+                pass
+        finally:
+            # Always release the in-flight marker so concurrent uploads
+            # with the same filename aren't spuriously rejected after
+            # a queue failure.
+            self._pending_files.pop(file_path.name, None)
 
     async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
         """Archive file and add to print queue, assigned to target printer or model."""
@@ -492,12 +562,33 @@ class VirtualPrinterInstance:
                             if overrides:
                                 filament_overrides_json = json.dumps(overrides)
 
+                    # Pick the next free position the same way the manual
+                    # /print-queue/ POST does — previously hardcoded to 1,
+                    # which created duplicate position=1 rows on every
+                    # VP upload and made queue execution order
+                    # non-deterministic for any non-empty queue.
+                    from sqlalchemy import func, select as _sql_select
+
+                    queue_scope = _sql_select(func.max(PrintQueueItem.position)).where(
+                        PrintQueueItem.status == "pending"
+                    )
+                    if self.target_printer_id is not None:
+                        queue_scope = queue_scope.where(PrintQueueItem.printer_id == self.target_printer_id)
+                    else:
+                        queue_scope = queue_scope.where(PrintQueueItem.printer_id.is_(None))
+                    try:
+                        max_pos_raw = (await db.execute(queue_scope)).scalar()
+                        max_pos = int(max_pos_raw) if max_pos_raw is not None else 0
+                    except (TypeError, ValueError):
+                        max_pos = 0
+                    next_position = max_pos + 1
+
                     queue_item = PrintQueueItem(
                         printer_id=self.target_printer_id,
                         target_model=target_model,
                         archive_id=archive.id,
                         plate_id=plate_id,
-                        position=1,
+                        position=next_position,
                         status="pending",
                         manual_start=not self.auto_dispatch,
                         required_filament_types=required_filament_types_json,
@@ -512,15 +603,20 @@ class VirtualPrinterInstance:
                     await db.commit()
                     logger.info("[VP %s] Added to queue: %s", self.name, queue_item.id)
                     await self._broadcast_archive_created(archive)
-                    try:
-                        file_path.unlink()
-                    except OSError:
-                        pass
-                    self._pending_files.pop(file_path.name, None)
                 else:
                     logger.error("Failed to archive file: %s", file_path.name)
         except Exception as e:
             logger.error("Error adding to print queue: %s", e)
+        finally:
+            # Always release the marker and clean the temp file. Without this
+            # the same-name STOR guard would block the next upload and the
+            # upload_dir would accumulate failed temp files forever
+            # (#audit-R2-1).
+            self._pending_files.pop(file_path.name, None)
+            try:
+                file_path.unlink(missing_ok=True)
+            except OSError:
+                pass
 
     async def _broadcast_archive_created(self, archive) -> None:
         """Notify connected clients that a new archive exists.
@@ -560,7 +656,12 @@ class VirtualPrinterInstance:
                         for meta in plate.findall("metadata"):
                             if meta.get("key") == "index" and meta.get("value"):
                                 return int(meta.get("value"))
-        except Exception:
+        except Exception as e:
+            # Malformed / missing slice_info.config — fall through to None.
+            # Logged at debug so a non-3MF or unconventional 3MF doesn't
+            # spam production logs; a debug trail exists for support
+            # bundles when wrong-plate dispatches are reported.
+            logger.debug("[VP] _extract_plate_id failed for %s: %s", file_path.name, e)
             return None
         return None
 
@@ -682,8 +783,11 @@ class VirtualPrinterInstance:
             )
         )
 
-        # SSDP server — advertise_addr is the Tailscale FQDN when available,
-        # otherwise the bind/remote IP (existing behaviour)
+        # SSDP server — advertise_addr is the remote_interface_ip (Tailscale
+        # IP, when chosen from the bind_ip dropdown) or the bind_ip. SSDP
+        # Location accepts IPs only; FQDNs go in through bind_ip selection
+        # at the printer-IP level and resolve before reaching the SSDP
+        # advertisement.
         self._ssdp = VirtualPrinterSSDPServer(
             name=self.name,
             serial=self.serial,
@@ -698,6 +802,32 @@ class VirtualPrinterInstance:
             )
         )
 
+        # Wait briefly for every child service to actually finish binding its
+        # socket so ``is_running`` doesn't lie. Without this barrier a caller
+        # racing the start (e.g. the diagnostic route) would see is_running=True
+        # while ports were still in the gap between task creation and the
+        # ``asyncio.start_server`` returning. Bounded timeout — if a child
+        # hangs we log it and move on; the existing task tracking still
+        # catches the failure on the next iteration.
+        ready_targets = [
+            ("FTP", self._ftp.ready),
+            ("MQTT", self._mqtt.ready),
+            ("Bind", self._bind.ready),
+            ("SSDP", self._ssdp.ready),
+        ]
+        try:
+            await asyncio.wait_for(
+                asyncio.gather(*(e.wait() for _, e in ready_targets)),
+                timeout=5.0,
+            )
+        except TimeoutError:
+            not_ready = [name for name, e in ready_targets if not e.is_set()]
+            logger.warning(
+                "[VP %s] Sub-service(s) didn't bind within 5s: %s — continuing anyway",
+                self.name,
+                ", ".join(not_ready) or "(none)",
+            )
+
         logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr)
 
     async def stop_server(self) -> None:
@@ -850,6 +980,13 @@ class VirtualPrinterManager:
         self._session_factory: Callable | None = None
         self._printer_manager: PrinterManager | None = None
         self._instances: dict[int, VirtualPrinterInstance] = {}
+        # Serialize sync_from_db so concurrent PUT /vp/{id} calls can't
+        # race the start/stop sequence and leave duplicate sub-services
+        # bound to the same port. The lock is fine-grained enough that
+        # a single VP update completes in well under a second; if the
+        # user holds the lock with a long-running start they intended
+        # to anyway.
+        self._sync_lock = asyncio.Lock()
 
         # Directories
         self._base_dir = app_settings.base_dir / "virtual_printer"
@@ -895,11 +1032,22 @@ class VirtualPrinterManager:
         return len(self._instances) > 0
 
     async def sync_from_db(self) -> None:
-        """Load all VPs from DB, reconcile running state."""
+        """Load all VPs from DB, reconcile running state.
+
+        Serialised by ``self._sync_lock`` — concurrent PUT /vp/{id} routes
+        all call into this method; without the lock the start / stop
+        sequence races and can leave duplicate sub-services bound to the
+        same port or orphan still-running tasks.
+        """
         if not self._session_factory:
             logger.warning("Cannot sync virtual printers: no session factory")
             return
 
+        async with self._sync_lock:
+            await self._sync_from_db_locked()
+
+    async def _sync_from_db_locked(self) -> None:
+        """Inner sync body — caller holds ``self._sync_lock``."""
         from sqlalchemy import select
 
         from backend.app.models.printer import Printer
@@ -935,14 +1083,39 @@ class VirtualPrinterManager:
             if not instance:
                 continue
 
+            # Proxy mode: detect target printer IP / serial changes from the
+            # DB lookup above. Without this branch a DHCP renewal that gives
+            # the target printer a new IP would leave the running proxy
+            # forwarding to the stale IP until the user manually toggles the
+            # VP. The same shape covers a target-side serial change.
+            proxy_target_changed = False
+            if vp.mode == "proxy":
+                fresh = proxy_ips.get(vp.id)
+                if fresh is not None:
+                    fresh_ip, fresh_serial = fresh
+                    if (
+                        getattr(instance, "target_printer_ip", None) != fresh_ip
+                        or getattr(instance, "target_printer_serial", None) != fresh_serial
+                    ):
+                        proxy_target_changed = True
+
+            # Normalize the DB value before comparing — a legacy `immediate`
+            # row read before the migration window finishes would otherwise
+            # trip the "changed" branch and bounce every VP at boot.
+            db_mode = normalize_vp_mode(vp.mode)
             changed = (
-                instance.mode != vp.mode
+                instance.mode != db_mode
                 or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)
                 or instance.access_code != (vp.access_code or "")
                 or instance.bind_ip != (vp.bind_ip or "")
                 or instance.remote_interface_ip != (vp.remote_interface_ip or "")
                 or instance.target_printer_id != vp.target_printer_id
                 or instance.auto_dispatch != vp.auto_dispatch
+                # Queue-mode behaviour toggle — without it the running
+                # instance silently keeps the old value until process
+                # restart (#1552 follow-up family).
+                or instance.queue_force_color_match != vp.queue_force_color_match
+                or proxy_target_changed
             )
 
             if changed:
@@ -1065,7 +1238,7 @@ class VirtualPrinterManager:
         return {
             "enabled": False,
             "running": False,
-            "mode": "immediate",
+            "mode": VP_MODE_ARCHIVE,
             "name": "Bambuddy",
             "serial": "",
             "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
@@ -1077,7 +1250,7 @@ class VirtualPrinterManager:
         self,
         enabled: bool,
         access_code: str = "",
-        mode: str = "immediate",
+        mode: str = VP_MODE_ARCHIVE,
         model: str = "",
         target_printer_ip: str = "",
         target_printer_serial: str = "",

+ 169 - 23
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -65,6 +65,20 @@ _SLICER_VISIBLE_STICKY_KEYS: tuple[str, ...] = (
     "net",
     "ipcam",
     "lights_report",
+    # Pre-flight / Prepare-tab fields that BambuStudio reads off cached
+    # push_status. Bambu firmware emits them in full pushall but typically
+    # OMITS them from 1 Hz incremental updates, so without sticky-preservation
+    # the cache drops them after the very next tick and the slicer's
+    # "block Send while busy / unknown firmware" branch kicks in. Same shape
+    # as #1228 (storage indicators) and #1558 (live-progress fields) —
+    # cached-branch field-shape parity, not a new mechanism.
+    "upgrade_state",  # Send pre-flight reads dis_state / force_upgrade
+    "xcam",  # Prepare-tab reads spaghetti / first-layer / halt sensitivity
+    "hw_switch_state",  # Hardware switch state (Prepare tab)
+    "nozzle_diameter",
+    "nozzle_type",
+    "online",  # Module online map (ahb / rfid / version)
+    "ams_status",  # AMS overall status; can be ams_status-only incremental
 )
 
 
@@ -76,6 +90,33 @@ def _ip_to_uint32_le(ip_str: str) -> int:
     return parts[0] | (parts[1] << 8) | (parts[2] << 16) | (parts[3] << 24)
 
 
+def _resolve_host_interface_for_target(target_ip: str) -> str | None:
+    """Pick a host-side IPv4 for `net.info[].ip` when the VP has no dedicated bind IP.
+
+    Used when `mqtt_server.bind_address` is empty or 0.0.0.0 — the listener
+    accepts on every interface but we still need ONE concrete IPv4 to write
+    into the rewritten `net.info[].ip` field so the slicer's FTP target
+    resolves to Bambuddy rather than the real printer. Returns the IPv4 of
+    the host interface that shares a subnet with the printer (best fit
+    because the slicer is typically on the same LAN as the printer), or
+    None if no interface matches — in which case the bridge leaves
+    encoding unarmed and the previous (still-leaky) behaviour stands.
+    """
+    try:
+        from backend.app.services.network_utils import find_interface_for_ip
+    except Exception:  # pragma: no cover - import shielding
+        return None
+    try:
+        iface = find_interface_for_ip(target_ip)
+    except Exception:
+        logger.exception("MQTT bridge: find_interface_for_ip(%s) crashed", target_ip)
+        return None
+    if not iface:
+        return None
+    ip = iface.get("ip")
+    return ip if isinstance(ip, str) and ip else None
+
+
 def _merge_ams_dict(prev_ams: dict, new_ams: dict) -> dict:
     """Merge a new ``ams`` blob from an incremental push onto the previous one.
 
@@ -241,6 +282,11 @@ class MQTTBridge:
         BambuMQTTClient is destroyed and recreated on PrinterManager.connect_printer
         (e.g. printer config update). Without periodic refresh the bridge would lose
         fan-out after such a churn until the VP itself restarts.
+
+        On crash exit, the handler must be unbound — otherwise the registered
+        ``_on_printer_raw`` keeps firing on every real-printer message even
+        though the bridge is functionally dead (memory leak + behaviour leak
+        across VP restart).
         """
         try:
             while not self._stopping:
@@ -250,6 +296,9 @@ class MQTTBridge:
             raise
         except Exception:
             logger.exception("[%s] MQTT bridge refresh loop crashed", self.vp_name)
+            # Crash exit — unbind so the orphaned handler stops firing.
+            # ``stop()`` won't be invoked because the task completes done-not-cancelled.
+            self._unbind_client()
 
     def _resolve_client(self) -> None:
         """Look up the current client for target_printer_id and rebind if it changed."""
@@ -260,6 +309,15 @@ class MQTTBridge:
             return
 
         if current is self._target_client:
+            # Same client object — but `ip_address` can fill in *after* the
+            # initial bind (e.g. DB row had a stale/empty value until the
+            # client's first SSDP-driven IP refresh). The original code only
+            # encoded `_target_ip_uint32_le` on client-identity change, so
+            # that late-arriving IP was never picked up, the `net.info[*].ip`
+            # rewrite stayed disabled, and the cache filled with the real
+            # printer IP — #1429. Refresh the encoding every tick so it
+            # self-heals once `ip_address` becomes valid.
+            self._refresh_ip_encoding()
             return
 
         # Client identity changed — unregister from the old, register on the new.
@@ -275,20 +333,7 @@ class MQTTBridge:
 
         self._target_client = current
         self._target_serial = getattr(current, "serial_number", None)
-
-        # Cache printer IP and VP bind IP encoded as little-endian uint32, so we
-        # can rewrite `net.info[*].ip` in cached push_status. BambuStudio reads
-        # that field for the FTP destination IP — without rewriting, the slicer
-        # bypasses the VP and FTPs straight to the real printer.
-        target_ip = getattr(current, "ip_address", None)
-        vp_ip = getattr(self._mqtt_server, "bind_address", None)
-        if target_ip and vp_ip and vp_ip not in ("0.0.0.0", "", None):  # nosec B104
-            try:
-                self._target_ip_uint32_le = _ip_to_uint32_le(target_ip)
-                self._vp_ip_uint32_le = _ip_to_uint32_le(vp_ip)
-            except ValueError:
-                self._target_ip_uint32_le = None
-                self._vp_ip_uint32_le = None
+        self._refresh_ip_encoding()
 
         logger.info(
             "[%s] MQTT bridge bound to printer %s (serial=%s)",
@@ -326,6 +371,110 @@ class MQTTBridge:
         self._target_client = None
         self._target_serial = None
 
+    def _refresh_ip_encoding(self) -> None:
+        """(Re-)encode `_target_ip_uint32_le` / `_vp_ip_uint32_le` from current values.
+
+        Called on every refresh tick, not just on client-identity change, so
+        a late-arriving printer IP (or a bind-address change) is picked up
+        without restarting the VP. When the encoding becomes valid for the
+        first time *after* the cache already received a push with the real
+        printer IP, also sweep the existing cache so the slicer's next pull
+        sees the rewritten value (#1429). Without this sweep the sticky-key
+        preservation keeps the poisoned `net.info[].ip` alive forever.
+
+        VP bind IP resolution: when `mqtt_server.bind_address` is empty or
+        `0.0.0.0` (the default for VPs that were never assigned a dedicated
+        bind IP), fall back to auto-resolving the host interface in the same
+        subnet as the printer's IP. Without this fallback, the rewrite never
+        arms on a default-config flat-LAN install and `net.info[].ip` leaks
+        the real printer IP — slicer follows it on Send (#1429 residual).
+        """
+        client = self._target_client
+        if client is None:
+            return
+
+        target_ip = getattr(client, "ip_address", None)
+        if not target_ip:
+            return
+
+        vp_ip = getattr(self._mqtt_server, "bind_address", None)
+        vp_ip_source = "bind_address"
+        if not vp_ip or vp_ip in ("0.0.0.0", ""):  # nosec B104
+            resolved = _resolve_host_interface_for_target(target_ip)
+            if not resolved:
+                return
+            vp_ip = resolved
+            vp_ip_source = "auto-resolved"
+
+        try:
+            new_target_le = _ip_to_uint32_le(target_ip)
+            new_vp_le = _ip_to_uint32_le(vp_ip)
+        except ValueError:
+            return
+
+        if new_target_le == self._target_ip_uint32_le and new_vp_le == self._vp_ip_uint32_le:
+            return  # No change — nothing to do.
+
+        # Encoding either became valid for the first time or shifted (DHCP
+        # renewal, bind_ip reconfigured, etc.). Update + sweep the cache.
+        was_armed = self._target_ip_uint32_le is not None and self._vp_ip_uint32_le is not None
+        self._target_ip_uint32_le = new_target_le
+        self._vp_ip_uint32_le = new_vp_le
+        logger.info(
+            "[%s] MQTT bridge IP encoding %s: target=%s vp=%s (%s)",
+            self.vp_name,
+            "updated" if was_armed else "armed",
+            target_ip,
+            vp_ip,
+            vp_ip_source,
+        )
+
+        cached = self._latest_print_state
+        if isinstance(cached, dict):
+            n = self._rewrite_net_info_ips(cached)
+            if n:
+                logger.info(
+                    "[%s] MQTT bridge swept %d net.info[].ip entries in cached push",
+                    self.vp_name,
+                    n,
+                )
+
+    def _rewrite_net_info_ips(self, print_state: dict) -> int:
+        """Rewrite every non-zero `net.info[].ip` in `print_state` to the VP bind IP.
+
+        Returns the number of entries rewritten. Mutates `print_state` in place.
+
+        Strategy: rewrite ALL entries with a non-zero `ip`, not only those
+        matching `_target_ip_uint32_le`. Real printers (X1C, H2D Pro) can
+        report multiple active interfaces (WiFi + Ethernet) with different
+        IPs — only one matches the IP Bambuddy tracks, but the slicer may
+        read any of them. Leaving non-matching entries pointing at real
+        printer interfaces leaks an FTP fallback path that bypasses the VP
+        (the #1429 / #1302 symptom). Entries with `ip == 0` are placeholders
+        for unpopulated interfaces — leave them alone so the slicer's
+        "active interface" detection still recognises them as absent.
+        """
+        if self._vp_ip_uint32_le is None:
+            return 0
+        net = print_state.get("net")
+        if not isinstance(net, dict):
+            return 0
+        info = net.get("info")
+        if not isinstance(info, list):
+            return 0
+        rewritten = 0
+        for entry in info:
+            if not isinstance(entry, dict):
+                continue
+            ip_value = entry.get("ip")
+            if not isinstance(ip_value, int) or ip_value == 0:
+                continue
+            if ip_value == self._vp_ip_uint32_le:
+                continue
+            entry["ip"] = self._vp_ip_uint32_le
+            rewritten += 1
+        return rewritten
+
     def _on_printer_raw(self, topic: str, payload: bytes) -> None:
         """Paho-thread callback — cache the latest push_status for synthetic replay.
 
@@ -374,14 +523,7 @@ class MQTTBridge:
             # (i.e. configure the VP with the same access code as its target).
             # Rewrite real printer IP → VP bind IP in `net.info[*].ip` so the
             # slicer's FTP destination resolves to the VP, not the real printer.
-            if self._target_ip_uint32_le is not None and self._vp_ip_uint32_le is not None:
-                net = print_data.get("net")
-                if isinstance(net, dict):
-                    info = net.get("info")
-                    if isinstance(info, list):
-                        for entry in info:
-                            if isinstance(entry, dict) and entry.get("ip") == self._target_ip_uint32_le:
-                                entry["ip"] = self._vp_ip_uint32_le
+            self._rewrite_net_info_ips(print_data)
             # Defensive deep copy on store so the cache is fully decoupled from
             # the freshly-parsed tree and from any reader's reference.
             new_state = copy.deepcopy(print_data)
@@ -402,7 +544,11 @@ class MQTTBridge:
                 for sticky_key in _SLICER_VISIBLE_STICKY_KEYS:
                     if sticky_key not in new_state:
                         if sticky_key in prev:
-                            new_state[sticky_key] = prev[sticky_key]
+                            # Defensive deep copy — without this the carried-over
+                            # nested dicts/lists are shared between new_state and
+                            # the previous cache, so any in-place mutation later
+                            # (current or future code paths) would corrupt both.
+                            new_state[sticky_key] = copy.deepcopy(prev[sticky_key])
                         continue
                     # Key IS in new_state — but firmware sends partial blobs
                     # (status-only / tray-targeted) under the same key on

+ 233 - 13
backend/app/services/virtual_printer/mqtt_server.py

@@ -5,6 +5,8 @@ authenticates with the configured access code, and logs print commands.
 """
 
 import asyncio
+import copy
+import hmac
 import json
 import logging
 import ssl
@@ -20,6 +22,22 @@ logger = logging.getLogger(__name__)
 # Default MQTT port for Bambu printers (MQTT over TLS)
 MQTT_PORT = 8883
 
+# Per-IP MQTT auth rate-limit. 5 failures within 60 s blocks further attempts
+# for the remainder of the window. Bambu printers themselves don't rate-limit,
+# but they're not exposed past the LAN edge; Bambuddy's VPs sometimes are
+# (Tailscale, port-forwarded), so an 8-char access code without any
+# brute-force friction is too weak. The window auto-recovers — no manual
+# unblock — so a legitimate user who fat-fingered their access code 5 times
+# only waits up to 60 s.
+_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
+_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0
+
+# Pending-request map bound. Each entry maps a slicer command's
+# sequence_id to its originating client_id so the bridge response can be
+# routed back to just that client. Bounded so a slicer that issues
+# commands without ever consuming responses can't leak memory.
+_PENDING_REQUEST_MAX_ENTRIES = 256
+
 # Model code → product_name for version response (must match what slicer expects)
 MODEL_PRODUCT_NAMES = {
     "BL-P001": "X1 Carbon",
@@ -204,6 +222,8 @@ class SimpleMQTTServer:
         self.vp_name = vp_name
         self._log_prefix = f"[{vp_name}] " if vp_name else ""
         self._running = False
+        # Set after the socket is bound — see ftp_server.py for rationale.
+        self.ready = asyncio.Event()
         self._server = None
         self._clients: dict[str, asyncio.StreamWriter] = {}
         # Per-client "effective serial" — the serial the slicer actually uses in
@@ -228,6 +248,20 @@ class SimpleMQTTServer:
         # synthetic fallback resumes automatically.
         self._bridge: MQTTBridge | None = None
 
+        # Per-source-IP failed-auth tracker for rate-limiting / lockout.
+        # Maps IP → list[monotonic timestamp] of recent failures within the
+        # window. Pruned on every check so it doesn't grow unbounded.
+        self._auth_failures: dict[str, list[float]] = {}
+
+        # Maps sequence_id → originating client_id for slicer-initiated
+        # commands forwarded to the real printer. Used in
+        # ``push_raw_to_clients`` to route the printer's response only
+        # back to the requesting slicer instead of fanning out to all
+        # connected clients (which leaks slicer A's responses to slicer
+        # B in multi-slicer setups). FIFO-bounded; if a response never
+        # arrives the entry ages out instead of leaking.
+        self._pending_requests: dict[str, str] = {}
+
     async def start(self) -> None:
         """Start the MQTT server."""
         if self._running:
@@ -242,6 +276,14 @@ class SimpleMQTTServer:
         ssl_context.verify_mode = ssl.CERT_NONE
         # Allow TLS 1.2 for broader compatibility (some slicers may not support 1.3)
         ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
+        # Match real Bambu printer cipher behaviour: include the plain-RSA
+        # AES-GCM suites the slicer expects. On hardened distros
+        # (Fedora / RHEL with `update-crypto-policies`, hardened Alpine builds)
+        # OpenSSL's `DEFAULT` list strips these suites, leaving no overlap
+        # with the slicer's MQTT-over-TLS ClientHello — handshake fails
+        # immediately and the slicer reports a connect error before any MQTT
+        # CONNECT can be sent (#1610 audit). Same shape as the #620 fix.
+        ssl_context.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
         # Disable hostname checking
         ssl_context.check_hostname = False
 
@@ -287,6 +329,7 @@ class SimpleMQTTServer:
                 self.port,
                 ssl=ssl_context,
             )
+            self.ready.set()
 
             logger.info("Simple MQTT server listening on port %s", self.port)
 
@@ -312,6 +355,7 @@ class SimpleMQTTServer:
         """Stop the MQTT server."""
         logger.info("Stopping simple MQTT server")
         self._running = False
+        self.ready.clear()
 
         # Stop periodic status push
         if self._status_push_task:
@@ -363,9 +407,19 @@ class SimpleMQTTServer:
     async def _periodic_status_push(self) -> None:
         """Send periodic status updates to all connected clients (1 Hz, exact pre-bridge behaviour)."""
         logger.info("Starting periodic status push task")
+        # Per-client push counters reset every 60 ticks. Lets us confirm from
+        # logs whether the 1Hz push is actually reaching a specific slicer
+        # connection (#1548 keepalive follow-up: keepalive parser shipped but
+        # OrcaSlicer still disconnects on idle, and the periodic push is
+        # otherwise silent at INFO level so it can't be observed in the
+        # support bundle). One log line per minute per active connection —
+        # nothing when no slicer is attached.
+        push_counts: dict[str, int] = {}
+        ticks_since_summary = 0
         while self._running:
             try:
                 await asyncio.sleep(1)  # Push every 1 second like real printers
+                ticks_since_summary += 1
 
                 disconnected = []
                 for client_id, writer in list(self._clients.items()):
@@ -375,6 +429,7 @@ class SimpleMQTTServer:
                             continue
                         serial = self._client_serials.get(client_id, self.serial)
                         await self._send_status_report(writer, serial=serial)
+                        push_counts[client_id] = push_counts.get(client_id, 0) + 1
                     except OSError as e:
                         logger.debug("Failed to push status to %s: %s", client_id, e)
                         disconnected.append(client_id)
@@ -383,6 +438,18 @@ class SimpleMQTTServer:
                 for client_id in disconnected:
                     self._clients.pop(client_id, None)
                     self._client_serials.pop(client_id, None)
+                    push_counts.pop(client_id, None)
+
+                if ticks_since_summary >= 60:
+                    for cid, count in push_counts.items():
+                        logger.info(
+                            "%s1Hz status push: %d pushes/min to %s",
+                            self._log_prefix,
+                            count,
+                            cid,
+                        )
+                    push_counts.clear()
+                    ticks_since_summary = 0
 
             except asyncio.CancelledError:
                 break
@@ -392,10 +459,17 @@ class SimpleMQTTServer:
         logger.info("Periodic status push task stopped")
 
     async def push_raw_to_clients(self, topic: str, payload: bytes) -> None:
-        """Publish a pre-serialized MQTT payload on `topic` to every connected slicer.
+        """Publish a pre-serialized MQTT payload on `topic` to connected slicers.
 
         Called by MQTTBridge from the asyncio loop (scheduled via
         run_coroutine_threadsafe from paho's network thread).
+
+        Routes the response only back to the originating slicer if the
+        payload's sequence_id was previously recorded via
+        ``_record_pending_request``. Falls back to fan-out for
+        printer-initiated pushes (push_status etc.) and for sequence_ids
+        we never saw (covers a slicer that subscribes mid-flight to a
+        topic for which an earlier request is still in flight).
         """
         topic_bytes = topic.encode("utf-8")
         # MQTT remaining-length: 2-byte topic length prefix + topic + message body.
@@ -414,8 +488,12 @@ class SimpleMQTTServer:
         packet.extend(payload)
         frame = bytes(packet)
 
+        target_client_id = self._lookup_pending_request_client(payload)
+
         disconnected = []
         for client_id, writer in list(self._clients.items()):
+            if target_client_id is not None and client_id != target_client_id:
+                continue
             try:
                 if writer.is_closing():
                     disconnected.append(client_id)
@@ -440,12 +518,18 @@ class SimpleMQTTServer:
         logger.info("%sMQTT client connected: %s", self._log_prefix, client_id)
 
         authenticated = False
+        # Per-packet read timeout. Before CONNECT we default to 60 s so a
+        # client that opens TCP but never sends anything still gets reaped;
+        # after CONNECT the value is updated to 1.5× the keepalive the
+        # client negotiated (MQTT spec §4.4). ``None`` means no timeout,
+        # which is what spec §3.1.2.10 mandates for keep_alive == 0.
+        read_timeout: float | None = 60.0
 
         try:
             while self._running:
                 # Read MQTT fixed header
                 try:
-                    header = await asyncio.wait_for(reader.read(1), timeout=60)
+                    header = await asyncio.wait_for(reader.read(1), timeout=read_timeout)
                 except TimeoutError:
                     break
 
@@ -464,9 +548,30 @@ class SimpleMQTTServer:
 
                 # Handle packet types
                 if packet_type == 1:  # CONNECT
-                    authenticated = await self._handle_connect(payload, writer)
+                    source_ip = addr[0] if addr else "unknown"
+                    if self._is_auth_rate_limited(source_ip):
+                        logger.warning(
+                            "%sMQTT auth rate-limited from %s (>=%d failures in %ds)",
+                            self._log_prefix,
+                            source_ip,
+                            _AUTH_RATE_LIMIT_MAX_ATTEMPTS,
+                            int(_AUTH_RATE_LIMIT_WINDOW_SECONDS),
+                        )
+                        writer.write(bytes([0x20, 0x02, 0x00, 0x05]))  # Not authorized
+                        await writer.drain()
+                        break
+                    authenticated, keep_alive = await self._handle_connect(payload, writer)
                     if not authenticated:
+                        self._record_auth_failure(source_ip)
                         break
+                    self._clear_auth_failures(source_ip)
+                    # Honour the client's negotiated keepalive (#1548). Before
+                    # this fix, the hardcoded 60 s above would close
+                    # OrcaSlicer's idle connection at the keepalive boundary
+                    # instead of waiting 1.5× as the spec requires — Orca
+                    # sends PINGREQ within its own keepalive interval but
+                    # we'd already have closed the socket.
+                    read_timeout = keep_alive * 1.5 if keep_alive > 0 else None
                     # Register client for periodic status pushes; start with
                     # self.serial as the fallback until we learn the slicer's
                     # preferred serial from the first SUBSCRIBE/PUBLISH.
@@ -488,7 +593,11 @@ class SimpleMQTTServer:
         except asyncio.CancelledError:
             pass  # Expected when server is shutting down and cancels client tasks
         except Exception as e:
-            logger.debug("MQTT client error: %s", e)
+            # Outer handler — inner handlers already absorb expected parser
+            # / IO failures at debug. Anything reaching here is unexpected
+            # and would otherwise silently drop the slicer connection with
+            # no actionable signal in production logs (defaults are INFO+).
+            logger.warning("%sMQTT client session error from %s: %s", self._log_prefix, client_id, e)
         finally:
             logger.debug("MQTT client disconnected: %s", client_id)
             self._clients.pop(client_id, None)
@@ -519,10 +628,86 @@ class SimpleMQTTServer:
 
         return None
 
-    async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> bool:
+    def _record_pending_request(self, data: dict, client_id: str) -> None:
+        """Stash sequence_id → client_id for any nested block with a sequence_id.
+
+        Slicer commands typically wrap their seq id in ``{"print": {...}}`` or
+        ``{"info": {...}}`` / ``{"system": {...}}`` etc. Walks top-level dict
+        values once to find the seq id; if absent (some commands omit it) we
+        skip — the response will fall through to broadcast which is fine for
+        unsolicited pushes.
+        """
+        for block in data.values():
+            if isinstance(block, dict):
+                seq = block.get("sequence_id")
+                if seq is not None:
+                    key = str(seq)
+                    # Evict oldest entry when over the cap. Python dicts
+                    # preserve insertion order so iter(self._pending_requests)
+                    # yields the oldest key first.
+                    while len(self._pending_requests) >= _PENDING_REQUEST_MAX_ENTRIES:
+                        oldest = next(iter(self._pending_requests))
+                        self._pending_requests.pop(oldest, None)
+                    self._pending_requests[key] = client_id
+                    return
+
+    def _lookup_pending_request_client(self, payload: bytes) -> str | None:
+        """Parse a bridge-forwarded MQTT payload and return the originating
+        client_id if its sequence_id was recorded.
+
+        Returns ``None`` for printer-initiated pushes (no recorded seq id) so
+        push_raw_to_clients falls back to broadcast — required for push_status
+        and the other unsolicited pushes that every connected slicer expects.
+        """
+        try:
+            parsed = json.loads(payload)
+        except (ValueError, TypeError):
+            return None
+        if not isinstance(parsed, dict):
+            return None
+        for block in parsed.values():
+            if isinstance(block, dict):
+                seq = block.get("sequence_id")
+                if seq is not None:
+                    return self._pending_requests.pop(str(seq), None)
+        return None
+
+    def _is_auth_rate_limited(self, source_ip: str) -> bool:
+        """Return True if ``source_ip`` has hit the per-IP failure cap.
+
+        Prunes timestamps older than the window so the dict doesn't grow
+        unbounded. Uses ``time.monotonic()`` for a wall-clock-jump-immune
+        clock that's safe to call from any context (sync or async).
+        """
+        import time as _time
+
+        now = _time.monotonic()
+        window_start = now - _AUTH_RATE_LIMIT_WINDOW_SECONDS
+        recent = [t for t in self._auth_failures.get(source_ip, []) if t >= window_start]
+        if recent:
+            self._auth_failures[source_ip] = recent
+        else:
+            self._auth_failures.pop(source_ip, None)
+        return len(recent) >= _AUTH_RATE_LIMIT_MAX_ATTEMPTS
+
+    def _record_auth_failure(self, source_ip: str) -> None:
+        """Append a timestamp for ``source_ip``'s failed auth attempt."""
+        import time as _time
+
+        now = _time.monotonic()
+        self._auth_failures.setdefault(source_ip, []).append(now)
+
+    def _clear_auth_failures(self, source_ip: str) -> None:
+        """Reset ``source_ip``'s failure history after a successful auth."""
+        self._auth_failures.pop(source_ip, None)
+
+    async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> tuple[bool, int]:
         """Handle MQTT CONNECT packet.
 
-        Returns True if authentication successful.
+        Returns ``(authenticated, keep_alive_seconds)`` — the second element
+        is the value the client advertised in CONNECT, so the caller's
+        read-loop can honour it instead of the hardcoded default. ``0``
+        means the client opted out of keepalive (#1548).
         """
         try:
             # Parse CONNECT packet
@@ -535,7 +720,12 @@ class SimpleMQTTServer:
             # connect_flags = payload[idx + 1]
             idx += 2
 
-            # Skip keepalive
+            # Keepalive (2-byte big-endian, seconds). Honoured by the read
+            # loop in `_handle_client` per MQTT spec §3.1.2.10 / §4.4 —
+            # before #1548 we ignored this and used a hardcoded 60 s, which
+            # closed OrcaSlicer's idle connection at exactly the negotiated
+            # keepalive boundary instead of the spec-mandated 1.5×.
+            keep_alive = (payload[idx] << 8) | payload[idx + 1]
             idx += 2
 
             # Read client ID
@@ -555,8 +745,11 @@ class SimpleMQTTServer:
             idx += 2
             password = payload[idx : idx + password_len].decode("utf-8")
 
-            # Authenticate
-            if username == "bblp" and password == self.access_code:
+            # Authenticate. ``hmac.compare_digest`` is constant-time to keep
+            # the auth check from leaking the access code via response timing
+            # under network jitter — LAN-only threat is marginal, but it's
+            # the standard fix and costs nothing.
+            if username == "bblp" and hmac.compare_digest(password, self.access_code):
                 # Send CONNACK with success
                 writer.write(bytes([0x20, 0x02, 0x00, 0x00]))
                 await writer.drain()
@@ -564,20 +757,20 @@ class SimpleMQTTServer:
 
                 # Send immediate status report after auth - slicer expects this
                 await self._send_status_report(writer)
-                return True
+                return True, keep_alive
             else:
                 # Send CONNACK with auth failure
                 writer.write(bytes([0x20, 0x02, 0x00, 0x05]))  # Not authorized
                 await writer.drain()
                 logger.warning("%sMQTT auth failed for user '%s' (access code mismatch)", self._log_prefix, username)
-                return False
+                return False, 0
 
         except (IndexError, ValueError) as e:
             logger.debug("MQTT CONNECT parse error: %s", e)
             # Send CONNACK with error
             writer.write(bytes([0x20, 0x02, 0x00, 0x02]))  # Protocol error
             await writer.drain()
-            return False
+            return False, 0
 
     async def _handle_subscribe(self, payload: bytes, writer: asyncio.StreamWriter, client_id: str) -> None:
         """Handle MQTT SUBSCRIBE packet."""
@@ -647,7 +840,13 @@ class SimpleMQTTServer:
             if isinstance(cached, dict):
                 # Real-printer-shaped response. Copy the cache, then replace the
                 # protocol / upload-state fields with values under our control.
-                print_block = dict(cached)
+                # Deep copy — current mutations are top-level only, but a future
+                # override that writes into a nested dict (e.g. ``online``,
+                # ``upgrade_state``, ``ipcam``) would otherwise corrupt the
+                # bridge cache and be read by every subsequent subscriber until
+                # the next real-printer push lands. Cost is one allocation per
+                # status report; the cached dict is already short-lived.
+                print_block = copy.deepcopy(cached)
                 print_block["sequence_id"] = str(self._sequence_id)
                 print_block["command"] = "push_status"
                 print_block["msg"] = 0
@@ -673,6 +872,23 @@ class SimpleMQTTServer:
                 print_block["home_flag"] = print_block.get("home_flag", 0) | 0x100  # bit 8 = HAS_SDCARD_NORMAL
                 print_block["sdcard"] = True
                 print_block.setdefault("storage", {"free": 1_000_000_000, "total": 32_000_000_000})
+                # Live-progress fields the slicer's Send pre-flight reads
+                # (#1558). When the real target printer is mid-print, the
+                # cached push_status carries the real values for these
+                # fields and the slicer reads the VP as "busy" — refusing
+                # Send — even though gcode_state above is forced to IDLE.
+                # For VP usage the VP isn't actually running the print
+                # the printer is, so these need to mirror the synthetic
+                # stub's idle values. Same shape as #1228 (storage) — the
+                # cached-branch override set just needed extending.
+                print_block["mc_print_stage"] = ""
+                print_block["mc_percent"] = 0
+                print_block["mc_remaining_time"] = 0
+                print_block["stg"] = []
+                print_block["stg_cur"] = 0
+                print_block["layer_num"] = 0
+                print_block["total_layer_num"] = 0
+                print_block["print_error"] = 0
                 status = {"print": print_block}
                 await self._publish_to_report(writer, status, serial or self.serial)
                 return
@@ -1026,6 +1242,10 @@ class SimpleMQTTServer:
             # Forward anything the synthetic flow didn't handle to the real
             # printer. AMS load / dry / xcam / system / extrusion_cali_get etc.
             if not handled_locally and self._bridge is not None and self._bridge.is_active:
+                # Remember which client originated this command so the
+                # printer's response goes back only to them (not fanned
+                # out to every connected slicer).
+                self._record_pending_request(data, client_id)
                 self._bridge.forward_to_printer(data)
 
         except (IndexError, ValueError, OSError) as e:

+ 5 - 0
backend/app/services/virtual_printer/ssdp_server.py

@@ -55,6 +55,9 @@ class VirtualPrinterSSDPServer:
         self.model = model
         self._bind_ip = bind_ip
         self._running = False
+        # Set after the primary multicast socket is bound — see ftp_server.py
+        # for rationale.
+        self.ready = asyncio.Event()
         self._socket: socket.socket | None = None
         self._extra_sockets: list[socket.socket] = []
         self._extra_interfaces = extra_interfaces or []
@@ -169,6 +172,7 @@ class VirtualPrinterSSDPServer:
             local_ip = self._get_local_ip()
             logger.info("SSDP server listening on port %s, advertising IP: %s", SSDP_PORT, local_ip)
             logger.info("Virtual printer: %s (%s) model=%s", self.name, self.serial, self.model)
+            self.ready.set()
 
             # Create extra sockets for additional interfaces (VPN, etc.)
             # If no explicit extra interfaces given and we're bound to a
@@ -249,6 +253,7 @@ class VirtualPrinterSSDPServer:
         """Stop the SSDP server."""
         logger.info("Stopping SSDP server")
         self._running = False
+        self.ready.clear()
         await self._cleanup()
 
     async def _cleanup(self) -> None:

+ 7 - 2
backend/app/services/virtual_printer/tailscale.py

@@ -134,13 +134,18 @@ class TailscaleService:
 
         try:
             returncode, stdout, stderr = await self._run_tailscale("status", "--json", timeout=5.0)
-        except OSError as e:
+        except (OSError, asyncio.TimeoutError) as e:
+            # asyncio.TimeoutError covers the case where ``_run_tailscale``
+            # killed a stuck subprocess and re-raised. Without this branch
+            # the timeout escaped into the FastAPI route handler and could
+            # crash the VP management UI for users with a lagging
+            # tailscaled daemon.
             return TailscaleStatus(
                 available=False,
                 hostname="",
                 tailnet_name="",
                 fqdn="",
-                error=str(e),
+                error=str(e) or "tailscale status timed out",
             )
 
         if returncode is None or returncode != 0:

+ 71 - 6
backend/app/services/virtual_printer/tcp_proxy.py

@@ -194,6 +194,13 @@ class TLSProxy:
         ctx.minimum_version = ssl.TLSVersion.TLSv1_2
         # Don't require client certificates
         ctx.verify_mode = ssl.CERT_NONE
+        # Match real Bambu printer cipher behaviour on the slicer-facing side
+        # as well (the #620 fix only patched the printer-facing client context
+        # below). On hardened distros where OpenSSL `DEFAULT` strips the
+        # plain-RSA AES-GCM suites, the slicer's ClientHello has no overlap
+        # with our server's offered suites and the handshake fails before any
+        # data flows (#1610 audit).
+        ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
         return ctx
 
     def _create_client_ssl_context(self) -> ssl.SSLContext:
@@ -814,7 +821,17 @@ class FTPTLSProxy(TLSProxy):
 
     async def stop(self) -> None:
         """Stop proxy and clean up data connection servers."""
-        # Close all data servers first
+        # Cancel any pending auto_close timeouts so they don't outlive the
+        # proxy holding a dead server reference. Without this, up to 101
+        # tasks lingered for ~60 s after stop(), each gripping a server
+        # ref + an active_connections slot; under rapid mode-switch the
+        # ports stayed bound long enough to fail the next start().
+        for task in list(self._auto_close_tasks):
+            task.cancel()
+        if self._auto_close_tasks:
+            await asyncio.gather(*self._auto_close_tasks, return_exceptions=True)
+        self._auto_close_tasks.clear()
+        # Close all data servers
         for server in list(self._data_servers):
             try:
                 server.close()
@@ -827,6 +844,7 @@ class FTPTLSProxy(TLSProxy):
     async def start(self) -> None:
         """Start the FTP TLS proxy."""
         self._data_servers: list[asyncio.Server] = []
+        self._auto_close_tasks: list[asyncio.Task] = []
         await super().start()
 
     async def _handle_client(
@@ -1408,7 +1426,11 @@ class FTPTLSProxy(TLSProxy):
                 if server in self._data_servers:
                     self._data_servers.remove(server)
 
-        asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
+        # Track the auto_close task so stop() can cancel it; otherwise the
+        # 60 s timeout would hold a server reference past proxy teardown.
+        ac_task = asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
+        self._auto_close_tasks.append(ac_task)
+        ac_task.add_done_callback(lambda t, tasks=self._auto_close_tasks: tasks.remove(t) if t in tasks else None)
 
         logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port)
 
@@ -1469,6 +1491,17 @@ class SlicerProxyManager:
         self._bind_server = None
         self._probe_servers: list[asyncio.Server] = []
         self._tasks: list[asyncio.Task] = []
+        # Pre-bind the lifetime-coupled collections so ``stop()`` works if
+        # called before ``start()`` finishes (rapid mode-switch races).
+        # Previously ``_ftp_data_proxies`` was first assigned inside ``start()``
+        # at line ~1520, so an early stop hit AttributeError and left
+        # sockets stranded.
+        self._ftp_data_proxies: list[TCPProxy] = []
+        # Actual FTP listen port — class constant by default; ``start()``
+        # overwrites with the redirect target when iptables-redirect is
+        # active. ``get_status()`` reads this so diagnostics probe the
+        # port that actually has a listener, not the static LOCAL_FTP_PORT.
+        self._actual_ftp_port: int = self.LOCAL_FTP_PORT
 
     # FTP passive data port range — Bambu printers typically use ports in
     # this range for EPSV/PASV data connections. We pre-listen on all of
@@ -1499,8 +1532,25 @@ class SlicerProxyManager:
                 redirect_target,
             )
             ftp_listen_port = redirect_target
-
-        # FTP control — raw TCP pass-through (end-to-end TLS with printer)
+        # Cache the actual listen port for get_status() / diagnostic so the
+        # port_ftps check probes the port that actually has a socket.
+        self._actual_ftp_port = ftp_listen_port
+
+        # FTP control — raw TCP pass-through (end-to-end TLS with printer).
+        # A TLS 1.3 → 1.2 ClientHello-rewrite was attempted to work around
+        # BambuStudio's libcurl bug on the X1C FTPS data channel (PSK
+        # session-resumption + CURLE_PARTIAL_FILE). Reverted because the
+        # rewrite broke the control-channel TLS handshake itself: replacing
+        # 0x0304 with a duplicate 0x0303 in supported_versions while leaving
+        # the TLS-1.3-only extensions (key_share, psk_key_exchange_modes,
+        # signature_algorithms_cert) in place produced a malformed ClientHello
+        # that the printer or slicer rejected, and the connection closed
+        # before any data channel was opened. A proper fix needs full TLS
+        # bumping (terminate + re-establish) with packet-capture work
+        # that's out of scope for now. X1C proxy-mode FTP uploads remain
+        # broken — users with X1C should use the non-proxy modes (immediate
+        # / review / print_queue) which work end-to-end via the VP's own
+        # FTP server on TLS 1.2.
         self._ftp_proxy = TCPProxy(
             name="FTP",
             listen_port=ftp_listen_port,
@@ -1745,8 +1795,16 @@ class SlicerProxyManager:
             await dp.stop()
         self._ftp_data_proxies = []
 
+        # Probe servers need wait_closed — without it the OS releases the
+        # bind socket asynchronously and a rapid stop+start cycle (e.g.
+        # config-change-driven mode switch) can race "address already in
+        # use" on the probe ports.
         for srv in self._probe_servers:
-            srv.close()
+            try:
+                srv.close()
+                await srv.wait_closed()
+            except OSError:
+                pass  # Best-effort — port may already be released
         self._probe_servers = []
 
         # Cancel tasks
@@ -1798,7 +1856,14 @@ class SlicerProxyManager:
         return {
             "running": self.is_running,
             "target_host": self.target_host,
-            "ftp_port": self.LOCAL_FTP_PORT,
+            # ``_actual_ftp_port`` reflects the iptables-redirected listen
+            # port when the docker-host deployment uses
+            # ``iptables -t nat -A PREROUTING ... REDIRECT --to-port`` to
+            # let non-root containers serve on the printer's 990. Returning
+            # the class constant here made the diagnostic probe a port
+            # nothing was listening on and report a false fail on every
+            # working redirect deployment.
+            "ftp_port": self._actual_ftp_port,
             "mqtt_port": self.LOCAL_MQTT_PORT,
             "bind_ports": self.PRINTER_BIND_PORTS,
             "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),

+ 90 - 0
backend/app/utils/filename.py

@@ -0,0 +1,90 @@
+"""Print-file filename validation matching Bambu Studio's save-dialog rules.
+
+The Bambu printer SD card is FAT32/exFAT. Names containing the Windows /
+DOS-reserved set (``< > : " / \\ | ? *``), ASCII control characters
+(0x00-0x1F), or trailing dots / spaces cannot be created on it — FTP fails
+with ``553 Could not create file`` (#1540). Bambu Studio refuses to save
+such names client-side; Bambuddy now does the same at the rename, upload,
+and dispatch boundaries so the failure surfaces with a clear message
+instead of an obscure FTP error after the user has already hit Print.
+"""
+
+INVALID_FILENAME_CHARS = '<>:"/\\|?*'
+
+# FAT/exFAT cap on a single path component; UTF-8 byte length, not codepoints,
+# because that is what the on-disk encoding limit actually is.
+MAX_FILENAME_BYTES = 255
+
+
+class InvalidFilenameError(ValueError):
+    """Filename contains characters or shape the printer SD card rejects.
+
+    ``char`` is the first offending character when the failure is a
+    character-set violation, or ``None`` for structural failures (empty,
+    bare ``.``, trailing space, too long, etc.). The frontend echoes it
+    back to the user in the Bambu Studio-style error message.
+    """
+
+    def __init__(self, message: str, char: str | None = None):
+        super().__init__(message)
+        self.char = char
+
+
+def validate_print_filename(name: str) -> None:
+    """Raise ``InvalidFilenameError`` if ``name`` would fail on the SD card.
+
+    Matches Bambu Studio's save-dialog rejection set. Callers are expected
+    to translate the exception into an HTTP 400 (or a clean dispatch
+    rejection); the message is intentionally short and ASCII so it fits
+    a translation template.
+    """
+    if not name or not name.strip():
+        raise InvalidFilenameError("Filename cannot be empty")
+
+    if name in (".", ".."):
+        raise InvalidFilenameError("Filename cannot be '.' or '..'")
+
+    for ch in name:
+        if ch in INVALID_FILENAME_CHARS:
+            raise InvalidFilenameError(f"Filename contains invalid character: {ch}", char=ch)
+        if ord(ch) < 0x20:
+            raise InvalidFilenameError("Filename contains a control character", char=ch)
+
+    if name.endswith(" ") or name.endswith("."):
+        raise InvalidFilenameError("Filename cannot end with a space or dot")
+
+    if len(name.encode("utf-8")) > MAX_FILENAME_BYTES:
+        raise InvalidFilenameError(f"Filename exceeds {MAX_FILENAME_BYTES} bytes")
+
+
+def derive_remote_filename(filename: str) -> str:
+    """Compute the SD-card filename used when uploading a sliced print file.
+
+    Strips repeated trailing ``.gcode.3mf`` / ``.3mf`` suffixes until the
+    bare stem remains, then appends a single ``.3mf``; spaces are
+    replaced with underscores because the firmware parses
+    ``ftp://{filename}`` as a URL.
+
+    Canonical for both the dispatch uploader and the post-print SD
+    cleanup — when the two drift apart the cleanup misses, and a
+    library row whose stored filename ended up with a doubled
+    ``.gcode.3mf`` (#1542) leaves the real file on the SD card. On A1
+    firmware that lingering file becomes a ghost print on the next
+    power-on (same family as the P1S behaviour in #374).
+
+    Raises ``TypeError`` on non-string input rather than entering the
+    strip loop, because a duck-typed object that returns truthy
+    sentinels from ``endswith`` would never escape and the resulting
+    unbounded allocation has cgroup-OOM'd the test runner under mocks.
+    """
+    if not isinstance(filename, str):
+        raise TypeError(f"derive_remote_filename requires str, got {type(filename).__name__}")
+    stem = filename
+    while True:
+        if stem.endswith(".gcode.3mf"):
+            stem = stem[:-10]
+        elif stem.endswith(".3mf"):
+            stem = stem[:-4]
+        else:
+            break
+    return f"{stem}.3mf".replace(" ", "_")

+ 114 - 0
backend/app/utils/safe_path.py

@@ -0,0 +1,114 @@
+"""Containment-checked path joining.
+
+Single source of truth for joining a user-controlled string under a trusted
+parent directory. The two-vector arbitrary-file-write reported against
+``backend/app/api/routes/projects.py::import_project_file`` traced to plain
+``Path / user_string`` arithmetic with no resolve + containment check —
+attacker passed an absolute path, ``Path("/lib") / "/etc"`` collapsed to
+``Path("/etc")``, and the next ``write_bytes`` landed wherever the attacker
+chose. This module is the answer.
+
+Every site that joins a path component coming from a request body, a ZIP
+``namelist()``, an ``UploadFile.filename``, or any other attacker-controlled
+source MUST route through ``safe_join_under``. Sites that join trusted
+constants (settings paths, hardcoded subdirs) are not in scope — those should
+carry a ``# SEC-PATH-OK: <reason>`` marker so the CI backstop knows.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from fastapi import HTTPException
+
+
+class PathTraversalError(ValueError):
+    """Raised when a join attempt would escape the trusted parent.
+
+    Callers in API-route context catch this and translate to ``HTTPException``
+    via ``safe_join_under`` (which already raises HTTPException directly when
+    invoked with ``http=True``). Non-route callers can catch the
+    ``PathTraversalError`` and decide their own response shape.
+    """
+
+
+def safe_join_under(parent: Path, *parts: str, http: bool = True) -> Path:
+    """Join *parts* under *parent* and assert the result stays under it.
+
+    Rejects:
+    - empty / None / non-str parts;
+    - parts containing NUL (``\\x00``);
+    - parts starting with ``/`` or ``\\`` (absolute paths;
+      ``Path("/lib") / "/etc"`` discards ``/lib``);
+    - any sequence whose resolved form is not a descendant of *parent*'s
+      resolved form (defeats ``..`` traversal even when the literal join
+      doesn't look suspicious).
+
+    Returns the resolved absolute path on success.
+
+    When ``http=True`` (default; suitable for FastAPI routes), failures raise
+    ``HTTPException(400, "Invalid path in upload")``. Set ``http=False`` to
+    raise ``PathTraversalError`` instead — for non-route callers that need
+    finer control over the response.
+    """
+    if not parts:
+        _fail("safe_join_under called with no parts", http)
+
+    for part in parts:
+        if not isinstance(part, str):
+            _fail(f"Path part has type {type(part).__name__}, expected str", http)
+        if not part:
+            _fail("Empty path part", http)
+        if "\x00" in part:
+            _fail("NUL byte in path part", http)
+        # Reject literal absolute markers: pathlib collapses ``Path("/a") /
+        # "/b"`` to ``Path("/b")`` so the catch-after-resolve below would also
+        # fire, but rejecting up-front gives a clearer error and avoids
+        # touching the filesystem.
+        if part.startswith("/") or part.startswith("\\"):
+            _fail("Absolute path part not allowed", http)
+
+    parent_resolved = parent.resolve()
+    candidate = parent
+    for part in parts:
+        candidate = candidate / part
+    candidate_resolved = candidate.resolve()
+
+    if not _is_relative_to(candidate_resolved, parent_resolved):
+        _fail("Path escapes the parent directory", http)
+
+    return candidate_resolved
+
+
+def assert_under(parent: Path, candidate: Path, *, http: bool = True) -> Path:
+    """Assert that an already-joined *candidate* path is under *parent*.
+
+    Use when you have an existing ``Path`` (e.g. from another helper that
+    builds the path itself) and need a containment check before writing or
+    deleting. Equivalent to ``safe_join_under`` minus the per-part input
+    validation.
+    """
+    parent_resolved = parent.resolve()
+    candidate_resolved = candidate.resolve()
+    if not _is_relative_to(candidate_resolved, parent_resolved):
+        _fail("Path escapes the parent directory", http)
+    return candidate_resolved
+
+
+def _is_relative_to(child: Path, parent: Path) -> bool:
+    # ``Path.is_relative_to`` exists in Python 3.9+. Bambuddy targets 3.11+
+    # (per pyproject and the bug-report system info) so this is safe.
+    try:
+        return child.is_relative_to(parent)
+    except AttributeError:  # pragma: no cover - defensive
+        try:
+            child.relative_to(parent)
+            return True
+        except ValueError:
+            return False
+
+
+def _fail(reason: str, http: bool) -> None:
+    if http:
+        raise HTTPException(status_code=400, detail="Invalid path in upload")
+    raise PathTraversalError(reason)

+ 142 - 0
backend/tests/integration/test_archives_api.py

@@ -3,6 +3,8 @@
 Tests the full request/response cycle for /api/v1/archives/ endpoints.
 """
 
+from pathlib import Path
+
 import pytest
 from httpx import AsyncClient
 
@@ -1157,3 +1159,143 @@ class TestArchiveF3DEndpoints:
         response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
         assert response.status_code == 200
         assert response.json()["affected"] == 0
+
+
+class TestUploadSourceThreeMF:
+    """Regression for #1531: source-3MF upload on fallback archives."""
+
+    @staticmethod
+    def _minimal_3mf_bytes() -> bytes:
+        """Smallest valid .3mf — the upload path enforces a zip header check."""
+        import io
+        import zipfile
+
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w") as zf:
+            zf.writestr("[Content_Types].xml", "<types/>")
+        return buf.getvalue()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_fallback_archive_source_upload_lands_under_base_dir(
+        self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
+    ):
+        """Fallback archive (file_path='') must accept a source upload and store it inside base_dir.
+
+        Pre-fix, ``Path(base_dir) / ''`` collapsed to ``base_dir`` and the
+        ``.parent`` walked out of the data volume, sending the file to
+        ``/app/source/...`` and crashing on ``relative_to``.
+        """
+        from backend.app.core.config import settings as app_settings
+
+        monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Cloud Print",
+            file_path="",  # fallback archive — no source 3MF was archived
+            filename="Cloud Print.3mf",
+        )
+
+        files = {"file": ("cloud_print.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
+        response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
+
+        assert response.status_code == 200, response.text
+        payload = response.json()
+        rel = payload["source_3mf_path"]
+        # Stored as a relative path inside base_dir.
+        assert not rel.startswith("/"), f"source_3mf_path should be relative, got {rel!r}"
+        # File physically landed under base_dir (NOT escaped to /app/source/).
+        assert (tmp_path / rel).is_file()
+        # Deterministic fallback location keyed off archive id.
+        assert rel == f"archive/no_source/{archive.id}/cloud_print.3mf"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_normal_archive_source_upload_unchanged(
+        self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
+    ):
+        """Normal archive (file_path set) still nests the source under <archive>/source/."""
+        from backend.app.core.config import settings as app_settings
+
+        monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+
+        printer = await printer_factory()
+        # archive_factory's default file_path is "archives/test/test_print.gcode.3mf".
+        archive = await archive_factory(printer.id, print_name="Real Print")
+
+        files = {"file": ("real_print.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
+        response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
+
+        assert response.status_code == 200, response.text
+        rel = response.json()["source_3mf_path"]
+        assert rel == "archives/test/source/real_print.3mf"
+        assert (tmp_path / rel).is_file()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_symlinked_data_dir_upload_succeeds(
+        self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
+    ):
+        """Regression: DATA_DIR that's a symlink to the real storage must not break the upload.
+
+        Common on TrueNAS / Synology / QNAP storage pools, and any
+        ``-v /symlinked/host/path:/app/data`` mount. The helper resolves
+        only for the containment check and returns literal paths so the
+        caller's ``relative_to(settings.base_dir)`` doesn't trip over a
+        canonical-vs-symlink mismatch.
+        """
+        from backend.app.core.config import settings as app_settings
+
+        real_dir = tmp_path / "real_storage"
+        real_dir.mkdir()
+        symlink_dir = tmp_path / "data_via_symlink"
+        symlink_dir.symlink_to(real_dir)
+        monkeypatch.setattr(app_settings, "base_dir", symlink_dir)
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Symlinked Print",
+            file_path="archives/X1C/print.gcode.3mf",
+            filename="print.gcode.3mf",
+        )
+
+        files = {"file": ("print.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
+        response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
+
+        assert response.status_code == 200, response.text
+        rel = response.json()["source_3mf_path"]
+        assert rel == "archives/X1C/source/print.3mf"
+        # Reachable via both the symlink and the canonical path.
+        assert (symlink_dir / rel).is_file()
+        assert (real_dir / rel).is_file()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_absolute_file_path_rejected_with_clear_500(
+        self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
+    ):
+        """A row whose file_path is absolute (corrupted by old import / manual edit)
+        must fail with the explicit "outside the data directory" message, not silently
+        write outside base_dir."""
+        from backend.app.core.config import settings as app_settings
+
+        monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Corrupt Path",
+            file_path="/tmp/totally_outside.gcode.3mf",  # nosec B108
+            filename="totally_outside.gcode.3mf",
+        )
+
+        files = {"file": ("totally_outside.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
+        response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
+
+        assert response.status_code == 500
+        assert "outside the data directory" in response.json()["detail"]
+        # Did not write anything under the bogus /tmp/source/ either.
+        assert not (Path("/tmp") / "source").exists() or not (Path("/tmp") / "source" / "totally_outside.3mf").exists()  # nosec B108

+ 276 - 2
backend/tests/integration/test_auth_apikey_rbac.py

@@ -147,10 +147,13 @@ class TestApiKeyDenylistIntegrity:
         from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
         from backend.app.core.permissions import Permission
 
+        # NOTE: under the GHSA-r2qv-8222-hqg3 allowlist model, INVENTORY_CREATE
+        # and INVENTORY_UPDATE are administrative (not in the allowlist) and
+        # therefore denied for API keys regardless of denylist membership.
+        # This test still guards the small denylist-redundancy set of read-y
+        # permissions that the SpoolBuddy kiosk + status integrations rely on.
         expected_allowed = {
             Permission.INVENTORY_READ,
-            Permission.INVENTORY_CREATE,
-            Permission.INVENTORY_UPDATE,
             Permission.PRINTERS_READ,
             Permission.PRINTERS_CONTROL,
             Permission.ARCHIVES_READ,
@@ -159,3 +162,274 @@ class TestApiKeyDenylistIntegrity:
         }
         incorrectly_denied = expected_allowed & _APIKEY_DENIED_PERMISSIONS
         assert not incorrectly_denied, f"Operational permissions incorrectly in API key denylist: {incorrectly_denied}"
+
+
+class TestApiKeyScopeAllowlist:
+    """GHSA-r2qv-8222-hqg3 (CVSS 9.9) — allowlist-based scope enforcement.
+
+    Verifies that ``_check_apikey_permissions`` (and the higher-level
+    dependencies that call it) honour the per-permission scope mapping rather
+    than the legacy denylist-only model. Failures here would re-open the
+    "Read Status / Manage Queue / Control Printer / Manage Library checkboxes
+    are decorative" class of bug.
+    """
+
+    def test_every_permission_has_a_classification(self):
+        """Structural: every Permission must be either allowlisted or admin-denied.
+
+        This is the load-bearing drift-detection test for the allowlist model.
+        A new Permission added to ``core/permissions.py`` without a matching
+        entry in ``_APIKEY_SCOPE_BY_PERMISSION`` or ``_APIKEY_DENIED_PERMISSIONS``
+        is functionally admin-only (allowlist failure → 403) — that's the safe
+        default, but it should be an explicit choice rather than an oversight.
+        """
+        from backend.app.core.auth import (
+            _APIKEY_DENIED_PERMISSIONS,
+            _APIKEY_SCOPE_BY_PERMISSION,
+        )
+        from backend.app.core.permissions import Permission
+
+        unclassified = {
+            perm
+            for perm in Permission
+            if perm not in _APIKEY_SCOPE_BY_PERMISSION and perm not in _APIKEY_DENIED_PERMISSIONS
+        }
+        assert not unclassified, (
+            "Every Permission must be classified for API-key access. "
+            "Either add to _APIKEY_SCOPE_BY_PERMISSION (with scope flag) or "
+            f"_APIKEY_DENIED_PERMISSIONS (admin-only). Unclassified: {unclassified}"
+        )
+
+    def test_allowlist_uses_only_valid_scope_flags(self):
+        """Every value in the scope mapping must be a real bool field on APIKey."""
+        from backend.app.core.auth import _APIKEY_SCOPE_BY_PERMISSION
+        from backend.app.models.api_key import APIKey
+
+        # can_access_cloud / can_update_energy_cost are narrow opt-in scopes;
+        # the latter routes through its own ``require_energy_cost_update`` dep
+        # rather than the central allowlist, so it doesn't appear here.
+        valid_flags = {
+            "can_read_status",
+            "can_queue",
+            "can_control_printer",
+            "can_manage_library",
+            "can_manage_inventory",
+            "can_access_cloud",
+        }
+        used_flags = set(_APIKEY_SCOPE_BY_PERMISSION.values())
+        assert used_flags <= valid_flags, f"Unknown scope flags in mapping: {used_flags - valid_flags}"
+        # And every flag must actually exist on the model.
+        for flag in valid_flags:
+            assert hasattr(APIKey, flag), f"APIKey model missing column referenced by allowlist: {flag}"
+
+    def test_allowlist_and_denylist_are_disjoint(self):
+        """A permission classified as allowlisted must not also be in the denylist (and v/v)."""
+        from backend.app.core.auth import (
+            _APIKEY_DENIED_PERMISSIONS,
+            _APIKEY_SCOPE_BY_PERMISSION,
+        )
+
+        overlap = set(_APIKEY_SCOPE_BY_PERMISSION) & _APIKEY_DENIED_PERMISSIONS
+        assert not overlap, f"Permissions in both allowlist and denylist: {overlap}"
+
+    @pytest.mark.parametrize(
+        "scope_flag",
+        [
+            "can_read_status",
+            "can_queue",
+            "can_control_printer",
+            "can_manage_library",
+            "can_manage_inventory",
+            "can_access_cloud",
+        ],
+    )
+    def test_each_scope_flag_has_at_least_one_permission(self, scope_flag):
+        """If a scope flag has no permissions, it's dead code — fail loudly."""
+        from backend.app.core.auth import _APIKEY_SCOPE_BY_PERMISSION
+
+        assert scope_flag in _APIKEY_SCOPE_BY_PERMISSION.values(), (
+            f"No permission maps to {scope_flag} — either remove the flag or classify a permission under it."
+        )
+
+
+class _FakeApiKey:
+    """Bool-attribute stand-in for APIKey used by the scope matrix tests.
+
+    The ``_check_apikey_permissions`` function only inspects the four scope
+    booleans, so a lightweight stub is enough; instantiating the real model
+    requires a DB session which is overkill for pure-logic verification.
+    """
+
+    def __init__(
+        self,
+        can_read_status=False,
+        can_queue=False,
+        can_control_printer=False,
+        can_manage_library=False,
+        can_manage_inventory=False,
+    ):
+        self.can_read_status = can_read_status
+        self.can_queue = can_queue
+        self.can_control_printer = can_control_printer
+        self.can_manage_library = can_manage_library
+        self.can_manage_inventory = can_manage_inventory
+
+
+class TestCheckApiKeyPermissionsMatrix:
+    """Pure-logic matrix: every (scope flag combo × representative permission) outcome.
+
+    These are the tests that would have caught GHSA-r2qv-8222-hqg3 — they prove
+    the actual gate function honours the scope flags, not just that some
+    helper called by webhook.py does.
+    """
+
+    # (Permission, expected scope flag attribute, category description)
+    _SCOPE_CASES = [
+        # can_read_status
+        ("PRINTERS_READ", "can_read_status", "read printer status"),
+        ("ARCHIVES_READ", "can_read_status", "read archives"),
+        ("QUEUE_READ", "can_read_status", "read queue"),
+        ("SETTINGS_READ", "can_read_status", "SpoolBuddy kiosk settings read"),
+        ("WEBSOCKET_CONNECT", "can_read_status", "websocket subscribe"),
+        # can_queue
+        ("QUEUE_CREATE", "can_queue", "add queue item"),
+        ("QUEUE_DELETE_ALL", "can_queue", "delete any queue item"),
+        ("ARCHIVES_REPRINT_ALL", "can_queue", "reprint an archive"),
+        # can_control_printer
+        ("PRINTERS_CONTROL", "can_control_printer", "start/stop print"),
+        ("PRINTERS_FILES", "can_control_printer", "send file to printer"),
+        ("SMART_PLUGS_CONTROL", "can_control_printer", "smart plug on/off"),
+        # can_manage_library
+        ("LIBRARY_UPLOAD", "can_manage_library", "upload library file"),
+        ("LIBRARY_DELETE_OWN", "can_manage_library", "delete own library file"),
+        ("MAKERWORLD_IMPORT", "can_manage_library", "import from MakerWorld"),
+        # can_manage_inventory
+        ("INVENTORY_CREATE", "can_manage_inventory", "create spool record"),
+        ("INVENTORY_UPDATE", "can_manage_inventory", "update spool / SpoolBuddy kiosk write"),
+        ("INVENTORY_DELETE", "can_manage_inventory", "delete spool record"),
+        ("INVENTORY_FORECAST_WRITE", "can_manage_inventory", "update forecast SKU settings"),
+    ]
+
+    _ADMIN_CASES = [
+        # Documented denylist
+        "SETTINGS_UPDATE",
+        "USERS_CREATE",
+        "GROUPS_DELETE",
+        "API_KEYS_CREATE",
+        "GITHUB_BACKUP",
+        "FIRMWARE_UPDATE",
+        # Unmapped administrative (allowlist fail-closed catches these too)
+        "PRINTERS_CREATE",
+        "LIBRARY_DELETE_ALL",
+        "LIBRARY_PURGE",
+        "DISCOVERY_SCAN",
+    ]
+
+    @pytest.mark.parametrize("perm_name,required_flag,_descr", _SCOPE_CASES)
+    def test_permission_allowed_only_when_scope_flag_is_set(self, perm_name, required_flag, _descr):
+        """For each (Permission, scope) case, true→allow and false→403."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+        from backend.app.core.permissions import Permission
+
+        perm = Permission[perm_name].value
+
+        # Flag set → passes
+        _check_apikey_permissions(_FakeApiKey(**{required_flag: True}), [perm])
+
+        # All flags off → 403
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(_FakeApiKey(), [perm])
+        assert exc.value.status_code == 403
+
+        # Wrong flag set, required flag off → 403 (no cross-scope leakage)
+        other_flags = {
+            f
+            for f in ("can_read_status", "can_queue", "can_control_printer", "can_manage_library")
+            if f != required_flag
+        }
+        for other in other_flags:
+            with pytest.raises(HTTPException) as exc:
+                _check_apikey_permissions(_FakeApiKey(**{other: True}), [perm])
+            assert exc.value.status_code == 403
+
+    @pytest.mark.parametrize("perm_name", _ADMIN_CASES)
+    def test_admin_permissions_are_403_regardless_of_flags(self, perm_name):
+        """A fully-flagged API key still cannot use administrative permissions."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+        from backend.app.core.permissions import Permission
+
+        perm = Permission[perm_name].value
+        all_flags = _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True)
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(all_flags, [perm])
+        assert exc.value.status_code == 403
+        assert "administrative" in exc.value.detail.lower() or "does not have" in exc.value.detail.lower()
+
+    def test_unknown_permission_string_is_admin_denied(self):
+        """An unrecognised permission string must fail closed, not silently pass."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+
+        all_flags = _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True)
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(all_flags, ["bogus:nonexistent"])
+        assert exc.value.status_code == 403
+
+    def test_empty_perm_list_is_403(self):
+        """Defence-in-depth: an empty perm list must not silently allow."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+
+        all_flags = _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True)
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(all_flags, [])
+        assert exc.value.status_code == 403
+
+    def test_require_any_at_least_one_must_pass(self):
+        """``require_any=True`` matches any-of semantics, but still respects scopes."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+        from backend.app.core.permissions import Permission
+
+        # can_read_status only: any-of (PRINTERS_READ, QUEUE_CREATE) passes because the read flag is set.
+        _check_apikey_permissions(
+            _FakeApiKey(can_read_status=True),
+            [Permission.PRINTERS_READ.value, Permission.QUEUE_CREATE.value],
+            require_any=True,
+        )
+        # No flags: any-of fails.
+        with pytest.raises(HTTPException):
+            _check_apikey_permissions(
+                _FakeApiKey(),
+                [Permission.PRINTERS_READ.value, Permission.QUEUE_CREATE.value],
+                require_any=True,
+            )
+        # All admin perms: any-of fails even with every flag set.
+        with pytest.raises(HTTPException):
+            _check_apikey_permissions(
+                _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True),
+                [Permission.USERS_CREATE.value, Permission.GROUPS_DELETE.value],
+                require_any=True,
+            )
+
+    def test_require_all_every_perm_must_pass(self):
+        """Default ``require_any=False``: every permission must pass — single failure → 403."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+        from backend.app.core.permissions import Permission
+
+        # Read+queue set, queue+control required → fails because control flag is off.
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(
+                _FakeApiKey(can_read_status=True, can_queue=True),
+                [Permission.QUEUE_CREATE.value, Permission.PRINTERS_CONTROL.value],
+            )
+        assert exc.value.status_code == 403

+ 37 - 6
backend/tests/integration/test_external_folders_api.py

@@ -8,6 +8,21 @@ import pytest
 from httpx import AsyncClient
 
 
+@pytest.fixture(autouse=True)
+def _enable_external_roots(monkeypatch, tmp_path):
+    """Permit pytest's ``tmp_path`` tree as a valid external root.
+
+    After the GHSA-r2qv I1 fix, external folders are opt-in via the
+    ``BAMBUDDY_EXTERNAL_ROOTS`` env var (empty by default → feature
+    disabled). The test suite's external dirs live under pytest's
+    per-session ``tmp_path`` root, which is a subtree of the OS tmp
+    dir, so allowlisting the parent of ``tmp_path`` lets every test
+    folder fixture pass the new validator. Autouse so individual tests
+    don't have to know the env var exists.
+    """
+    monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(tmp_path.parent))
+
+
 class TestExternalFolderCreation:
     """Tests for POST /library/folders/external."""
 
@@ -53,11 +68,18 @@ class TestExternalFolderCreation:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session):
-        """Verify 400 for non-existent path."""
+    async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session, tmp_path):
+        """Verify 400 for non-existent path within an allowed root.
+
+        After GHSA-r2qv I1 the allowlist check runs before the existence
+        check, so the test path must be inside ``BAMBUDDY_EXTERNAL_ROOTS``
+        (= ``tmp_path.parent`` per ``_enable_external_roots``) to actually
+        exercise the existence branch rather than the allowlist branch.
+        """
+        bad_path = tmp_path / "nonexistent" / "subdir"
         data = {
             "name": "Bad Path",
-            "external_path": "/nonexistent/path/that/does/not/exist",
+            "external_path": str(bad_path),
         }
         response = await async_client.post("/api/v1/library/folders/external", json=data)
         assert response.status_code == 400
@@ -65,15 +87,24 @@ class TestExternalFolderCreation:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_create_external_folder_system_dir_blocked(self, async_client: AsyncClient, db_session):
-        """Verify system directories are blocked."""
+    async def test_create_external_folder_outside_allowlist_blocked(self, async_client: AsyncClient, db_session):
+        """Paths outside ``BAMBUDDY_EXTERNAL_ROOTS`` are rejected (GHSA-r2qv I1).
+
+        Prior behaviour was a denylist (``/proc``, ``/sys``, ``/dev``, etc);
+        anything not enumerated passed, including ``/data`` containing
+        other users' archives. The allowlist replacement defaults to the
+        empty set; this test confirms that a path outside the (tmp-path)
+        allowlist set up by ``_enable_external_roots`` is rejected.
+        ``/proc`` is the canonical example of a system directory that
+        any operator allowlist would never legitimately include.
+        """
         data = {
             "name": "System",
             "external_path": "/proc",
         }
         response = await async_client.post("/api/v1/library/folders/external", json=data)
         assert response.status_code == 400
-        assert "system directory" in response.json()["detail"].lower()
+        assert "not within an allowed external root" in response.json()["detail"].lower()
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 55 - 4
backend/tests/integration/test_library_api.py

@@ -285,22 +285,24 @@ class TestLibraryFilesAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):
-        """Verify file rename fails with path separators."""
+        """Verify file rename fails with a forward slash (FAT32-illegal, #1540)."""
         lib_file = await file_factory(filename="test.3mf")
         data = {"filename": "path/to/file.3mf"}
         response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
         assert response.status_code == 400
-        assert "path separator" in response.json()["detail"].lower()
+        assert "invalid character" in response.json()["detail"].lower()
+        assert "/" in response.json()["detail"]
 
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):
-        """Verify file rename fails with backslash."""
+        """Verify file rename fails with a backslash (FAT32-illegal, #1540)."""
         lib_file = await file_factory(filename="test.3mf")
         data = {"filename": "path\\to\\file.3mf"}
         response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
         assert response.status_code == 400
-        assert "path separator" in response.json()["detail"].lower()
+        assert "invalid character" in response.json()["detail"].lower()
+        assert "\\" in response.json()["detail"]
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -1177,6 +1179,55 @@ class TestPrintFileUploadValidation:
         result = response.json()
         assert result["filename"] == "plate_1.gcode.3mf"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_upload_classifies_gcode_3mf_as_compound(self, async_client: AsyncClient, db_session):
+        """#1600 follow-up: upload path used to strip to the trailing
+        extension and store ``file_type='3mf'`` for sliced outputs, while
+        the external-folder scan stored ``file_type='gcode.3mf'``. Now
+        every ingest path goes through ``classify_file_type`` and
+        produces the canonical compound name."""
+        files = {
+            "file": (
+                "sliced.gcode.3mf",
+                self._valid_3mf_bytes(),
+                "application/zip",
+            )
+        }
+        response = await async_client.post("/api/v1/library/files", files=files)
+        assert response.status_code == 200
+        assert response.json()["file_type"] == "gcode.3mf"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_get_gcode_endpoint_accepts_compound_file_type(self, async_client: AsyncClient, db_session):
+        """#1600 follow-up: pre-fix, ``GET /files/{id}/gcode`` only handled
+        ``file_type`` of ``gcode`` or ``3mf`` and 400'd on a row whose
+        ``file_type`` was ``gcode.3mf`` — exactly the rows the external-
+        folder scan was creating. The gate now treats both as 3MF and
+        unzips the embedded gcode the same way."""
+        from backend.app.models.library import LibraryFile
+
+        # Persist a real `.gcode.3mf` zip under file_type='gcode.3mf' so
+        # the endpoint hits the new branch.
+        with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
+            tmp.write(self._valid_3mf_bytes(name="Metadata/plate_1.gcode"))
+            tmp_path = tmp.name
+
+        lib_file = LibraryFile(
+            filename="sliced.gcode.3mf",
+            file_path=tmp_path,
+            file_type="gcode.3mf",
+            file_size=Path(tmp_path).stat().st_size,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
+        assert response.status_code == 200
+        assert b"G28" in response.content
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):

+ 25 - 0
backend/tests/integration/test_maintenance_api.py

@@ -46,6 +46,31 @@ class TestMaintenanceTypesAPI:
         assert result["name"] == "Custom Test Task"
         assert result["is_system"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_custom_type_persists_wiki_url(self, async_client: AsyncClient):
+        """#1596: pre-fix, the POST handler hard-coded every constructor field
+        by name and silently dropped `wiki_url`. The schema accepted the value,
+        the response echoed `null`, and the row landed without it. Pin the
+        contract so the constructor doesn't drift again."""
+        data = {
+            "name": "Wiki URL Persistence Test",
+            "default_interval_hours": 50.0,
+            "interval_type": "hours",
+            "wiki_url": "https://wiki.example.com/lubrication",
+        }
+        response = await async_client.post("/api/v1/maintenance/types", json=data)
+        assert response.status_code == 200
+        assert response.json()["wiki_url"] == "https://wiki.example.com/lubrication"
+
+        # Verify it persists through a separate GET round-trip — the POST
+        # response could have echoed the request body without committing.
+        list_response = await async_client.get("/api/v1/maintenance/types")
+        assert list_response.status_code == 200
+        matching = [t for t in list_response.json() if t["name"] == data["name"]]
+        assert len(matching) == 1
+        assert matching[0]["wiki_url"] == "https://wiki.example.com/lubrication"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_maintenance_type(self, async_client: AsyncClient):

+ 211 - 0
backend/tests/integration/test_mfa_api.py

@@ -4241,6 +4241,217 @@ class TestOIDCEmailResolutionExtra:
         assert upd2.json()["email_claim"] == "preferred_username"
 
 
+# ===========================================================================
+# Issue #1569: standard 'email' claim fallback for User.email during
+# auto-provisioning when email_claim is a non-email identity claim.
+# ===========================================================================
+
+
+class TestOIDCStandardEmailFallback:
+    """Issue #1569: when email_claim is set to a non-email identity claim
+    (e.g. preferred_username on Authentik) and the IdP token also carries a
+    standard 'email' claim, auto-created users get User.email from the
+    standard claim — without affecting the auto-link gate."""
+
+    async def _get_user_and_link(self, db_session: AsyncSession, provider_id: int, sub: str):
+        from sqlalchemy import select
+
+        from backend.app.models.oidc_provider import UserOIDCLink
+        from backend.app.models.user import User as UserModel
+
+        link_result = await db_session.execute(
+            select(UserOIDCLink)
+            .where(UserOIDCLink.provider_id == provider_id)
+            .where(UserOIDCLink.provider_user_id == sub)
+        )
+        link = link_result.scalar_one_or_none()
+        if link is None:
+            return None, None
+        user_result = await db_session.execute(select(UserModel).where(UserModel.id == link.user_id))
+        return user_result.scalar_one_or_none(), link
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_email_claim_preferred_username_falls_back_to_standard_email(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+    ):
+        """email_claim=preferred_username + both claims → username from preferred_username,
+        email populated from the standard 'email' claim."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://issue1569-both.example"
+        client_id = "issue1569-both-client"
+
+        admin_token = await _setup_and_login(async_client, "i1569b_adm", "I1569b123!")
+        provider_id = await _create_provider_via_api(
+            async_client,
+            admin_token,
+            issuer,
+            client_id,
+            email_claim="preferred_username",
+            require_email_verified=False,
+            suffix="i1569both",
+        )
+
+        sub = f"sub-i1569-both-{secrets.token_hex(6)}"
+        await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={
+                "sub": sub,
+                "preferred_username": "jdoe",
+                "email": "jdoe@example.com",
+                "email_verified": True,
+            },
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        db_session.expire_all()
+        user, link = await self._get_user_and_link(db_session, provider_id, sub)
+        assert user is not None and link is not None
+        assert user.username == "jdoe"
+        assert user.email == "jdoe@example.com"
+        assert link.provider_email == "jdoe@example.com"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_email_claim_preferred_username_no_standard_email_keeps_email_none(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+    ):
+        """email_claim=preferred_username + no standard 'email' claim → behaviour unchanged:
+        username derived from preferred_username, User.email stays None."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://issue1569-noemail.example"
+        client_id = "issue1569-noemail-client"
+
+        admin_token = await _setup_and_login(async_client, "i1569n_adm", "I1569n123!")
+        provider_id = await _create_provider_via_api(
+            async_client,
+            admin_token,
+            issuer,
+            client_id,
+            email_claim="preferred_username",
+            require_email_verified=False,
+            suffix="i1569none",
+        )
+
+        sub = f"sub-i1569-none-{secrets.token_hex(6)}"
+        await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": sub, "preferred_username": "jdoe2"},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        db_session.expire_all()
+        user, link = await self._get_user_and_link(db_session, provider_id, sub)
+        assert user is not None and link is not None
+        assert user.username == "jdoe2"
+        assert user.email is None
+        assert link.provider_email is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_fallback_respects_email_verified_false(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+    ):
+        """email_claim=preferred_username + standard 'email' claim with email_verified=False
+        → fallback drops the email (User.email None) — matches Fall B semantics."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://issue1569-evfalse.example"
+        client_id = "issue1569-evfalse-client"
+
+        admin_token = await _setup_and_login(async_client, "i1569f_adm", "I1569f123!")
+        provider_id = await _create_provider_via_api(
+            async_client,
+            admin_token,
+            issuer,
+            client_id,
+            email_claim="preferred_username",
+            require_email_verified=False,
+            suffix="i1569evfalse",
+        )
+
+        sub = f"sub-i1569-evfalse-{secrets.token_hex(6)}"
+        await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={
+                "sub": sub,
+                "preferred_username": "jdoe3",
+                "email": "jdoe3@example.com",
+                "email_verified": False,
+            },
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        db_session.expire_all()
+        user, link = await self._get_user_and_link(db_session, provider_id, sub)
+        assert user is not None and link is not None
+        assert user.username == "jdoe3"
+        assert user.email is None
+        assert link.provider_email is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_fallback_skipped_when_email_claim_is_email(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+    ):
+        """email_claim='email' (default) — fallback path must NOT fire. Primary
+        resolver result is authoritative; this guards against the fallback
+        accidentally widening Fall-A/B semantics."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://issue1569-default.example"
+        client_id = "issue1569-default-client"
+
+        admin_token = await _setup_and_login(async_client, "i1569d_adm", "I1569d123!")
+        provider_id = await _create_provider_via_api(
+            async_client,
+            admin_token,
+            issuer,
+            client_id,
+            email_claim="email",
+            require_email_verified=True,
+            suffix="i1569default",
+        )
+
+        sub = f"sub-i1569-default-{secrets.token_hex(6)}"
+        # email_verified absent → Fall A drops it. Fallback must NOT
+        # re-instate the email since email_claim='email' is already the
+        # primary path.
+        await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": sub, "email": "jdoe4@example.com"},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        db_session.expire_all()
+        user, link = await self._get_user_and_link(db_session, provider_id, sub)
+        assert user is not None and link is not None
+        assert user.email is None
+        assert link.provider_email is None
+
+
 # ===========================================================================
 # E2E: Fall C (custom email claim) auto-link actually links existing user
 # ===========================================================================

+ 351 - 6
backend/tests/integration/test_projects_api.py

@@ -287,10 +287,19 @@ class TestProjectPartsTracking:
 
     @pytest.fixture
     async def archive_factory(self, db_session):
-        """Factory to create test archives."""
+        """Factory to create a test archive plus a matching PrintLogEntry.
+
+        Project stats aggregate from ``print_log_entries`` (#1593), so a
+        test that only writes archives wouldn't exercise the production
+        path — production always writes one log entry per run. The
+        factory mirrors that: every archive whose status is anything other
+        than ``"archived"`` (file shelved without printing) gets a log
+        entry whose status matches the archive.
+        """
 
         async def _create_archive(**kwargs):
             from backend.app.models.archive import PrintArchive
+            from backend.app.models.print_log import PrintLogEntry
 
             defaults = {
                 "filename": "test.3mf",
@@ -306,6 +315,16 @@ class TestProjectPartsTracking:
             db_session.add(archive)
             await db_session.commit()
             await db_session.refresh(archive)
+
+            if archive.status != "archived":
+                db_session.add(
+                    PrintLogEntry(
+                        archive_id=archive.id,
+                        print_name=archive.print_name,
+                        status=archive.status,
+                    )
+                )
+                await db_session.commit()
             return archive
 
         return _create_archive
@@ -436,10 +455,12 @@ class TestProjectArchivedStatusNotCounted:
 
     @pytest.fixture
     async def archive_factory(self, db_session):
-        """Factory to create test archives."""
+        """Factory to create a test archive plus a matching PrintLogEntry —
+        see TestProjectPartsTracking.archive_factory for rationale (#1593)."""
 
         async def _create_archive(**kwargs):
             from backend.app.models.archive import PrintArchive
+            from backend.app.models.print_log import PrintLogEntry
 
             defaults = {
                 "filename": "test.3mf",
@@ -455,6 +476,16 @@ class TestProjectArchivedStatusNotCounted:
             db_session.add(archive)
             await db_session.commit()
             await db_session.refresh(archive)
+
+            if archive.status != "archived":
+                db_session.add(
+                    PrintLogEntry(
+                        archive_id=archive.id,
+                        print_name=archive.print_name,
+                        status=archive.status,
+                    )
+                )
+                await db_session.commit()
             return archive
 
         return _create_archive
@@ -499,7 +530,10 @@ class TestProjectArchivedStatusNotCounted:
         our_project = next((p for p in data if p["name"] == "List Archived Test"), None)
         assert our_project is not None
         assert our_project["completed_count"] == 4  # Only completed, not archived
-        assert our_project["archive_count"] == 2  # Both archives exist as plates
+        # Post-#1593: archive_count is "print runs", not "files attached". An
+        # ``archived``-status file (shelved without printing) has no
+        # PrintLogEntry and doesn't count — only the actual printed run does.
+        assert our_project["archive_count"] == 1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -519,9 +553,201 @@ class TestProjectArchivedStatusNotCounted:
         data = response.json()
 
         assert data["stats"]["completed_prints"] == 10  # Only "completed"
-        assert data["stats"]["failed_prints"] == 2  # failed + aborted (count of archives, not sum)
-        assert data["stats"]["total_archives"] == 4  # All archives
-        assert data["stats"]["total_items"] == 20  # Sum of all quantities
+        assert data["stats"]["failed_prints"] == 2  # failed + aborted (count of runs)
+        # Post-#1593: total_archives counts runs from print_log_entries, not
+        # files. The ``archived`` row is a shelved file with no run, so it
+        # contributes 0; the other three (completed, failed, aborted) each
+        # produced a run.
+        assert data["stats"]["total_archives"] == 3
+        # total_items sums quantity per run: 10 (completed) + 3 (failed) + 2 (aborted) = 15
+        assert data["stats"]["total_items"] == 15
+
+
+class TestProjectStatsPerRun:
+    """Project stats aggregate per-run from ``print_log_entries`` so
+    reprints and multi-plate prints count every run (#1593). Pre-fix the
+    stats counted ``print_archives`` (one row per file), so 3 reprints of
+    one file showed as 1 job with plate-1-only filament/time/cost.
+    """
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {"name": "Per-Run Stats Project", "color": "#FF0000"}
+            defaults.update(kwargs)
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def archive_with_runs(self, db_session):
+        """Build a single archive + N PrintLogEntry rows.
+
+        Models the reporter's case: one source file (archive) is reprinted
+        N times, each run with its own duration / filament / cost.
+        """
+
+        async def _create(*, project_id: int, runs: list[dict], archive_status: str = "completed", quantity: int = 1):
+            from backend.app.models.archive import PrintArchive
+            from backend.app.models.print_log import PrintLogEntry
+
+            archive = PrintArchive(
+                filename="reprinted.3mf",
+                file_path="test/reprinted.3mf",
+                file_size=1000,
+                print_name="Reprinted Print",
+                status=archive_status,
+                quantity=quantity,
+                project_id=project_id,
+            )
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+
+            for run in runs:
+                db_session.add(
+                    PrintLogEntry(
+                        archive_id=archive.id,
+                        print_name=archive.print_name,
+                        status=run.get("status", "completed"),
+                        duration_seconds=run.get("duration_seconds"),
+                        filament_used_grams=run.get("filament_used_grams"),
+                        cost=run.get("cost"),
+                        energy_kwh=run.get("energy_kwh"),
+                        energy_cost=run.get("energy_cost"),
+                    )
+                )
+            await db_session.commit()
+            return archive
+
+        return _create
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_three_reprints_count_as_three_jobs_with_summed_totals(
+        self, async_client: AsyncClient, project_factory, archive_with_runs
+    ):
+        """Reporter's case: 3 runs of one multi-plate file should report
+        3 jobs and summed time / filament / cost — pre-fix it reported 1
+        job with plate-1-only totals."""
+        project = await project_factory()
+        await archive_with_runs(
+            project_id=project.id,
+            runs=[
+                {"duration_seconds": 7140, "filament_used_grams": 19.2, "cost": 0.40},
+                {"duration_seconds": 6000, "filament_used_grams": 20.0, "cost": 0.40},
+                {"duration_seconds": 6300, "filament_used_grams": 18.8, "cost": 0.40},
+            ],
+        )
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        stats = response.json()["stats"]
+
+        assert stats["total_archives"] == 3, "3 runs must show as 3 jobs"
+        assert stats["completed_prints"] == 3, "Each run with quantity=1 contributes 1 part"
+        assert stats["total_filament_grams"] == round(19.2 + 20.0 + 18.8, 2)
+        assert stats["total_print_time_hours"] == round((7140 + 6000 + 6300) / 3600, 2)
+        # Cost rounds at 2 decimals — 3 * 0.40 = 1.20
+        assert stats["estimated_cost"] == 1.20
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_orphan_log_entries_do_not_bleed_into_projects(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Log rows whose ``archive_id`` is NULL (archive deleted via
+        ON DELETE SET NULL) must not leak into any project — the inner
+        join filters them out by construction."""
+        from backend.app.models.print_log import PrintLogEntry
+
+        project = await project_factory()
+
+        # Orphan log entries — no archive_id.
+        for _ in range(5):
+            db_session.add(
+                PrintLogEntry(
+                    archive_id=None,
+                    print_name="Orphan Run",
+                    status="completed",
+                    duration_seconds=3600,
+                    filament_used_grams=20.0,
+                    cost=0.5,
+                )
+            )
+        await db_session.commit()
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        stats = response.json()["stats"]
+
+        # None of the orphan rows are attributable to this project.
+        assert stats["total_archives"] == 0
+        assert stats["completed_prints"] == 0
+        assert stats["total_filament_grams"] == 0
+        assert stats["total_print_time_hours"] == 0
+        assert stats["estimated_cost"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_mixed_run_outcomes_split_completed_and_failed(
+        self, async_client: AsyncClient, project_factory, archive_with_runs
+    ):
+        """A multi-run archive with mixed outcomes splits cleanly between
+        completed_prints (per-quantity) and failed_prints (per-run)."""
+        project = await project_factory()
+        await archive_with_runs(
+            project_id=project.id,
+            quantity=2,
+            runs=[
+                {"status": "completed", "filament_used_grams": 30.0},
+                {"status": "completed", "filament_used_grams": 30.0},
+                {"status": "failed", "filament_used_grams": 5.0},
+                {"status": "aborted", "filament_used_grams": 2.0},
+            ],
+        )
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        stats = response.json()["stats"]
+
+        assert stats["total_archives"] == 4
+        # 2 completed runs × quantity=2 each = 4 parts
+        assert stats["completed_prints"] == 4
+        # 2 failure runs (failed + aborted) count as 2, not 2*quantity
+        assert stats["failed_prints"] == 2
+        # All 4 runs contribute filament: 30 + 30 + 5 + 2 = 67
+        assert stats["total_filament_grams"] == 67.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_quick_stats_in_list_view_agree_with_per_project_stats(
+        self, async_client: AsyncClient, project_factory, archive_with_runs
+    ):
+        """The /projects list view's quick stats must agree with
+        /projects/{id}'s detailed stats — both come from the same per-run
+        aggregation."""
+        project = await project_factory(name="Quick-Stats Alignment")
+        await archive_with_runs(
+            project_id=project.id,
+            quantity=1,
+            runs=[
+                {"status": "completed"},
+                {"status": "completed"},
+                {"status": "failed"},
+            ],
+        )
+
+        list_resp = await async_client.get("/api/v1/projects/")
+        ours = next(p for p in list_resp.json() if p["name"] == "Quick-Stats Alignment")
+        assert ours["archive_count"] == 3
+        assert ours["completed_count"] == 2
+        assert ours["failed_count"] == 1
 
 
 class TestProjectArchivesAPI:
@@ -953,3 +1179,122 @@ class TestProjectExportImport:
         response = await async_client.post("/api/v1/projects/import/file", files=files)
         assert response.status_code == 400
         assert "Invalid JSON" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_rejects_absolute_path_in_folder_name(self, async_client: AsyncClient, tmp_path):
+        """Absolute paths in `linked_folders[*].name` must not escape library_dir.
+
+        Verbatim shape from the upstream advisory: attacker sets folder name to
+        an absolute path, expecting Python's ``Path("/lib") / "/anywhere"`` to
+        collapse to ``Path("/anywhere")`` and let the next file write land
+        outside the library directory.
+        """
+        import io
+        import json
+        import zipfile
+
+        target_outside = tmp_path / "outside" / "owned"
+        # Build a ZIP whose folder name points outside library_dir entirely.
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr(
+                "project.json",
+                json.dumps(
+                    {
+                        "name": "innocent",
+                        "linked_folders": [{"name": str(target_outside)}],
+                    }
+                ),
+            )
+            zf.writestr(f"files/{target_outside}/evil.pth", b"import os; os.system('echo pwned > /tmp/owned')\n")
+
+        zip_buffer.seek(0)
+        files = {"file": ("evil.zip", zip_buffer, "application/zip")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400, response.text
+        assert not target_outside.exists(), "Attacker payload landed outside library_dir"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_rejects_dotdot_in_folder_name(self, async_client: AsyncClient):
+        """`..` segments in folder name must be rejected."""
+        import io
+        import json
+        import zipfile
+
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr(
+                "project.json",
+                json.dumps(
+                    {
+                        "name": "innocent",
+                        "linked_folders": [{"name": "../../../etc"}],
+                    }
+                ),
+            )
+            zf.writestr("files/../../../etc/x.txt", b"x")
+
+        zip_buffer.seek(0)
+        files = {"file": ("evil.zip", zip_buffer, "application/zip")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_rejects_dotdot_in_relative_path(self, async_client: AsyncClient):
+        """`..` segments in the per-entry path (Vector B in the advisory) must
+        be rejected even when the folder name itself is fine."""
+        import io
+        import json
+        import zipfile
+
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr(
+                "project.json",
+                json.dumps(
+                    {
+                        "name": "innocent",
+                        "linked_folders": [{"name": "ok"}],
+                    }
+                ),
+            )
+            # Folder name is benign, but the file path inside attempts to
+            # escape via ``..``.
+            zf.writestr("files/ok/../../../etc/x.txt", b"x")
+
+        zip_buffer.seek(0)
+        files = {"file": ("evil.zip", zip_buffer, "application/zip")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_legit_nested_zip_still_works(self, async_client: AsyncClient):
+        """A legitimate ZIP with a nested file path inside the folder must
+        continue to import cleanly. Guards against the fix being over-strict."""
+        import io
+        import json
+        import zipfile
+
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr(
+                "project.json",
+                json.dumps(
+                    {
+                        "name": "nested-ok",
+                        "linked_folders": [{"name": "OkFolder"}],
+                    }
+                ),
+            )
+            zf.writestr("files/OkFolder/sub/dir/inside.txt", b"hello")
+
+        zip_buffer.seek(0)
+        files = {"file": ("nested.zip", zip_buffer, "application/zip")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert data["name"] == "nested-ok"

+ 33 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -1608,6 +1608,39 @@ class TestUpdateSpoolWeightSpoolman:
         assert resp.status_code == 200
         assert resp.json()["weight_used"] == 500
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stale_local_row_does_not_shadow_spoolman(
+        self, async_client: AsyncClient, db_session, spool_factory, spoolman_settings
+    ):
+        """Regression for #1530: when Spoolman mode is on, a stale local Spool
+        sharing the same numeric id must NOT absorb the update — Spoolman is
+        the authoritative target."""
+        local_spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
+        # Spoolman spool with the SAME numeric id as the local stale row.
+        sm_spool = _spoolman_spool_fixture(local_spool.id, spool_weight=250.0, filament_weight=1000.0)
+        mock_client = _mock_spoolman_client()
+        mock_client.get_spool = AsyncMock(return_value=sm_spool)
+        mock_client.update_spool = AsyncMock(return_value=sm_spool)
+
+        with (
+            patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
+            patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
+        ):
+            resp = await async_client.post(
+                f"{API}/scale/update-spool-weight",
+                json={"spool_id": local_spool.id, "weight_grams": 750},
+            )
+
+        assert resp.status_code == 200
+        # Spoolman got the update.
+        mock_client.update_spool.assert_called_once_with(spool_id=local_spool.id, remaining_weight=pytest.approx(500.0))
+        # Local row is untouched — the bug was that the local update silently
+        # absorbed the request while Spoolman stayed stale.
+        await db_session.refresh(local_spool)
+        assert local_spool.weight_used == 0
+        assert local_spool.last_scale_weight is None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):

+ 25 - 3
backend/tests/integration/test_system_api.py

@@ -219,10 +219,32 @@ class TestSystemAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_system_info_with_archives(self, async_client: AsyncClient, printer_factory, archive_factory):
-        """Verify database stats include archive counts."""
+        """Verify database stats include archive counts.
+
+        Post-#1593 `total_print_time_seconds` is summed from
+        `PrintLogEntry.duration_seconds` (the *actual* per-run duration),
+        not `PrintArchive.print_time_seconds` (the slicer estimate). The
+        archive_factory derives the run's duration from
+        ``completed_at - started_at`` on the archive, so the test sets
+        those so each run carries a duration the system route can sum.
+        """
+        from datetime import datetime, timezone
+
         printer = await printer_factory()
-        await archive_factory(printer.id, status="completed", print_time_seconds=3600)
-        await archive_factory(printer.id, status="failed", print_time_seconds=1800)
+        await archive_factory(
+            printer.id,
+            status="completed",
+            print_time_seconds=3600,
+            started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
+        )
+        await archive_factory(
+            printer.id,
+            status="failed",
+            print_time_seconds=1800,
+            started_at=datetime(2026, 5, 2, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 2, 10, 30, tzinfo=timezone.utc),
+        )
 
         with (
             patch("backend.app.api.routes.system.psutil") as mock_psutil,

+ 31 - 18
backend/tests/integration/test_virtual_printer_api.py

@@ -61,33 +61,46 @@ class TestVirtualPrinterSettingsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_mode_to_print_queue(self, async_client: AsyncClient):
-        """Verify mode can be set to print_queue."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?mode=print_queue")
+    async def test_update_mode_to_queue(self, async_client: AsyncClient):
+        """Verify mode can be set to the canonical 'queue' value."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
 
         assert response.status_code == 200
         result = response.json()
-        assert result["mode"] == "print_queue"
+        assert result["mode"] == "queue"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_mode_legacy_queue_maps_to_review(self, async_client: AsyncClient):
-        """Verify legacy 'queue' mode is normalized to 'review'."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
+    async def test_update_mode_legacy_print_queue_normalises_to_queue(self, async_client: AsyncClient):
+        """Legacy `print_queue` is accepted on input and translated to `queue` on
+        storage so the UI button label and the support-bundle field agree
+        (#1429 mode-label discrepancy)."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?mode=print_queue")
 
         assert response.status_code == 200
         result = response.json()
-        assert result["mode"] == "review"  # Legacy queue maps to review
+        assert result["mode"] == "queue"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_mode_to_immediate(self, async_client: AsyncClient):
-        """Verify mode can be set to immediate."""
+    async def test_update_mode_legacy_immediate_normalises_to_archive(self, async_client: AsyncClient):
+        """Legacy `immediate` is accepted on input and translated to `archive`
+        on storage (#1429 mode-label discrepancy)."""
         response = await async_client.put("/api/v1/settings/virtual-printer?mode=immediate")
 
         assert response.status_code == 200
         result = response.json()
-        assert result["mode"] == "immediate"
+        assert result["mode"] == "archive"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mode_to_archive(self, async_client: AsyncClient):
+        """Verify mode can be set to the canonical 'archive' value."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?mode=archive")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mode"] == "archive"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -136,7 +149,7 @@ class TestVirtualPrinterSettingsAPI:
                 return_value={
                     "enabled": True,
                     "running": True,
-                    "mode": "immediate",
+                    "mode": "archive",
                     "name": "Bambuddy",
                     "serial": "00M09A391800001",
                     "pending_files": 0,
@@ -157,7 +170,7 @@ class TestVirtualPrinterSettingsAPI:
                 return_value={
                     "enabled": False,
                     "running": False,
-                    "mode": "immediate",
+                    "mode": "archive",
                     "name": "Bambuddy",
                     "serial": "00M09A391800001",
                     "pending_files": 0,
@@ -283,7 +296,7 @@ class TestVirtualPrinterAutoDispatchAPI:
             "/api/v1/virtual-printers",
             json={
                 "name": "TestDefaultDispatch",
-                "mode": "print_queue",
+                "mode": "queue",
                 "access_code": "12345678",
             },
         )
@@ -300,7 +313,7 @@ class TestVirtualPrinterAutoDispatchAPI:
             "/api/v1/virtual-printers",
             json={
                 "name": "TestManualDispatch",
-                "mode": "print_queue",
+                "mode": "queue",
                 "access_code": "12345678",
                 "auto_dispatch": False,
             },
@@ -319,7 +332,7 @@ class TestVirtualPrinterAutoDispatchAPI:
             "/api/v1/virtual-printers",
             json={
                 "name": "TestToggleDispatch",
-                "mode": "print_queue",
+                "mode": "queue",
                 "access_code": "12345678",
             },
         )
@@ -359,7 +372,7 @@ class TestVirtualPrinterTailscaleToggleAPI:
             "/api/v1/virtual-printers",
             json={
                 "name": "TestTailscaleToggle",
-                "mode": "immediate",
+                "mode": "archive",
                 "access_code": "12345678",
             },
         )
@@ -426,7 +439,7 @@ class TestVirtualPrinterDiagnosticAPI:
         """A freshly created (disabled) VP fails the 'enabled' check."""
         create_resp = await async_client.post(
             "/api/v1/virtual-printers",
-            json={"name": "TestDiagVP", "mode": "immediate", "access_code": "12345678"},
+            json={"name": "TestDiagVP", "mode": "archive", "access_code": "12345678"},
         )
         assert create_resp.status_code == 200
         vp_id = create_resp.json()["id"]

+ 225 - 0
backend/tests/integration/test_webhook_printer_status.py

@@ -0,0 +1,225 @@
+"""Regression tests for the webhook printer-status / stop / cancel routes.
+
+Pre-fix the routes treated ``printer_manager.get_status(...)``'s return value
+as a dict and called ``.get(...)`` on it. The return is a ``PrinterState``
+dataclass (``backend/app/services/bambu_mqtt.py``), so the call raised
+``AttributeError`` and surfaced as a generic 500 for any printer that
+actually had a status row. See #1584.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.bambu_mqtt import PrinterState
+
+
+@pytest.fixture
+async def api_key_data(async_client: AsyncClient, db_session):
+    """API key with read_status + control_printer scopes — covers status,
+    stop, and cancel in a single fixture."""
+    from backend.app.core.auth import generate_api_key
+    from backend.app.models.api_key import APIKey
+
+    full_key, key_hash, key_prefix = generate_api_key()
+    api_key = APIKey(
+        name="webhook-status-test-key",
+        key_hash=key_hash,
+        key_prefix=key_prefix,
+        can_read_status=True,
+        can_control_printer=True,
+        enabled=True,
+    )
+    db_session.add(api_key)
+    await db_session.commit()
+    return full_key
+
+
+@pytest.fixture
+async def printer_row(db_session):
+    from backend.app.models.printer import Printer
+
+    printer = Printer(
+        name="StatusTest",
+        ip_address="192.168.1.44",
+        access_code="12345678",
+        serial_number="00M00A000000010",
+        model="P1S",
+    )
+    db_session.add(printer)
+    await db_session.commit()
+    return printer
+
+
+class TestWebhookGetPrinterStatus:
+    """``GET /api/v1/webhook/printer/{id}/status`` — the route reads the
+    dataclass via attribute access, not ``.get(...)``. Pre-fix the call
+    raised AttributeError → 500 for every printer with a status row.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_200_with_connected_dataclass_status(
+        self,
+        async_client: AsyncClient,
+        api_key_data,
+        printer_row,
+    ):
+        """A live PrinterState dataclass must yield a 200 with the
+        attributes mapped into the response — this is the exact regression
+        from #1584 where the dataclass crashed the ``.get(...)`` calls."""
+        state = PrinterState(
+            connected=True,
+            state="RUNNING",
+            current_print="bench.3mf",
+            progress=42.0,
+            remaining_time=1234,
+        )
+        with patch(
+            "backend.app.api.routes.webhook.printer_manager.get_status",
+            MagicMock(return_value=state),
+        ):
+            resp = await async_client.get(
+                f"/api/v1/webhook/printer/{printer_row.id}/status",
+                headers={"X-API-Key": api_key_data},
+            )
+
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert body["id"] == printer_row.id
+        assert body["name"] == "StatusTest"
+        assert body["connected"] is True
+        assert body["state"] == "RUNNING"
+        assert body["current_print"] == "bench.3mf"
+        assert body["progress"] == 42.0
+        assert body["remaining_time"] == 1234
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_200_when_status_is_none(
+        self,
+        async_client: AsyncClient,
+        api_key_data,
+        printer_row,
+    ):
+        """A registered printer the manager hasn't seen yet returns None from
+        ``get_status``; the response must still be 200 with sensible
+        defaults rather than 500."""
+        with patch(
+            "backend.app.api.routes.webhook.printer_manager.get_status",
+            MagicMock(return_value=None),
+        ):
+            resp = await async_client.get(
+                f"/api/v1/webhook/printer/{printer_row.id}/status",
+                headers={"X-API-Key": api_key_data},
+            )
+
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert body["id"] == printer_row.id
+        assert body["connected"] is False
+        assert body["state"] is None
+        assert body["current_print"] is None
+        assert body["progress"] is None
+        assert body["remaining_time"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_404_when_printer_does_not_exist(
+        self,
+        async_client: AsyncClient,
+        api_key_data,
+    ):
+        resp = await async_client.get(
+            "/api/v1/webhook/printer/99999/status",
+            headers={"X-API-Key": api_key_data},
+        )
+        assert resp.status_code == 404
+
+
+class TestWebhookStopPrint:
+    """``POST /api/v1/webhook/printer/{id}/stop`` — same dataclass-shape
+    fix applies to the connection / state precondition checks (#1584)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_503_when_disconnected(
+        self,
+        async_client: AsyncClient,
+        api_key_data,
+        printer_row,
+    ):
+        state = PrinterState(connected=False, state="unknown")
+        with patch(
+            "backend.app.api.routes.webhook.printer_manager.get_status",
+            MagicMock(return_value=state),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/webhook/printer/{printer_row.id}/stop",
+                headers={"X-API-Key": api_key_data},
+            )
+        # Pre-fix this would have 500'd on `status.get(...)`. Now it
+        # cleanly returns the documented 503.
+        assert resp.status_code == 503
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_409_when_not_running(
+        self,
+        async_client: AsyncClient,
+        api_key_data,
+        printer_row,
+    ):
+        state = PrinterState(connected=True, state="FINISH")
+        with patch(
+            "backend.app.api.routes.webhook.printer_manager.get_status",
+            MagicMock(return_value=state),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/webhook/printer/{printer_row.id}/stop",
+                headers={"X-API-Key": api_key_data},
+            )
+        assert resp.status_code == 409
+
+
+class TestWebhookCancelPrint:
+    """``POST /api/v1/webhook/printer/{id}/cancel`` — same fix shape."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_503_when_disconnected(
+        self,
+        async_client: AsyncClient,
+        api_key_data,
+        printer_row,
+    ):
+        state = PrinterState(connected=False, state="unknown")
+        with patch(
+            "backend.app.api.routes.webhook.printer_manager.get_status",
+            MagicMock(return_value=state),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/webhook/printer/{printer_row.id}/cancel",
+                headers={"X-API-Key": api_key_data},
+            )
+        assert resp.status_code == 503
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_409_when_not_running_or_paused(
+        self,
+        async_client: AsyncClient,
+        api_key_data,
+        printer_row,
+    ):
+        state = PrinterState(connected=True, state="IDLE")
+        with patch(
+            "backend.app.api.routes.webhook.printer_manager.get_status",
+            MagicMock(return_value=state),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/webhook/printer/{printer_row.id}/cancel",
+                headers={"X-API-Key": api_key_data},
+            )
+        assert resp.status_code == 409

+ 146 - 0
backend/tests/unit/services/test_archive_service.py

@@ -729,3 +729,149 @@ class TestGcodeHeaderFilamentUsage:
         meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse()
         assert "filament_used_grams" not in meta
         assert "filament_used_mm" not in meta
+
+
+class TestMultiPlateSliceInfoSum:
+    """Multi-plate ``.gcode.3mf`` exports must produce file-level totals that
+    are the SUM of every plate's prediction + weight, not plate-1 only.
+
+    Pre-fix the parser used ``root.find(".//plate")`` and only read the
+    first plate's metadata, so the archive card and project rollup
+    under-reported by roughly the number of plates (#1593).
+    """
+
+    @staticmethod
+    def _make_3mf_with_slice_info(slice_info_xml: str) -> str:
+        """Write a minimal .3mf with the given slice_info.config payload.
+
+        Bambu Studio's slice_info.config is the file the parser reads for
+        file-level `prediction` / `weight`; the rest of the 3MF members
+        aren't required for this test.
+        """
+        import os
+        import tempfile
+        import zipfile
+
+        fd, path = tempfile.mkstemp(suffix=".3mf")
+        os.close(fd)
+        with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr("Metadata/slice_info.config", slice_info_xml)
+        return path
+
+    def test_three_plate_file_sums_prediction_and_weight(self):
+        """The reporter's case: three plates with distinct prediction +
+        weight values must yield file-level totals that are the sum.
+        """
+        from backend.app.services.archive import ThreeMFParser
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1" />
+                <metadata key="prediction" value="7140" />
+                <metadata key="weight" value="19.2" />
+            </plate>
+            <plate>
+                <metadata key="index" value="2" />
+                <metadata key="prediction" value="6000" />
+                <metadata key="weight" value="20.0" />
+            </plate>
+            <plate>
+                <metadata key="index" value="3" />
+                <metadata key="prediction" value="6300" />
+                <metadata key="weight" value="18.8" />
+            </plate>
+        </config>
+        """
+        parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
+        meta = parser.parse()
+        assert meta["print_time_seconds"] == 7140 + 6000 + 6300  # 19440
+        assert meta["filament_used_grams"] == round(19.2 + 20.0 + 18.8, 2)  # 58.0
+        # Multi-plate file: no single plate index should be claimed at the
+        # file level — the archive represents all plates, not a specific one.
+        assert parser.plate_number is None
+
+    def test_single_plate_file_preserves_plate_index_and_objects(self):
+        """The single-plate path must still set ``_plate_index`` and pick
+        up printable objects — these only make sense when the archive
+        represents exactly one plate.
+        """
+        from backend.app.services.archive import ThreeMFParser
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="2" />
+                <metadata key="prediction" value="3600" />
+                <metadata key="weight" value="50.5" />
+                <metadata key="curr_bed_type" value="textured_pei" />
+                <object identify_id="1" name="Part_A" skipped="false" />
+                <object identify_id="2" name="Part_B" skipped="true" />
+            </plate>
+        </config>
+        """
+        parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
+        meta = parser.parse()
+        assert meta["print_time_seconds"] == 3600
+        assert meta["filament_used_grams"] == 50.5
+        # Single-plate exports surface the plate index via ``plate_number``
+        # (``_plate_index`` is an internal key cleared at the end of parse).
+        assert parser.plate_number == 2
+        assert meta["bed_type"] == "textured_pei"
+        assert meta["printable_objects"] == {1: "Part_A"}
+
+    def test_multi_plate_ignores_per_plate_objects(self):
+        """Multi-plate exports must NOT carry a single plate's objects at
+        the file level — the ``/plates`` endpoint surfaces them per-plate.
+        Conflating them would attach plate-1's parts to the whole archive.
+        """
+        from backend.app.services.archive import ThreeMFParser
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1" />
+                <metadata key="prediction" value="1000" />
+                <metadata key="weight" value="10.0" />
+                <object identify_id="1" name="Part_A" skipped="false" />
+            </plate>
+            <plate>
+                <metadata key="index" value="2" />
+                <metadata key="prediction" value="1500" />
+                <metadata key="weight" value="15.0" />
+                <object identify_id="2" name="Part_B" skipped="false" />
+            </plate>
+        </config>
+        """
+        parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
+        meta = parser.parse()
+        assert meta["print_time_seconds"] == 2500
+        assert meta["filament_used_grams"] == 25.0
+        # No archive-level object list when there's more than one plate.
+        assert "printable_objects" not in meta
+        assert parser.plate_number is None
+
+    def test_missing_or_malformed_values_are_skipped(self):
+        """A plate with a malformed prediction/weight string must skip
+        that field, not poison the sum or raise — defensive parsing was
+        already present per-field; the sum loop must preserve it.
+        """
+        from backend.app.services.archive import ThreeMFParser
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="prediction" value="100" />
+                <metadata key="weight" value="not-a-number" />
+            </plate>
+            <plate>
+                <metadata key="prediction" value="200" />
+                <metadata key="weight" value="5.0" />
+            </plate>
+        </config>
+        """
+        meta = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml)).parse()
+        assert meta["print_time_seconds"] == 300
+        # Only the second plate's weight contributed.
+        assert meta["filament_used_grams"] == 5.0

+ 116 - 0
backend/tests/unit/services/test_attach_timelapse_safe_path.py

@@ -0,0 +1,116 @@
+"""Regression tests for ArchiveService.attach_timelapse path-traversal guard.
+
+``filename`` ultimately comes from a printer's FTP listing or a query
+parameter on ``POST /archives/{id}/timelapse/select``. A compromised printer
+that returns a malicious filename (e.g. ``"../../etc/passwd"``) used to land
+the write outside the archive directory. The safe-join helper now rejects
+such names; this test locks the behaviour in.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from backend.app.services.archive import ArchiveService
+
+
+@pytest.mark.asyncio
+async def test_attach_timelapse_rejects_dotdot_filename(tmp_path: Path, monkeypatch):
+    """A ``..`` traversal in filename must not land bytes outside archive_dir."""
+    # Stage an archive directory that the service thinks is owned.
+    archive_dir = tmp_path / "archive" / "1" / "20260101_test"
+    archive_dir.mkdir(parents=True)
+    # Repoint settings.base_dir so attach_timelapse's archive_dir = file_path.parent
+    # resolves to our tmp directory.
+    monkeypatch.setattr(
+        "backend.app.services.archive.settings",
+        MagicMock(base_dir=tmp_path),
+    )
+
+    db = MagicMock()
+    db.commit = AsyncMock()
+    service = ArchiveService(db)
+
+    # Mock the archive lookup to return a row whose file_path resolves under tmp_path.
+    fake_archive = MagicMock()
+    fake_archive.file_path = "archive/1/20260101_test/file.3mf"
+    service.get_archive = AsyncMock(return_value=fake_archive)
+
+    # The attacker-controlled filename in the threat model.
+    malicious = "../../etc/passwd_pwned"
+
+    result = await service.attach_timelapse(
+        archive_id=1,
+        timelapse_data=b"would-be-attacker-payload",
+        filename=malicious,
+    )
+
+    # The helper rejected the join → service returns False.
+    assert result is False
+    # And no payload landed at the target outside archive_dir.
+    target_outside = tmp_path / "etc" / "passwd_pwned"
+    assert not target_outside.exists(), "Attacker payload landed outside archive_dir"
+    # And no payload landed under archive_dir either (since we rejected before write).
+    assert not list(archive_dir.glob("*"))
+
+
+@pytest.mark.asyncio
+async def test_attach_timelapse_rejects_absolute_filename(tmp_path: Path, monkeypatch):
+    """An absolute path in filename must not collapse the join."""
+    archive_dir = tmp_path / "archive" / "1" / "20260101_test"
+    archive_dir.mkdir(parents=True)
+    monkeypatch.setattr(
+        "backend.app.services.archive.settings",
+        MagicMock(base_dir=tmp_path),
+    )
+
+    db = MagicMock()
+    db.commit = AsyncMock()
+    service = ArchiveService(db)
+
+    fake_archive = MagicMock()
+    fake_archive.file_path = "archive/1/20260101_test/file.3mf"
+    service.get_archive = AsyncMock(return_value=fake_archive)
+
+    result = await service.attach_timelapse(
+        archive_id=1,
+        timelapse_data=b"x",
+        filename="/tmp/owned_via_absolute",  # nosec B108
+    )
+
+    assert result is False
+    assert not Path("/tmp/owned_via_absolute").exists()  # nosec B108
+
+
+@pytest.mark.asyncio
+async def test_attach_timelapse_accepts_legit_filename(tmp_path: Path, monkeypatch):
+    """The legitimate happy path must still work — the fix isn't over-strict."""
+    archive_dir = tmp_path / "archive" / "1" / "20260101_test"
+    archive_dir.mkdir(parents=True)
+    monkeypatch.setattr(
+        "backend.app.services.archive.settings",
+        MagicMock(base_dir=tmp_path),
+    )
+
+    db = MagicMock()
+    db.commit = AsyncMock()
+    service = ArchiveService(db)
+
+    fake_archive = MagicMock()
+    fake_archive.file_path = "archive/1/20260101_test/file.3mf"
+    fake_archive.timelapse_path = None
+    service.get_archive = AsyncMock(return_value=fake_archive)
+
+    result = await service.attach_timelapse(
+        archive_id=1,
+        timelapse_data=b"hello-timelapse",
+        filename="timelapse_2026-01-01_12-00-00.mp4",
+    )
+
+    assert result is True
+    landed = archive_dir / "timelapse_2026-01-01_12-00-00.mp4"
+    assert landed.exists()
+    assert landed.read_bytes() == b"hello-timelapse"

+ 134 - 2
backend/tests/unit/services/test_bambu_cloud.py

@@ -202,10 +202,13 @@ class TestBambuCloudTOTPVerification:
 
     @pytest.mark.asyncio
     async def test_verify_totp_cloudflare_blocked(self, cloud_service):
-        """When Cloudflare blocks request, should handle gracefully."""
+        """When Cloudflare returns a 'Just a moment...' interstitial instead of
+        JSON, surface the actionable CF-specific message (issue #1575) rather
+        than the opaque "Invalid response from Bambu Cloud" parse error."""
         mock_response = MagicMock()
         mock_response.status_code = 403
         mock_response.text = "<!DOCTYPE html><html><head><title>Just a moment...</title>"
+        mock_response.headers = {}
         # json() raises an error when response is HTML
         mock_response.json.side_effect = ValueError("No JSON")
 
@@ -215,7 +218,8 @@ class TestBambuCloudTOTPVerification:
             result = await cloud_service.verify_totp("test-tfa-key", "123456")
 
             assert result["success"] is False
-            assert "Invalid response" in result["message"]
+            assert "Cloudflare" in result["message"]
+            assert "bambulab.com" in result["message"]
 
     @pytest.mark.asyncio
     async def test_verify_totp_uses_honest_bambuddy_user_agent(self, cloud_service):
@@ -305,3 +309,131 @@ class TestBambuCloudRegion:
             url = mock_post.call_args[0][0]
             assert "bambulab.cn/api/sign-in/tfa" in url
             assert "bambulab.com" not in url
+
+
+# ===========================================================================
+# Issue #1575: Cloudflare interstitial → actionable error message
+# ===========================================================================
+
+
+class TestCloudflareChallengeDetection:
+    """The _detect_cloudflare_challenge helper inspects a response and returns
+    the user-actionable message when CF returned a challenge / mitigation page
+    instead of JSON. None otherwise."""
+
+    # The actual interstitial fragment captured from issue #1575's log — keeping
+    # this verbatim so future regressions in detection are checked against the
+    # exact body shape the user hit, not a stylised copy.
+    _REPORTER_INTERSTITIAL = (
+        '<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...'
+        '</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">'
+        '<meta http-equiv="X-UA-Compatible" content="IE=Edge">'
+        '<meta name="robots" content="noindex,nofollow">'
+        '<meta name="viewport" content="width=device-width,initial-scale=1">'
+    )
+
+    def test_just_a_moment_title_in_body(self):
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = self._REPORTER_INTERSTITIAL
+        response.status_code = 200
+        response.headers = {}
+        assert _detect_cloudflare_challenge(response) is not None
+
+    def test_challenges_cloudflare_com_in_body(self):
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = (
+            '<html><body><script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script></body></html>'
+        )
+        response.status_code = 200
+        response.headers = {}
+        assert _detect_cloudflare_challenge(response) is not None
+
+    def test_cf_mitigated_403(self):
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = ""
+        response.status_code = 403
+        response.headers = {"cf-mitigated": "challenge"}
+        assert _detect_cloudflare_challenge(response) is not None
+
+    def test_cf_ray_503(self):
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = "<html>Under attack</html>"
+        response.status_code = 503
+        response.headers = {"cf-ray": "abc-DEF"}
+        assert _detect_cloudflare_challenge(response) is not None
+
+    def test_real_json_400_is_not_a_challenge(self):
+        """Application-level 400 with the real "Login failed" JSON the API
+        normally returns must NOT be misclassified as a CF challenge — that
+        would suppress the actionable upstream error."""
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = '{"code":5,"error":"Login failed"}'
+        response.status_code = 400
+        response.headers = {"cf-ray": "abc-DEF", "server": "cloudflare"}
+        assert _detect_cloudflare_challenge(response) is None
+
+    def test_message_mentions_bambu_lab_and_cloudflare(self):
+        """The message must clearly attribute the block to Bambu Lab's
+        Cloudflare protection — not to Bambuddy — so users know what to do."""
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = "<title>Just a moment...</title>"
+        response.status_code = 200
+        response.headers = {}
+        msg = _detect_cloudflare_challenge(response)
+        assert msg is not None
+        assert "Cloudflare" in msg
+        assert "bambulab.com" in msg
+
+    @pytest.mark.asyncio
+    async def test_verify_code_surfaces_cf_message_on_interstitial(self):
+        """verify_code (email-code path) must surface the CF message when the
+        endpoint returns an HTML interstitial — same shape as verify_totp."""
+        cloud = BambuCloudService()
+
+        mock_response = MagicMock()
+        mock_response.status_code = 403
+        mock_response.text = self._REPORTER_INTERSTITIAL
+        mock_response.headers = {}
+        mock_response.json.side_effect = ValueError("No JSON")
+
+        with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud.verify_code("test@example.com", "123456")
+
+            assert result["success"] is False
+            assert "Cloudflare" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_login_request_surfaces_cf_message_on_interstitial(self):
+        """login_request must surface the CF message when the endpoint returns
+        an HTML interstitial. Previously the parse error bubbled to
+        BambuCloudAuthError with an opaque "Expecting value..." detail."""
+        cloud = BambuCloudService()
+
+        mock_response = MagicMock()
+        mock_response.status_code = 403
+        mock_response.text = self._REPORTER_INTERSTITIAL
+        mock_response.headers = {}
+        mock_response.json.side_effect = ValueError("No JSON")
+
+        with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud.login_request("test@example.com", "password")
+
+            assert result["success"] is False
+            assert result["needs_verification"] is False
+            assert "Cloudflare" in result["message"]

+ 146 - 0
backend/tests/unit/services/test_notification_service.py

@@ -2057,3 +2057,149 @@ class TestFirstLayerCompleteNotifications:
             mock_send.assert_called_once()
             call_kwargs = mock_send.call_args
             assert call_kwargs.kwargs.get("image_data") == fake_image
+
+
+class TestNtfyOutbound:
+    """Regression for #1534 — UA hygiene and Cloudflare-challenge detection."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.mark.asyncio
+    async def test_notification_client_sets_honest_user_agent(self, service):
+        """Default httpx UA leaks `python-httpx/<version>` — every other
+        outbound client in the codebase identifies as Bambuddy. The
+        notification client must too."""
+        client = await service._get_client()
+        try:
+            assert client.headers.get("user-agent") == "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
+        finally:
+            await service.close()
+
+    @pytest.mark.asyncio
+    async def test_ntfy_cloudflare_challenge_returns_actionable_error(self, service):
+        """When ntfy is fronted by Cloudflare and CF returns its JS
+        challenge, the user must see a message that points at the actual
+        fix (CF security skip), not the raw HTML."""
+        import httpx
+
+        challenge_html = (
+            '<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title>'
+            '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">'
+        )
+        mock_response = httpx.Response(
+            403,
+            content=challenge_html.encode(),
+            headers={"server": "cloudflare", "content-type": "text/html; charset=UTF-8"},
+        )
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+            ok, detail = await service._send_ntfy(
+                {"server": "https://ntfy.example", "topic": "alerts", "auth_token": "tk_xxx"},
+                title="t",
+                message="m",
+            )
+
+        assert ok is False
+        assert "Cloudflare" in detail
+        assert "security-skip" in detail or "Bot Fight Mode" in detail
+        # The raw HTML must not be the dominant content shown to the user.
+        assert "<!DOCTYPE" not in detail
+
+    @pytest.mark.asyncio
+    async def test_ntfy_normal_403_still_surfaces_body(self, service):
+        """A non-Cloudflare 403 (e.g. ntfy auth fail) must keep showing
+        the original body so the user can debug the real error — we
+        only intercept the Cloudflare-challenge shape."""
+        import httpx
+
+        mock_response = httpx.Response(
+            403,
+            content=b"forbidden: invalid auth token",
+            headers={"content-type": "text/plain"},
+        )
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+            ok, detail = await service._send_ntfy(
+                {"server": "https://ntfy.sh", "topic": "alerts", "auth_token": "bad"},
+                title="t",
+                message="m",
+            )
+
+        assert ok is False
+        assert "Cloudflare" not in detail
+        assert "invalid auth token" in detail
+        assert detail.startswith("HTTP 403:")
+
+    @pytest.mark.asyncio
+    async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service):
+        """Cloudflare adds Server: cloudflare to EVERY proxied response,
+        including legitimate origin errors. A real 401 "wrong token"
+        from an ntfy server that happens to sit behind Cloudflare must
+        still surface the origin's actual error body — we must not flip
+        every CF-fronted 4xx into a "your Cloudflare is blocking" message.
+        """
+        import httpx
+
+        mock_response = httpx.Response(
+            401,
+            content=b'{"code":40101,"http":401,"error":"unauthorized"}',
+            headers={
+                "server": "cloudflare",
+                "cf-ray": "abc123-FRA",
+                "content-type": "application/json",
+                # No cf-mitigated — CF just proxied the origin response.
+            },
+        )
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+            ok, detail = await service._send_ntfy(
+                {"server": "https://ntfy.example", "topic": "alerts", "auth_token": "wrong"},
+                title="t",
+                message="m",
+            )
+
+        assert ok is False
+        assert "Cloudflare" not in detail
+        assert "unauthorized" in detail
+        assert detail.startswith("HTTP 401:")
+
+    @pytest.mark.asyncio
+    async def test_ntfy_cloudflare_cf_mitigated_header_alone_triggers(self, service):
+        """The cf-mitigated header on its own is enough — that's the
+        canonical CF "I actively blocked this" signal, even if the
+        response body shape changes between CF challenge generations."""
+        import httpx
+
+        mock_response = httpx.Response(
+            403,
+            content=b"<html>some future CF block page</html>",
+            headers={
+                "server": "cloudflare",
+                "cf-mitigated": "challenge",
+                "content-type": "text/html",
+            },
+        )
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+            ok, detail = await service._send_ntfy(
+                {"server": "https://ntfy.example", "topic": "alerts"},
+                title="t",
+                message="m",
+            )
+
+        assert ok is False
+        assert "Cloudflare" in detail

+ 95 - 0
backend/tests/unit/services/test_stl_thumbnail.py

@@ -1,5 +1,6 @@
 """Unit tests for the STL thumbnail service."""
 
+import os
 import tempfile
 from pathlib import Path
 
@@ -230,3 +231,97 @@ class TestStlThumbnailConstants:
         from backend.app.services.stl_thumbnail import MAX_VERTICES
 
         assert MAX_VERTICES == 100000
+
+    def test_min_usable_stl_bytes_threshold(self):
+        """MIN_USABLE_STL_BYTES is the call-site pre-skip floor.
+
+        Binary STL with one triangle = 80B header + 4B count + 50B triangle
+        = 134B. ASCII STL with one triangle ≈ 150B. Anything below this size
+        cannot contain a usable mesh.
+        """
+        from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES
+
+        assert MIN_USABLE_STL_BYTES == 200
+        # Verify it sits between "smaller than smallest real STL" and
+        # "common stub size" — the 24-byte ``solid test\nendsolid test``
+        # stubs that triggered the warning storm.
+        assert MIN_USABLE_STL_BYTES > 134  # smallest binary STL with one triangle
+        assert MIN_USABLE_STL_BYTES > 150  # smallest ASCII STL with one triangle
+        assert MIN_USABLE_STL_BYTES > 24  # the ZIP-stub case in the bug report
+
+    def test_font_manager_logger_demoted_to_warning(self):
+        """matplotlib.font_manager's per-font INFO scan is demoted at module
+        import so the first STL upload doesn't surface a multi-line preamble
+        of matplotlib internals in the journal."""
+        import logging
+
+        # Importing the module sets the level as a side effect.
+        import backend.app.services.stl_thumbnail  # noqa: F401
+
+        assert logging.getLogger("matplotlib.font_manager").level >= logging.WARNING
+
+    def test_configure_matplotlib_cache_sets_mplconfigdir(self, tmp_path, monkeypatch):
+        """``_configure_matplotlib_cache`` points matplotlib at a writable
+        persistent path so it doesn't fall back to ``/tmp/matplotlib-XXX``
+        on every cold start."""
+        from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
+
+        # Ensure we start with no value so the helper actually runs.
+        monkeypatch.delenv("MPLCONFIGDIR", raising=False)
+        monkeypatch.setattr(
+            "backend.app.services.stl_thumbnail.Path",
+            __import__("pathlib").Path,
+        )
+
+        # Stub settings.base_dir to point inside tmp_path.
+        from backend.app.core import config as core_config
+
+        monkeypatch.setattr(core_config.settings, "base_dir", tmp_path, raising=False)
+
+        _configure_matplotlib_cache()
+
+        assert "MPLCONFIGDIR" in os.environ
+        configured = Path(os.environ["MPLCONFIGDIR"])
+        assert configured.exists()
+        assert configured.is_dir()
+        # And the directory sits under base_dir, not /tmp/matplotlib-XXX.
+        assert tmp_path in configured.parents
+
+    def test_configure_matplotlib_cache_respects_externally_set_value(self, tmp_path, monkeypatch):
+        """If the operator (or container init) has set MPLCONFIGDIR already,
+        the helper must leave it alone — they made a deliberate choice."""
+        from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
+
+        external = str(tmp_path / "external-mpl-cache")
+        monkeypatch.setenv("MPLCONFIGDIR", external)
+        _configure_matplotlib_cache()
+        assert os.environ["MPLCONFIGDIR"] == external
+
+    def test_empty_mesh_logged_at_debug_not_warning(self, caplog):
+        """An empty STL (header present, no triangles) must log at DEBUG, not
+        WARNING — bulk uploads used to log thousands of WARNING lines per
+        ZIP. Per-file content observations stay observable in debug logs
+        but don't spam production journals."""
+        import logging
+        import tempfile
+        from pathlib import Path
+
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        # The exact 24-byte stub from the bug report
+        stub_content = b"solid test\nendsolid test"
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            tmpdir_path = Path(tmpdir)
+            stl_path = tmpdir_path / "stub.stl"
+            stl_path.write_bytes(stub_content)
+
+            with caplog.at_level(logging.DEBUG, logger="backend.app.services.stl_thumbnail"):
+                result = generate_stl_thumbnail(stl_path, tmpdir_path)
+
+        assert result is None
+        # The empty-mesh message must NOT appear at WARNING level.
+        warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING and "empty mesh" in r.getMessage()]
+        assert warning_records == [], (
+            f"Empty-mesh path still logs at WARNING: {[r.getMessage() for r in warning_records]}"
+        )

+ 281 - 43
backend/tests/unit/services/test_virtual_printer.py

@@ -47,7 +47,7 @@ class TestVirtualPrinterInstance:
         return VirtualPrinterInstance(
             vp_id=1,
             name="TestPrinter",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
@@ -62,7 +62,7 @@ class TestVirtualPrinterInstance:
         """Verify constructor stores parameters correctly."""
         assert instance.id == 1
         assert instance.name == "TestPrinter"
-        assert instance.mode == "immediate"
+        assert instance.mode == "archive"
         assert instance.model == "C11"
         assert instance.access_code == "12345678"
         assert instance.serial_suffix == "391800001"
@@ -79,7 +79,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=2,
             name="X1C",
-            mode="immediate",
+            mode="archive",
             model="BL-P001",
             access_code="12345678",
             serial_suffix="391800002",
@@ -182,7 +182,7 @@ class TestVirtualPrinterInstance:
         Send-flow slicers don't watch the post-upload state, so this is a
         no-op behavior change for them.
         """
-        instance.mode = "immediate"
+        instance.mode = "archive"
         instance._mqtt = MagicMock()
         instance._mqtt.set_gcode_state = MagicMock()
         file_path = Path("/tmp/test.3mf")  # nosec B108
@@ -196,7 +196,7 @@ class TestVirtualPrinterInstance:
     async def test_on_file_received_non_3mf_does_not_touch_state(self, instance):
         """Non-3MF uploads (e.g., a job's auxiliary files) must not transition
         the visible state — the slicer is only tracking the .3mf upload."""
-        instance.mode = "immediate"
+        instance.mode = "archive"
         instance._mqtt = MagicMock()
         instance._mqtt.set_gcode_state = MagicMock()
         file_path = Path("/tmp/test.gcode")  # nosec B108
@@ -236,7 +236,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=30,
             name="ImmediateBroadcast",
-            mode="immediate",
+            mode="archive",
             model="C12",
             access_code="12345678",
             serial_suffix="391800030",
@@ -289,7 +289,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=10,
             name="DefaultDispatch",
-            mode="print_queue",
+            mode="queue",
             model="C11",
             access_code="12345678",
             serial_suffix="391800010",
@@ -320,7 +320,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=11,
             name="AutoDispatchOn",
-            mode="print_queue",
+            mode="queue",
             model="C11",
             access_code="12345678",
             serial_suffix="391800011",
@@ -374,7 +374,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=31,
             name="QueueBroadcast",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             access_code="12345678",
             serial_suffix="391800031",
@@ -440,7 +440,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=12,
             name="AutoDispatchOff",
-            mode="print_queue",
+            mode="queue",
             model="C11",
             access_code="12345678",
             serial_suffix="391800012",
@@ -499,7 +499,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=22,
             name="DefaultsTest",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             access_code="12345678",
             serial_suffix="391800022",
@@ -572,7 +572,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=23,
             name="FreshInstallDefaults",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             access_code="12345678",
             serial_suffix="391800023",
@@ -636,7 +636,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=24,
             name="SlicerInherits",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             access_code="12345678",
             serial_suffix="391800024",
@@ -723,7 +723,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=25,
             name="SlicerIntegers",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             access_code="12345678",
             serial_suffix="391800025",
@@ -787,7 +787,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=21,
             name="Reqs",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             access_code="12345678",
             serial_suffix="391800021",
@@ -857,7 +857,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=22,
             name="ForceColor",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             access_code="12345678",
             serial_suffix="391800022",
@@ -927,7 +927,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=23,
             name="Unparseable",
-            mode="print_queue",
+            mode="queue",
             model="C12",
             access_code="12345678",
             serial_suffix="391800023",
@@ -996,7 +996,7 @@ class TestVirtualPrinterInstance:
         inst = VirtualPrinterInstance(
             vp_id=20,
             name="NameSource",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800020",
@@ -1030,6 +1030,220 @@ class TestVirtualPrinterInstance:
         kwargs = archive_print_mock.await_args.kwargs
         assert kwargs.get("prefer_filename_for_name") is expected_prefer_filename
 
+    # ========================================================================
+    # Tests for failure-path cleanup (#audit-R2-1)
+    # ========================================================================
+    #
+    # All three file handlers (_archive_file, _queue_file, _add_to_print_queue)
+    # previously only popped _pending_files and unlinked the temp file on the
+    # success branch. Failure paths leaked the marker (blocking same-name
+    # retries via the FTP layer) and the temp file on disk. The cleanup must
+    # ALWAYS run, even when archival / queue insert raises.
+
+    @pytest.mark.asyncio
+    async def test_archive_file_failure_path_pops_pending_and_unlinks(self, tmp_path):
+        """When the archive layer raises, `_pending_files[filename]` must still
+        be popped and the temp file must be unlinked. Otherwise the FTP layer's
+        same-name retry guard would silently reject the slicer's next attempt
+        and the upload_dir would accumulate ghost files."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=40,
+            name="ArchiveFailCleanup",
+            mode="archive",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800040",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+        file_path = tmp_path / "cleanup-archive.3mf"
+        file_path.write_bytes(b"fake3mf")
+        inst._pending_files[file_path.name] = file_path
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                side_effect=RuntimeError("archive blew up"),
+            ),
+        ):
+            await inst._archive_file(file_path, "192.168.1.100")
+
+        assert file_path.name not in inst._pending_files
+        assert not file_path.exists()
+
+    @pytest.mark.asyncio
+    async def test_queue_file_failure_path_pops_pending_and_unlinks(self, tmp_path):
+        """Same invariant for _queue_file: a DB error during PendingUpload
+        insert must not leak the in-flight marker or the temp file."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        # Commit raises — emulating a DB connectivity error.
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock(side_effect=RuntimeError("db unreachable"))
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=41,
+            name="QueueFailCleanup",
+            mode="review",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800041",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+        file_path = tmp_path / "cleanup-queue.3mf"
+        file_path.write_bytes(b"fake3mf")
+        inst._pending_files[file_path.name] = file_path
+
+        await inst._queue_file(file_path, "192.168.1.100")
+
+        assert file_path.name not in inst._pending_files
+        assert not file_path.exists()
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_failure_path_pops_pending_and_unlinks(self, tmp_path):
+        """Same invariant for _add_to_print_queue: a DB error or archive
+        failure must not leak the in-flight marker or the temp file."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+        mock_db.execute = AsyncMock(side_effect=RuntimeError("queue insert blew up"))
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=42,
+            name="DispatchFailCleanup",
+            mode="queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800042",
+            auto_dispatch=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+        file_path = tmp_path / "cleanup-dispatch.3mf"
+        file_path.write_bytes(b"fake3mf")
+        inst._pending_files[file_path.name] = file_path
+
+        with patch(
+            "backend.app.api.routes.settings.get_setting",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert file_path.name not in inst._pending_files
+        assert not file_path.exists()
+
+    # ========================================================================
+    # Test for position=MAX+1 (audit-R2)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_position_picks_max_plus_one(self, tmp_path):
+        """VP-queue items previously got hardcoded `position=1`, colliding
+        with existing items at position 1 and producing non-deterministic
+        execution order. Now the position is chosen by `MAX(position)+1`
+        against the target queue, matching the canonical `POST /print-queue/`
+        path."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        # Capture the inserted PrintQueueItem so we can assert on .position.
+        added_items: list = []
+
+        class _RecordingDb:
+            def __init__(self):
+                self.add = lambda item: added_items.append(item)
+                self.commit = AsyncMock()
+
+            async def execute(self, query):  # noqa: ARG002
+                """Return a stub result whose `.scalar()` reports the existing
+                MAX(position) for the target. Returning 7 means the new item
+                should land at 8."""
+                result = MagicMock()
+                result.scalar = MagicMock(return_value=7)
+                return result
+
+        mock_db = _RecordingDb()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=43,
+            name="PositionMaxPlusOne",
+            mode="queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800043",
+            target_printer_id=99,
+            auto_dispatch=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+        file_path = tmp_path / "next-position.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 555
+        mock_archive.printer_id = None
+        mock_archive.filename = "next-position.3mf"
+        mock_archive.print_name = "next-position"
+        mock_archive.status = "archived"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+            patch(
+                "backend.app.core.websocket.ws_manager.send_archive_created",
+                new_callable=AsyncMock,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        # One queue item was added.
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        # Position = max(7) + 1 = 8 — NOT the legacy hardcoded 1.
+        assert queue_item.position == 8
+
 
 class TestVirtualPrinterManager:
     """Tests for VirtualPrinterManager orchestrator."""
@@ -1051,7 +1265,7 @@ class TestVirtualPrinterManager:
         status = manager.get_status()
         assert status["enabled"] is False
         assert status["running"] is False
-        assert status["mode"] == "immediate"
+        assert status["mode"] == "archive"
 
     def test_manager_is_enabled_with_instance(self, manager, tmp_path):
         """Verify is_enabled is True when instances exist."""
@@ -1060,7 +1274,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
             vp_id=1,
             name="Test",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
@@ -1077,7 +1291,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
             vp_id=1,
             name="Test",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
@@ -1121,7 +1335,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
             vp_id=1,
             name="Bambuddy",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
@@ -1135,7 +1349,7 @@ class TestVirtualPrinterManager:
         status = manager.get_status()
         assert status["enabled"] is True
         assert status["running"] is True
-        assert status["mode"] == "immediate"
+        assert status["mode"] == "archive"
         assert status["name"] == "Bambuddy"
         assert status["serial"] == "01S00A391800001"
         assert status["model"] == "C11"
@@ -1150,7 +1364,7 @@ class TestVirtualPrinterManager:
             inst = VirtualPrinterInstance(
                 vp_id=i,
                 name=f"VP{i}",
-                mode="immediate",
+                mode="archive",
                 model="C11",
                 access_code="12345678",
                 serial_suffix=f"39180000{i}",
@@ -1172,7 +1386,7 @@ class TestVirtualPrinterManager:
             inst = VirtualPrinterInstance(
                 vp_id=i,
                 name=f"VP{i}",
-                mode="immediate",
+                mode="archive",
                 model="C11",
                 access_code="12345678",
                 serial_suffix=f"39180000{i}",
@@ -1194,7 +1408,7 @@ class TestVirtualPrinterManager:
             "id": 1,
             "name": "TestVP",
             "enabled": True,
-            "mode": "immediate",
+            "mode": "archive",
             "model": "C11",
             "access_code": "12345678",
             "serial_suffix": "391800001",
@@ -1203,6 +1417,7 @@ class TestVirtualPrinterManager:
             "target_printer_id": None,
             "auto_dispatch": True,
             "tailscale_disabled": True,  # Opt-in default (#1070 UX fix)
+            "queue_force_color_match": False,  # default — must be explicit so MagicMock truthiness doesn't trip the change detector
             "position": 0,
         }
         defaults.update(overrides)
@@ -1232,7 +1447,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
             vp_id=1,
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
@@ -1241,8 +1456,8 @@ class TestVirtualPrinterManager:
         inst.stop_server = AsyncMock()
         manager._instances[1] = inst
 
-        # DB says mode changed to "archive"
-        db_vp = self._make_db_vp(mode="archive")
+        # DB says mode changed to "review"
+        db_vp = self._make_db_vp(mode="review")
         self._setup_sync_mocks(manager, [db_vp], tmp_path)
 
         with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
@@ -1264,7 +1479,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
             vp_id=1,
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
@@ -1294,7 +1509,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
             vp_id=1,
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
@@ -1319,7 +1534,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
             vp_id=1,
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
@@ -1350,7 +1565,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
             vp_id=1,
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
@@ -1385,7 +1600,7 @@ class TestVirtualPrinterManager:
         inst = VirtualPrinterInstance(
             vp_id=1,
             name="TestVP",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
@@ -2097,7 +2312,7 @@ class TestVirtualPrinterManagerDirectories:
         VirtualPrinterInstance(
             vp_id=42,
             name="Test",
-            mode="immediate",
+            mode="archive",
             model="C11",
             access_code="12345678",
             serial_suffix="391800042",
@@ -2187,7 +2402,7 @@ class TestVirtualPrinterInstanceIPOverride:
         return VirtualPrinterInstance(
             vp_id=20,
             name="IPTest",
-            mode="immediate",
+            mode="archive",
             model="BL-P001",
             access_code="12345678",
             serial_suffix="391800020",
@@ -2224,7 +2439,7 @@ class TestVirtualPrinterInstanceIPOverride:
         inst = VirtualPrinterInstance(
             vp_id=21,
             name="NoRemote",
-            mode="immediate",
+            mode="archive",
             model="BL-P001",
             access_code="12345678",
             serial_suffix="391800021",
@@ -2250,7 +2465,7 @@ class TestVirtualPrinterInstanceIPOverride:
         inst = VirtualPrinterInstance(
             vp_id=22,
             name="NoIPs",
-            mode="immediate",
+            mode="archive",
             model="BL-P001",
             access_code="12345678",
             serial_suffix="391800022",
@@ -2383,7 +2598,7 @@ class TestBindServer:
         inst = VirtualPrinterInstance(
             vp_id=99,
             name="Bambuddy",
-            mode="immediate",
+            mode="archive",
             model="BL-P001",
             access_code="12345678",
             serial_suffix="391800099",
@@ -2391,11 +2606,34 @@ class TestBindServer:
             base_dir=tmp_path,
         )
 
+        # Each mocked child service exposes a real asyncio.Event for the
+        # readiness barrier added in start_server (set on instantiation so
+        # the barrier returns immediately in tests).
+        ready_event = asyncio.Event()
+        ready_event.set()
+
+        def with_ready(*_args, **_kwargs):
+            child = MagicMock()
+            child.ready = ready_event
+            return child
+
         with (
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
-            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
-            patch("backend.app.services.virtual_printer.manager.BindServer") as mock_bind_cls,
+            patch(
+                "backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer",
+                side_effect=with_ready,
+            ),
+            patch(
+                "backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer",
+                side_effect=with_ready,
+            ),
+            patch(
+                "backend.app.services.virtual_printer.manager.SimpleMQTTServer",
+                side_effect=with_ready,
+            ),
+            patch(
+                "backend.app.services.virtual_printer.manager.BindServer",
+                side_effect=with_ready,
+            ) as mock_bind_cls,
             patch.object(inst._cert_service, "delete_printer_certificate"),
             patch.object(
                 inst._cert_service,

+ 1 - 1
backend/tests/unit/services/test_vp_diagnostic.py

@@ -19,7 +19,7 @@ def _vp(**overrides):
     base = {
         "id": 1,
         "name": "Test VP",
-        "mode": "immediate",
+        "mode": "archive",
         "enabled": True,
         "bind_ip": "192.168.1.50",
         "access_code": "12345678",

+ 251 - 0
backend/tests/unit/test_archive_run_aggregation.py

@@ -245,3 +245,254 @@ async def test_soft_delete_keeps_runs_for_stats(
     stats = (await async_client.get("/api/v1/archives/stats")).json()
     assert stats["total_prints"] >= 1
     assert stats["total_filament_grams"] >= 75.0
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_time_accuracy_excludes_multi_plate_plate_by_plate_outliers(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """Per-run accuracy clamps to a plausible 50%-200% band so multi-plate
+    archives printed plate-by-plate don't poison the printer-level average.
+
+    Pre-#1593 the parser stored plate-1-only time in
+    ``PrintArchive.print_time_seconds``, so a plate-by-plate run produced a
+    near-100% ratio by accident. Post-#1593 the field is the sum across
+    plates, so each plate-by-plate run produces estimate/actual = N×100%
+    for an N-plate file. Without the band filter a single 3-plate file
+    printed plate-by-plate would drag the printer's accuracy reading to
+    ~300%, which is pure noise. The metric is designed for the
+    single-plate-file case and should reflect real slicer drift there.
+    """
+    printer = await printer_factory()
+
+    # Archive 1: single-plate file. Estimate 3600s, actual 3700s
+    # → ratio 97.3% (well within band).
+    single = await archive_factory(
+        printer.id,
+        print_time_seconds=3600,
+        with_run=False,
+    )
+    db_session.add(
+        PrintLogEntry(
+            archive_id=single.id,
+            printer_id=printer.id,
+            status="completed",
+            duration_seconds=3700,
+        )
+    )
+
+    # Archive 2: multi-plate file (3 plates totaling 18000s). Two runs
+    # printed plate-by-plate at ~6000s each — ratio 18000/6000 = 300%.
+    # Both must be filtered out so the printer average stays at the
+    # single-plate file's 97.3% reading.
+    multi = await archive_factory(
+        printer.id,
+        print_time_seconds=18000,
+        with_run=False,
+    )
+    db_session.add(
+        PrintLogEntry(
+            archive_id=multi.id,
+            printer_id=printer.id,
+            status="completed",
+            duration_seconds=6000,
+        )
+    )
+    db_session.add(
+        PrintLogEntry(
+            archive_id=multi.id,
+            printer_id=printer.id,
+            status="completed",
+            duration_seconds=6100,
+        )
+    )
+    await db_session.commit()
+
+    body = (await async_client.get("/api/v1/archives/stats")).json()
+    assert body["average_time_accuracy"] == pytest.approx(97.3, abs=0.1)
+    assert body["time_accuracy_by_printer"][str(printer.id)] == pytest.approx(97.3, abs=0.1)
+
+
+# ---------------------------------------------------------------------------
+# #1608: compute_time_accuracy suppresses the per-card badge for multi-run
+# archives where the whole-file estimate is incommensurable with the
+# latest-run actual.
+# ---------------------------------------------------------------------------
+
+
+class TestComputeTimeAccuracyMultiRun:
+    """The card-level ``compute_time_accuracy`` runs against the archive's own
+    ``started_at`` / ``completed_at`` (latest run only) and
+    ``print_time_seconds`` (post-#1593 sum across plates). For multi-run
+    archives those describe different scopes — a 3-plate file printed
+    plate-by-plate over 3 runs produces estimate/actual = 300% → +200% badge,
+    which is pure noise. The reporter (#1608, archive #65) verified the
+    bug surfaces at +188% for a 3-plate file with 9 runs.
+
+    The fix: when the archive has more than one logged run, suppress BOTH
+    fields. The frontend then falls through to ``print_time_seconds`` for
+    the time display (so the user sees the slicer's whole-file estimate
+    instead of one run's wall-clock) and hides the badge.
+    """
+
+    def _make_archive(self, *, print_time_seconds, started_at, completed_at, status="completed"):
+        from types import SimpleNamespace
+
+        return SimpleNamespace(
+            print_time_seconds=print_time_seconds,
+            started_at=started_at,
+            completed_at=completed_at,
+            status=status,
+        )
+
+    def test_single_run_keeps_original_behaviour(self):
+        """``run_count == 1`` is the case the badge was designed for —
+        compute and return both actual + accuracy as before."""
+        from backend.app.api.routes.archives import compute_time_accuracy
+
+        archive = self._make_archive(
+            print_time_seconds=3600,
+            started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
+        )
+        result = compute_time_accuracy(archive, run_aggregate={"run_count": 1})
+
+        assert result["actual_time_seconds"] == 3600
+        assert result["time_accuracy"] == 100.0
+
+    def test_no_run_aggregate_keeps_original_behaviour(self):
+        """Endpoints that don't yet load run_aggregates (legacy callers, or
+        contexts where the data isn't relevant) must keep the pre-fix
+        per-archive computation — never silently drop the badge."""
+        from backend.app.api.routes.archives import compute_time_accuracy
+
+        archive = self._make_archive(
+            print_time_seconds=3600,
+            started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 1, 10, 50, tzinfo=timezone.utc),
+        )
+        result = compute_time_accuracy(archive)  # no run_aggregate
+
+        assert result["actual_time_seconds"] == 3000
+        assert result["time_accuracy"] == 120.0  # 3600/3000
+
+    def test_multi_run_archive_suppresses_both_fields(self):
+        """Reporter's case (archive #65, 3 plates, 9 logged runs): one-run
+        actual (6364s) vs whole-file estimate (18354s) → +188% badge that
+        means nothing. Multi-run must clear both fields so the card falls
+        through to the estimate display with no badge."""
+        from backend.app.api.routes.archives import compute_time_accuracy
+
+        archive = self._make_archive(
+            print_time_seconds=18354,
+            started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 1, 11, 46, 4, tzinfo=timezone.utc),  # ~6364s
+        )
+        result = compute_time_accuracy(archive, run_aggregate={"run_count": 9})
+
+        assert result["actual_time_seconds"] is None
+        assert result["time_accuracy"] is None
+
+    def test_run_count_zero_keeps_original_behaviour(self):
+        """A run_aggregate that exists but reports zero runs (edge case from
+        the LEFT JOIN-style helper) must not trigger suppression — that's
+        not the multi-run shape, it's the no-runs shape, and the
+        per-archive timestamps are still meaningful (the archive was
+        marked completed without a PrintLogEntry trail, e.g. legacy
+        imports)."""
+        from backend.app.api.routes.archives import compute_time_accuracy
+
+        archive = self._make_archive(
+            print_time_seconds=3600,
+            started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
+        )
+        result = compute_time_accuracy(archive, run_aggregate={"run_count": 0})
+
+        assert result["actual_time_seconds"] == 3600
+        assert result["time_accuracy"] == 100.0
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_archive_list_suppresses_time_accuracy_for_multi_run_archives(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """#1608 integration: the card response from the main list endpoint
+    must report ``actual_time_seconds = null`` and ``time_accuracy = null``
+    for an archive with multiple logged runs, so the frontend renders the
+    slicer estimate without the misleading +N% badge."""
+    printer = await printer_factory()
+
+    # 3-plate file: estimate is the whole-file sum, latest run is one plate.
+    archive = await archive_factory(
+        printer.id,
+        status="completed",
+        print_time_seconds=18354,  # all-plates estimate (post-#1593 parser)
+        started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+        completed_at=datetime(2026, 5, 1, 11, 46, 4, tzinfo=timezone.utc),  # ~6364s = one plate
+        with_run=False,
+    )
+    # Three runs each ~6364s — plate-by-plate.
+    for day in (1, 2, 3):
+        db_session.add(
+            PrintLogEntry(
+                archive_id=archive.id,
+                printer_id=archive.printer_id,
+                status="completed",
+                started_at=datetime(2026, 5, day, 10, 0, tzinfo=timezone.utc),
+                completed_at=datetime(2026, 5, day, 11, 46, 4, tzinfo=timezone.utc),
+                duration_seconds=6364,
+            )
+        )
+    await db_session.commit()
+
+    response = await async_client.get("/api/v1/archives/")
+    assert response.status_code == 200
+    row = next(r for r in response.json() if r["id"] == archive.id)
+
+    # Frontend renders archive.actual_time_seconds || archive.print_time_seconds —
+    # with actual cleared, it falls through to the estimate; with accuracy
+    # cleared, no badge renders.
+    assert row["actual_time_seconds"] is None, "multi-run actual is incommensurable with the estimate — must be null"
+    assert row["time_accuracy"] is None, "no badge for multi-run archives — the scopes don't match"
+    # The estimate itself is preserved so the card has something to display.
+    assert row["print_time_seconds"] == 18354
+    assert row["run_count"] == 3
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_archive_list_keeps_time_accuracy_for_single_run_archives(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """Sanity check for the #1608 fix: single-run archives (the case the
+    badge was designed for) keep their original badge behaviour."""
+    printer = await printer_factory()
+
+    archive = await archive_factory(
+        printer.id,
+        status="completed",
+        print_time_seconds=3600,
+        started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+        completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
+        with_run=False,
+    )
+    db_session.add(
+        PrintLogEntry(
+            archive_id=archive.id,
+            printer_id=archive.printer_id,
+            status="completed",
+            started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
+            duration_seconds=3600,
+        )
+    )
+    await db_session.commit()
+
+    row = next(r for r in (await async_client.get("/api/v1/archives/")).json() if r["id"] == archive.id)
+
+    assert row["actual_time_seconds"] == 3600
+    assert row["time_accuracy"] == 100.0
+    assert row["run_count"] == 1

+ 293 - 0
backend/tests/unit/test_fallback_archive_mqtt_filament.py

@@ -0,0 +1,293 @@
+"""Tests for _extract_filament_data_from_mqtt (#1533).
+
+The fallback PrintArchive path in main.py fires when the source 3MF can't
+be downloaded from the printer at print start — common on P1S / A1 / P2S
+firmwares that lock the file during printing. Before this fix the
+fallback archive had every filament field NULL even though the MQTT
+print-start payload already carried the AMS state and the slicer's
+slot-per-print-filament mapping. The helper extracts a comma-separated
+``filament_type`` / ``filament_color`` from that payload so the inventory
+views can at least show what's loaded, and operators planning AMS
+expansion can count filaments per print.
+"""
+
+import pytest
+
+from backend.app.main import _extract_filament_data_from_mqtt
+
+
+def _ams_unit(unit_id: int, trays: list[dict]) -> dict:
+    return {"id": unit_id, "tray": trays}
+
+
+def _tray(tray_id: int, ttype: str | None, color: str | None) -> dict:
+    out: dict = {"id": tray_id}
+    if ttype is not None:
+        out["tray_type"] = ttype
+    if color is not None:
+        out["tray_color"] = color
+    return out
+
+
+class TestExtractFilamentDataFromMqtt:
+    def test_empty_payload_returns_empty_dict(self):
+        assert _extract_filament_data_from_mqtt({}) == {}
+        assert _extract_filament_data_from_mqtt({"ams": None}) == {}
+        assert _extract_filament_data_from_mqtt({"ams": {}}) == {}
+        assert _extract_filament_data_from_mqtt({"ams": {"ams": []}}) == {}
+
+    def test_no_loaded_slots_returns_empty(self):
+        """All slots empty (no tray_type) → nothing to report."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(i, "", "") for i in range(4)]),
+                ],
+            }
+        }
+        assert _extract_filament_data_from_mqtt(data) == {}
+
+    def test_no_mapping_lists_all_loaded_slots_sorted(self):
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(
+                        0,
+                        [
+                            _tray(0, "PLA", "FF0000"),
+                            _tray(1, "PETG", "00FF00"),
+                            _tray(2, "", ""),  # Empty slot — skipped.
+                            _tray(3, "ABS", "0000ff"),
+                        ],
+                    ),
+                ],
+            }
+        }
+        result = _extract_filament_data_from_mqtt(data)
+        # Order is by ascending global tray id, colors uppercased.
+        assert result == {"filament_type": "PLA,PETG,ABS", "filament_color": "FF0000,00FF00,0000FF"}
+
+    def test_ams_mapping_narrows_to_used_slots(self):
+        """The slicer's slot-per-print-filament mapping wins — only used
+        slots contribute, in the slicer's order (which is the order the
+        print materially consumes them)."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(
+                        0,
+                        [
+                            _tray(0, "PLA", "FF0000"),
+                            _tray(1, "PETG", "00FF00"),
+                            _tray(2, "ABS", "0000FF"),
+                            _tray(3, "TPU", "FFFF00"),
+                        ],
+                    ),
+                ],
+            }
+        }
+        # Print uses slots 3 then 0 then 1 (slot 2 untouched, no entry).
+        result = _extract_filament_data_from_mqtt(data, ams_mapping=[3, 0, 1])
+        assert result == {"filament_type": "TPU,PLA,PETG", "filament_color": "FFFF00,FF0000,00FF00"}
+
+    def test_ams_mapping_with_vt_tray_sentinels_filtered_out(self):
+        """ams_mapping entries equal to -1 represent the VT tray (external
+        spool feed). We have no AMS tray data for them — they must be
+        skipped, not treated as global tray id 0."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(
+                        0,
+                        [
+                            _tray(0, "PLA", "FF0000"),
+                            _tray(1, "PETG", "00FF00"),
+                        ],
+                    ),
+                ],
+            }
+        }
+        result = _extract_filament_data_from_mqtt(data, ams_mapping=[-1, 0, 1])
+        assert result == {"filament_type": "PLA,PETG", "filament_color": "FF0000,00FF00"}
+
+    def test_dual_ams_global_ids_use_unit4_offset(self):
+        """A dual-AMS rig has unit 0 → trays 0-3, unit 1 → trays 4-7.
+        ``ams_mapping=4`` must resolve to unit 1, tray 0 — not unit 0."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(0, "PLA", "FF0000")]),
+                    _ams_unit(1, [_tray(0, "PETG-CF", "112233")]),
+                ],
+            }
+        }
+        result = _extract_filament_data_from_mqtt(data, ams_mapping=[4, 0])
+        assert result == {"filament_type": "PETG-CF,PLA", "filament_color": "112233,FF0000"}
+
+    def test_mapping_pointing_at_unknown_slot_falls_through_to_known_only(self):
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(0, "PLA", "FF0000")]),
+                ],
+            }
+        }
+        # Slot 7 isn't in our AMS — entry skipped, only slot 0 remains.
+        result = _extract_filament_data_from_mqtt(data, ams_mapping=[7, 0])
+        assert result == {"filament_type": "PLA", "filament_color": "FF0000"}
+
+    def test_mapping_entirely_unknown_returns_empty(self):
+        """If every mapped slot is unknown the helper returns {} rather
+        than silently misreporting from the all-slots fallback — the
+        slicer was explicit about which slots to use."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(0, "PLA", "FF0000")]),
+                ],
+            }
+        }
+        assert _extract_filament_data_from_mqtt(data, ams_mapping=[5, 6]) == {}
+
+    def test_color_truncation_at_column_limit(self):
+        """filament_color column is VARCHAR(200); long multi-color prints
+        must not exceed it."""
+        # 16 trays of 6-char colors + 15 commas = 96+15 = 111 chars. Safe.
+        # Construct an oversized synthetic case with many distinct colors.
+        trays = [_tray(i, "PLA", f"{i:06X}") for i in range(4)]
+        data = {"ams": {"ams": [_ams_unit(u, trays) for u in range(8)]}}
+        result = _extract_filament_data_from_mqtt(data)
+        assert "filament_color" in result
+        assert len(result["filament_color"]) <= 200
+
+    def test_type_truncation_at_column_limit(self):
+        """filament_type column is VARCHAR(50). Many filaments must truncate."""
+        # 16 PETG-CF entries: 7 chars × 16 + 15 commas = 127 chars.
+        trays = [_tray(i, "PETG-CF", "AABBCC") for i in range(4)]
+        data = {"ams": {"ams": [_ams_unit(u, trays) for u in range(4)]}}
+        result = _extract_filament_data_from_mqtt(data)
+        assert "filament_type" in result
+        assert len(result["filament_type"]) <= 50
+
+    def test_color_missing_only_emits_type(self):
+        """A tray with type but blank color still contributes to filament_type."""
+        data = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(0, "PLA", "")]),
+                ],
+            }
+        }
+        result = _extract_filament_data_from_mqtt(data)
+        assert result == {"filament_type": "PLA"}
+        # filament_color absent — not empty string.
+        assert "filament_color" not in result
+
+    def test_malformed_unit_skipped_without_crash(self):
+        """Defensive: unexpected MQTT shapes (non-dict in ams list, missing
+        id, string tray.id) must not raise. The fallback-archive write
+        runs in a hot path during print start — anything that throws here
+        would bubble up and break the print log entirely."""
+        data = {
+            "ams": {
+                "ams": [
+                    "garbage",
+                    {"id": "not-an-int", "tray": []},
+                    _ams_unit(0, [_tray(0, "PLA", "FF0000"), {"id": "x", "tray_type": "PETG"}]),
+                ],
+            }
+        }
+        result = _extract_filament_data_from_mqtt(data)
+        # Only the well-formed entry contributes; no exception.
+        assert result.get("filament_type") == "PLA"
+
+    @pytest.mark.parametrize("data", [None, {}, {"ams": "weird-string"}])
+    def test_garbage_top_level_is_empty(self, data):
+        assert _extract_filament_data_from_mqtt(data or {}) == {}
+
+
+class TestOnPrintStartCallbackShape:
+    """Regression: the callback wrapper shape the bambu_mqtt service
+    actually hands to ``on_print_start`` at runtime (#1533 follow-up).
+
+    The original #1533 fix only handled the bare ``{"ams": {"ams": [...]}}``
+    inner shape, but the call site at ``backend/app/main.py::on_print_start``
+    receives the wrapper
+    ``{"filename", "subtask_name", "remaining_time", "raw_data": <mqtt>,
+       "ams_mapping"}`` from ``backend/app/services/bambu_mqtt.py:2971-2980``.
+    The lookup at ``data["ams"]`` therefore missed every real print and
+    fallback archives kept their filament fields NULL — the exact regression
+    the fix was supposed to close. Reproduced from JmanB52D's support
+    bundle whose print start log line showed
+    ``AMS 0: T0(type=PETG, color=FFFFFFFF, …)`` was sitting right there at
+    ``data["raw_data"]["ams"]["ams"][0]["tray"][0]``.
+    """
+
+    def test_callback_wrapper_payload_resolves_raw_data_path(self):
+        """The wrapper-shape payload must produce the same result the
+        inner-shape payload would."""
+        inner = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(0, "PETG", "FFFFFFFF")]),
+                ],
+            },
+        }
+        wrapper = {
+            "filename": "/data/Metadata/plate_1.gcode",
+            "subtask_name": "xyz-10mm-calibration-cube",
+            "remaining_time": 1200,
+            "raw_data": inner,
+            "ams_mapping": [0],
+        }
+        result = _extract_filament_data_from_mqtt(wrapper, ams_mapping=[0])
+        # The 6-char rgba `FFFFFF` is what the AMS reports (with `FF` alpha
+        # tail trimmed by the catalog) — the helper preserves whatever the
+        # firmware sends.
+        assert result == {"filament_type": "PETG", "filament_color": "FFFFFFFF"}
+
+    def test_wrapper_with_no_ams_mapping_falls_back_to_all_loaded(self):
+        """Wrapper shape without an ams_mapping behaves the same as the
+        inner-shape no-mapping path: lists every loaded slot."""
+        inner = {
+            "ams": {
+                "ams": [
+                    _ams_unit(0, [_tray(0, "PLA", "FF0000"), _tray(1, "PETG", "00FF00")]),
+                ],
+            },
+        }
+        wrapper = {"raw_data": inner}
+        result = _extract_filament_data_from_mqtt(wrapper)
+        assert result == {"filament_type": "PLA,PETG", "filament_color": "FF0000,00FF00"}
+
+    def test_inner_shape_still_supported_after_wrapper_lookup(self):
+        """Existing callers that pass the inner shape directly (e.g. the
+        unit tests above) must keep working — the new lookup is additive."""
+        inner = {
+            "ams": {
+                "ams": [_ams_unit(0, [_tray(0, "ASA", "112233")])],
+            },
+        }
+        assert _extract_filament_data_from_mqtt(inner) == {
+            "filament_type": "ASA",
+            "filament_color": "112233",
+        }
+
+    def test_wrapper_with_missing_raw_data_returns_empty(self):
+        """No raw_data wrapper AND no top-level ams → empty, no raise."""
+        wrapper = {"filename": "foo.gcode", "ams_mapping": [0]}
+        assert _extract_filament_data_from_mqtt(wrapper, ams_mapping=[0]) == {}
+
+    def test_wrapper_with_non_dict_raw_data_falls_through_to_inner_lookup(self):
+        """Defensive: a junk raw_data value (string / None) shouldn't crash
+        and shouldn't shadow a present inner ``ams`` either. Lets us catch
+        the case where MQTT decoding partially fails but the rest of the
+        payload is fine."""
+        wrapper = {
+            "raw_data": "garbage",
+            "ams": {"ams": [_ams_unit(0, [_tray(0, "PLA", "FF0000")])]},
+        }
+        assert _extract_filament_data_from_mqtt(wrapper) == {
+            "filament_type": "PLA",
+            "filament_color": "FF0000",
+        }

+ 120 - 0
backend/tests/unit/test_filename_validation.py

@@ -0,0 +1,120 @@
+"""Validator tests for FAT32/exFAT-safe print filenames (#1540)."""
+
+import pytest
+
+from backend.app.utils.filename import (
+    INVALID_FILENAME_CHARS,
+    InvalidFilenameError,
+    derive_remote_filename,
+    validate_print_filename,
+)
+
+
+class TestValidatePrintFilename:
+    @pytest.mark.parametrize(
+        "name",
+        [
+            "model.3mf",
+            "Bersaglio.gcode.3mf",
+            "Plate 1.3mf",
+            "プリント.3mf",
+            "model_v2-final.3mf",
+            "a.3mf",
+        ],
+    )
+    def test_valid_names_accepted(self, name: str) -> None:
+        validate_print_filename(name)
+
+    @pytest.mark.parametrize("char", list(INVALID_FILENAME_CHARS))
+    def test_each_invalid_char_rejected(self, char: str) -> None:
+        with pytest.raises(InvalidFilenameError) as exc_info:
+            validate_print_filename(f"L{char}R.3mf")
+        assert exc_info.value.char == char
+
+    def test_pipe_from_issue_1540(self) -> None:
+        """The exact reproducer from the bug report."""
+        with pytest.raises(InvalidFilenameError) as exc_info:
+            validate_print_filename("L|R.3mf")
+        assert exc_info.value.char == "|"
+
+    @pytest.mark.parametrize("name", ["", " ", "   "])
+    def test_empty_rejected(self, name: str) -> None:
+        with pytest.raises(InvalidFilenameError, match="empty"):
+            validate_print_filename(name)
+
+    @pytest.mark.parametrize("name", [".", ".."])
+    def test_dot_names_rejected(self, name: str) -> None:
+        with pytest.raises(InvalidFilenameError):
+            validate_print_filename(name)
+
+    def test_control_char_rejected(self) -> None:
+        with pytest.raises(InvalidFilenameError, match="control"):
+            validate_print_filename("file\x01.3mf")
+
+    @pytest.mark.parametrize("name", ["file.3mf.", "file.3mf "])
+    def test_trailing_space_or_dot_rejected(self, name: str) -> None:
+        with pytest.raises(InvalidFilenameError, match="space or dot"):
+            validate_print_filename(name)
+
+    def test_too_long_rejected(self) -> None:
+        with pytest.raises(InvalidFilenameError, match="bytes"):
+            validate_print_filename("a" * 256)
+
+    def test_unicode_byte_length_not_codepoint(self) -> None:
+        """255 multi-byte codepoints exceeds 255 bytes — must reject."""
+        # 'ä' is 2 bytes in UTF-8
+        with pytest.raises(InvalidFilenameError, match="bytes"):
+            validate_print_filename("ä" * 200)
+
+
+class TestDeriveRemoteFilename:
+    """SD-card upload-name derivation must match what the cleanup deletes (#1542)."""
+
+    def test_strips_gcode_3mf(self) -> None:
+        assert derive_remote_filename("Cube.gcode.3mf") == "Cube.3mf"
+
+    def test_strips_3mf(self) -> None:
+        assert derive_remote_filename("Cube.3mf") == "Cube.3mf"
+
+    def test_bare_stem_appends_3mf(self) -> None:
+        assert derive_remote_filename("Cube") == "Cube.3mf"
+
+    def test_replaces_spaces_with_underscores(self) -> None:
+        # firmware parses ftp://{filename} as a URL, spaces break it
+        assert derive_remote_filename("Cube (1).gcode.3mf") == "Cube_(1).3mf"
+
+    def test_doubled_gcode_3mf_fully_stripped(self) -> None:
+        # The literal reproducer from #1542: library row had .gcode.3mf appended twice
+        assert derive_remote_filename("Cube (1).gcode.3mf.gcode.3mf") == "Cube_(1).3mf"
+
+    def test_doubled_3mf_fully_stripped(self) -> None:
+        assert derive_remote_filename("Cube.3mf.3mf") == "Cube.3mf"
+
+    def test_mixed_double_extensions_fully_stripped(self) -> None:
+        assert derive_remote_filename("Cube.gcode.3mf.3mf") == "Cube.3mf"
+
+    def test_raw_gcode_unchanged_stem(self) -> None:
+        # Bare .gcode (no .3mf wrapper) is a valid sliced file — only the
+        # .3mf wrapper gets stripped; .gcode survives and the result is
+        # the printer's expected ftp:// target.
+        assert derive_remote_filename("Cube.gcode") == "Cube.gcode.3mf"
+
+    def test_idempotent(self) -> None:
+        once = derive_remote_filename("Cube (1).gcode.3mf.gcode.3mf")
+        assert derive_remote_filename(once) == once
+
+    def test_unicode_stem_preserved(self) -> None:
+        assert derive_remote_filename("プリント.gcode.3mf") == "プリント.3mf"
+
+    def test_non_string_input_raises_typeerror(self) -> None:
+        """A duck-typed object whose endswith always returns truthy must not be
+        allowed to enter the strip loop — that's how a test mock OOM'd the
+        container at 61 GB before the type guard was added."""
+        from unittest.mock import MagicMock
+
+        with pytest.raises(TypeError, match="requires str"):
+            derive_remote_filename(MagicMock())
+        with pytest.raises(TypeError, match="requires str"):
+            derive_remote_filename(None)  # type: ignore[arg-type]
+        with pytest.raises(TypeError, match="requires str"):
+            derive_remote_filename(123)  # type: ignore[arg-type]

+ 56 - 0
backend/tests/unit/test_library_classify_file_type.py

@@ -0,0 +1,56 @@
+"""Regression tests for the unified file_type classifier (#1600).
+
+Pre-#1600 each ingest path classified `LibraryFile.file_type` differently
+for the same on-disk file family — only the external-folder scan stored
+`gcode.3mf` for sliced outputs, while upload / ZIP-extract / in-process
+all stripped to the trailing extension and stored `3mf`. The frontend
+had to accept both per #1543, the gcode-download endpoint only handled
+`3mf`, and the external-scan thumbnail gate skipped `gcode.3mf` entirely
+(#1600 itself). `classify_file_type` is now the single source of truth
+across every ingest path + a one-shot DB migration backfills legacy rows.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from backend.app.api.routes.library import classify_file_type
+
+
+@pytest.mark.parametrize(
+    "filename, expected",
+    [
+        # Sliced output — compound extension preserved
+        ("model.gcode.3mf", "gcode.3mf"),
+        ("Multi.Plate.gcode.3mf", "gcode.3mf"),
+        ("MIXED_CASE.GCODE.3MF", "gcode.3mf"),
+        # Plain 3MF — unchanged
+        ("model.3mf", "3mf"),
+        ("model.3MF", "3mf"),
+        # Raw gcode — not a sliced-3mf, classified as gcode (matches existing
+        # gcode-thumbnail branch and the gcode-download endpoint).
+        ("model.gcode", "gcode"),
+        ("model.GCODE", "gcode"),
+        # STL — used by the stats query, thumbnail backfill, and the
+        # `file_type == "stl"` filter. Must not change.
+        ("model.stl", "stl"),
+        # Common image extensions used for thumbnails
+        ("preview.png", "png"),
+        ("preview.JPG", "jpg"),
+        # Files without an extension classify as `unknown` so the downstream
+        # `unknown` branches still see them.
+        ("README", "unknown"),
+        # Files that LOOK like sliced output but aren't — confidence guard.
+        ("not.gcode.3mf.bak", "bak"),
+    ],
+)
+def test_classify_file_type_returns_canonical_value(filename, expected):
+    assert classify_file_type(filename) == expected
+
+
+def test_gcode_3mf_classification_is_stable_across_extension_casing():
+    """The migration's `LOWER(filename) LIKE '%.gcode.3mf'` predicate matches
+    every casing the classifier accepts, so unify on a single canonical
+    lowercase value."""
+    for variant in ("foo.gcode.3mf", "foo.Gcode.3mf", "foo.gcode.3MF", "FOO.GCODE.3MF"):
+        assert classify_file_type(variant) == "gcode.3mf"

+ 160 - 0
backend/tests/unit/test_library_file_type_backfill_migration.py

@@ -0,0 +1,160 @@
+"""Regression test for the library_files.file_type backfill migration (#1600).
+
+Pre-#1600 the upload, ZIP-extract, and in-process ingest paths all stored
+`file_type='3mf'` for sliced `.gcode.3mf` outputs while the external-folder
+scan stored `file_type='gcode.3mf'` — the same on-disk file family split
+across two values depending on how it was ingested. `classify_file_type`
+is now canonical going forward; this migration backfills the legacy `3mf`
+rows so the DB ends up consistent. Idempotent and dialect-neutral.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch regardless of test env settings."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        library,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def engine():
+    from backend.app.core.database import Base
+
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield eng
+    await eng.dispose()
+
+
+async def _insert_file(conn, *, file_id: int, filename: str, file_type: str) -> None:
+    """Insert a minimal LibraryFile row; only the columns the migration
+    touches matter."""
+    await conn.execute(
+        text(
+            "INSERT INTO library_files "
+            "(id, filename, file_path, file_type, file_size, is_external, print_count) "
+            "VALUES (:id, :filename, :path, :ftype, 0, 0, 0)"
+        ),
+        {
+            "id": file_id,
+            "filename": filename,
+            "path": f"/lib/{file_id}",
+            "ftype": file_type,
+        },
+    )
+
+
+@pytest.mark.asyncio
+async def test_backfill_flips_only_legacy_gcode_3mf_rows(engine):
+    """Rows with `file_type='3mf'` whose filename ends in `.gcode.3mf` get
+    upgraded to `gcode.3mf`. Everything else stays put."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="sliced.gcode.3mf", file_type="3mf")
+        await _insert_file(conn, file_id=2, filename="UPPER.GCODE.3MF", file_type="3mf")
+        await _insert_file(conn, file_id=3, filename="model.3mf", file_type="3mf")  # not sliced
+        await _insert_file(conn, file_id=4, filename="model.gcode", file_type="gcode")
+        await _insert_file(conn, file_id=5, filename="model.stl", file_type="stl")
+        await _insert_file(conn, file_id=6, filename="already.gcode.3mf", file_type="gcode.3mf")
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        rows = dict((await conn.execute(text("SELECT id, file_type FROM library_files ORDER BY id"))).fetchall())
+
+    assert rows[1] == "gcode.3mf", "lowercase .gcode.3mf must be backfilled"
+    assert rows[2] == "gcode.3mf", "uppercase .GCODE.3MF must be backfilled (LOWER(filename) in migration)"
+    assert rows[3] == "3mf", "plain .3mf stays at `3mf` — not a sliced output"
+    assert rows[4] == "gcode", "raw .gcode is untouched"
+    assert rows[5] == "stl", "stl is untouched"
+    assert rows[6] == "gcode.3mf", "rows already at canonical pass through"
+
+
+@pytest.mark.asyncio
+async def test_backfill_is_idempotent(engine):
+    """Every boot re-runs the migration set; a second pass on already-
+    backfilled rows must be a no-op."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="sliced.gcode.3mf", file_type="3mf")
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        result = await conn.execute(text("SELECT file_type FROM library_files WHERE id = 1"))
+        assert result.scalar() == "gcode.3mf"
+
+
+@pytest.mark.asyncio
+async def test_backfill_leaves_unrelated_3mf_rows_alone(engine):
+    """A row whose filename happens to contain `.gcode.3mf` as a substring
+    but doesn't END with it (e.g. a `.bak` of a sliced output) is not a
+    sliced output — must NOT be backfilled."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="sliced.gcode.3mf.bak", file_type="3mf")
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        result = await conn.execute(text("SELECT file_type FROM library_files WHERE id = 1"))
+        # The LIKE predicate uses '%.gcode.3mf' so a trailing .bak doesn't match.
+        # The row keeps its pre-migration `3mf` — odd, but classify_file_type
+        # returns `bak` for a fresh ingest, so this row simply stays where it
+        # was. The migration's job is to fix the dominant class, not chase
+        # every edge case.
+        assert result.scalar() == "3mf"

+ 115 - 0
backend/tests/unit/test_no_fail_open_in_auth.py

@@ -0,0 +1,115 @@
+"""GHSA-6mf4-q26m-47pv backstop: no fail-open ``except Exception`` in auth code.
+
+The advisory's root cause was a single ``except Exception:`` block in the
+auth probe path that returned ``False`` (treated as "auth disabled, allow
+everything") when the DB raised an error during the check. CVSS 9.8.
+Other Python ecosystems would call this CWE-636 ("Not Failing
+Securely").
+
+The fundamental problem is that ``except Exception:`` is too broad to
+audit at review time — the reviewer cannot tell, just from the except
+clause, whether the handler re-raises, denies, or silently returns a
+permissive value. So every such block in auth-sensitive code must be
+explicitly tagged with the reviewer's audit conclusion using the
+``# SEC-AUTH-EXC: <reason>`` marker. Untagged blocks fail this
+test.
+
+The tag forces three things every time an ``except Exception:`` lands
+in scope:
+1. A reviewer has read the handler body and confirmed fail-closed semantics.
+2. The reasoning is captured at the exact line so future readers can verify.
+3. ``grep SEC-AUTH-EXC`` enumerates every audited exception path for spot-checks.
+
+Scope (where this rule applies, mirrors SECURITY.md rule 2):
+- backend/app/core/auth.py
+- backend/app/core/permissions.py
+- backend/app/api/routes/auth.py
+
+To add a new ``except Exception:`` block in scope, append a comment
+``# SEC-AUTH-EXC: <short reason>`` on the same line as the
+``except`` keyword. The reason should describe what makes the handler
+safe (e.g. "rollback + raise 500", "returns None which caller treats
+as invalid → 401", "logged only, no access decision made here").
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+
+# Files in scope for the lint. Adding a file here widens the safety net;
+# removing one weakens it. Either decision belongs in a PR description.
+IN_SCOPE: tuple[Path, ...] = (
+    REPO_ROOT / "backend" / "app" / "core" / "auth.py",
+    REPO_ROOT / "backend" / "app" / "core" / "permissions.py",
+    REPO_ROOT / "backend" / "app" / "api" / "routes" / "auth.py",
+)
+
+TAG_MARKER = "# SEC-AUTH-EXC:"
+
+
+def _is_broad_except(handler: ast.ExceptHandler) -> bool:
+    """Return True if ``handler`` catches Exception or bare ``except:``.
+
+    Excludes narrower catches like ``except (OperationalError, ProgrammingError):``
+    or ``except JWTError:`` which are explicit about what they handle and
+    not the GHSA-6mf4 shape.
+    """
+    if handler.type is None:
+        return True  # bare `except:`
+    # Single Name catching Exception
+    if isinstance(handler.type, ast.Name) and handler.type.id == "Exception":
+        return True
+    # Tuple catching Exception alongside other types — e.g. `except (Exception, OSError):`
+    if isinstance(handler.type, ast.Tuple):
+        return any(isinstance(elt, ast.Name) and elt.id == "Exception" for elt in handler.type.elts)
+    return False
+
+
+@pytest.mark.unit
+def test_no_fail_open_in_auth_modules() -> None:
+    """SEC-AUTH-2 (SECURITY.md): every broad except in auth modules must carry SEC-AUTH-EXC tag.
+
+    Walks the AST of each in-scope module, finds every ``except Exception:``
+    (or bare ``except:``) block, and asserts the source line containing
+    the ``except`` keyword has a ``# SEC-AUTH-EXC: <reason>`` tag.
+
+    The tag is the reviewer's signed-off audit conclusion. Without it,
+    the broad except is indistinguishable from the GHSA-6mf4 shape.
+    """
+    findings: list[str] = []
+    for source_path in IN_SCOPE:
+        assert source_path.is_file(), f"Expected in-scope file at {source_path}"
+        source = source_path.read_text(encoding="utf-8")
+        source_lines = source.splitlines()
+        tree = ast.parse(source)
+        relative = source_path.relative_to(REPO_ROOT)
+
+        for node in ast.walk(tree):
+            if not isinstance(node, ast.ExceptHandler):
+                continue
+            if not _is_broad_except(node):
+                continue
+
+            # The ``except`` keyword is on node.lineno. Comment must appear
+            # on that line (1-indexed in ast, 0-indexed in our list).
+            line_text = source_lines[node.lineno - 1]
+            if TAG_MARKER not in line_text:
+                # Show enough context for the operator to find the block.
+                handler_preview = line_text.strip()
+                findings.append(
+                    f"  {relative}:{node.lineno}  {handler_preview}\n"
+                    f"      → add `{TAG_MARKER} <reason>` describing why this is fail-closed"
+                )
+
+    assert not findings, (
+        "Untagged ``except Exception:`` (or bare ``except:``) blocks found in auth modules. "
+        "Each one is indistinguishable at review time from the GHSA-6mf4-q26m-47pv shape (CVSS 9.8). "
+        "Either narrow the catch to the specific exception type you handle, or tag the line with "
+        "`# SEC-AUTH-EXC: <reason>` documenting what makes the handler fail-closed. "
+        "See SECURITY.md rule 2 'Fail-closed in auth code'.\n\n" + "\n".join(findings)
+    )

+ 158 - 0
backend/tests/unit/test_no_hardcoded_secrets.py

@@ -0,0 +1,158 @@
+"""GHSA-gc24-px2r-5qmf backstop: no hardcoded fallback secrets in source.
+
+The first half of GHSA-gc24-px2r-5qmf (CVSS 9.8) was a literal
+``bambuddy-secret-key-change-in-production`` string used as the JWT
+signing key when ``JWT_SECRET_KEY`` was unset. Production Docker images
+shipped with that exact string — meaning anyone who pulled the image
+could forge admin tokens for any Bambuddy instance running unmodified.
+
+This test walks every source file in ``backend/app/`` at parse time and
+flags string literals that look like credential fallbacks. It is
+deliberately stricter than the actual exploit: any
+``*-change-in-production`` / ``change-me`` / ``your-secret-here``
+shaped string is a code smell at a security boundary, regardless of
+whether the call site happens to enforce env-var presence today. The
+goal is to keep that string class out of the codebase entirely so
+future code paths cannot re-introduce the same vulnerability shape.
+
+If you need one of these strings as a test input (e.g. asserting that
+a forged token signed with the old leaked secret is *rejected*), use
+the ``ALLOWED_TEST_INPUT_PATTERNS`` allowlist below — never the
+production source.
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+
+# Substring patterns that should never appear in production source as
+# string literals. Case-insensitive substring match.
+FORBIDDEN_PATTERNS: tuple[str, ...] = (
+    "change-in-production",
+    "change-me-in-production",
+    "your-secret-here",
+    "your-secret-key",
+    "default-secret-key",
+    "insecure-default",
+    "placeholder-secret",
+    "replace-this-secret",
+    # The exact leaked value from GHSA-gc24 — keep as a regression marker
+    # so any reintroduction is caught loudly with the CVE number attached.
+    "bambuddy-secret-key-change-in-production",
+)
+
+# Production-source files where these patterns are TOLERATED because they
+# document the historical leak (CHANGELOG / migration notes / security
+# advisory references) rather than being used as a credential fallback.
+# Add an entry with a `# reason: ...` comment, never silently.
+ALLOWED_PRODUCTION_FILES: frozenset[Path] = frozenset()
+
+
+def _python_files_under(root: Path) -> list[Path]:
+    """Yield every .py file under ``root`` excluding caches and virtualenvs."""
+    return [
+        p
+        for p in root.rglob("*.py")
+        if "__pycache__" not in p.parts and ".venv" not in p.parts and "venv" not in p.parts
+    ]
+
+
+def _string_literals_in(file_path: Path) -> list[tuple[int, str]]:
+    """Return (lineno, value) for every string literal in ``file_path``.
+
+    Uses ``ast`` to avoid false positives from comments / docstrings;
+    docstrings are ``ast.Constant`` too but we explicitly include them
+    because a docstring is not a safe place to put a credential either.
+    Returns an empty list on syntax-error files rather than crashing —
+    a parse failure means the file has a separate bug and we don't want
+    this test to mask it.
+    """
+    try:
+        tree = ast.parse(file_path.read_text(encoding="utf-8"))
+    except (SyntaxError, UnicodeDecodeError):
+        return []
+    return [
+        (node.lineno, node.value)
+        for node in ast.walk(tree)
+        if isinstance(node, ast.Constant) and isinstance(node.value, str)
+    ]
+
+
+@pytest.mark.unit
+def test_no_hardcoded_secrets_in_production_source() -> None:
+    """SEC-AUTH-3 (SECURITY.md): no credential-shaped fallback strings in backend/app/.
+
+    Walks every Python source file under ``backend/app/``. Flags string
+    literals matching any pattern in ``FORBIDDEN_PATTERNS``. Allowlisted
+    files (e.g. tests asserting we reject the leaked GHSA-gc24 token)
+    are exempt via ``ALLOWED_PRODUCTION_FILES``.
+
+    Failure here means a code change has reintroduced the GHSA-gc24
+    failure mode: a string literal that production code could fall
+    back to as a credential, defeating the env-var-or-fail design of
+    ``_get_jwt_secret()``.
+    """
+    repo_root = Path(__file__).resolve().parents[3]
+    production_root = repo_root / "backend" / "app"
+    assert production_root.is_dir(), f"Expected backend/app/ at {production_root}"
+
+    findings: list[str] = []
+    for src in _python_files_under(production_root):
+        relative = src.relative_to(repo_root)
+        if relative in ALLOWED_PRODUCTION_FILES:
+            continue
+        for lineno, literal in _string_literals_in(src):
+            literal_lower = literal.lower()
+            for pattern in FORBIDDEN_PATTERNS:
+                if pattern in literal_lower:
+                    findings.append(f"  {relative}:{lineno} contains forbidden pattern '{pattern}': {literal!r}")
+                    break
+
+    assert not findings, (
+        "Hardcoded credential-shaped strings found in production source — "
+        "this is the GHSA-gc24-px2r-5qmf shape (CVSS 9.8 hardcoded JWT secret). "
+        "See SECURITY.md rule 3 'No hardcoded fallback secrets'.\n\n" + "\n".join(findings)
+    )
+
+
+@pytest.mark.unit
+def test_jwt_secret_loader_has_no_hardcoded_fallback() -> None:
+    """SEC-AUTH-3 (SECURITY.md): _get_jwt_secret never returns a literal string.
+
+    The post-GHSA-gc24 design of ``_get_jwt_secret`` reads from env, then
+    file, then generates a random value via ``secrets.token_urlsafe``.
+    No code path returns a string literal. This test asserts that
+    structural property by walking the function's AST and confirming
+    every ``return`` statement returns either a Name (variable) or a
+    Call (function result), never an ast.Constant string literal.
+
+    If this test fails, ``_get_jwt_secret`` has been modified to return
+    a hardcoded value somewhere — likely as a "convenience default" that
+    will end up in a shipped Docker image, which is exactly how the
+    original GHSA-gc24 advisory happened.
+    """
+    repo_root = Path(__file__).resolve().parents[3]
+    auth_module = repo_root / "backend" / "app" / "core" / "auth.py"
+    tree = ast.parse(auth_module.read_text(encoding="utf-8"))
+
+    loader: ast.FunctionDef | None = None
+    for node in ast.walk(tree):
+        if isinstance(node, ast.FunctionDef) and node.name == "_get_jwt_secret":
+            loader = node
+            break
+
+    assert loader is not None, "_get_jwt_secret() not found in backend/app/core/auth.py — has it been renamed?"
+
+    literal_returns: list[tuple[int, str]] = []
+    for node in ast.walk(loader):
+        if isinstance(node, ast.Return) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
+            literal_returns.append((node.lineno, node.value.value))
+
+    assert not literal_returns, (
+        "_get_jwt_secret() has a string-literal return — this is the GHSA-gc24 vulnerability shape. "
+        "Use os.environ + file storage + secrets.token_urlsafe; never return a hardcoded string.\n"
+        + "\n".join(f"  auth.py:{ln}: returns {val!r}" for ln, val in literal_returns)
+    )

+ 304 - 0
backend/tests/unit/test_no_unsafe_path_joins.py

@@ -0,0 +1,304 @@
+"""Backstop: every Path-arithmetic site in the API routes that joins a
+variable to a directory-like parent must either use ``safe_join_under`` or
+carry a ``# SEC-PATH-OK: <reason>`` marker.
+
+A critical advisory traced to plain ``Path / user_string`` arithmetic in
+``import_project_file`` — the join had no resolve + containment check, and an
+attacker-supplied absolute path collapsed the left side. This test catches the
+same shape in any new route added later: it AST-walks every Python file under
+``backend/app/api/routes/`` and flags every ``a / b`` where ``a`` looks like a
+directory variable and ``b`` is a non-constant (i.e. variable / call result).
+
+False positives are intentionally cheap to silence (add a one-line
+``# SEC-PATH-OK: <reason>`` justifying the existing guard) so that *future*
+unsafe joins are noisy by default.
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+
+# The route surface receives external input directly; the services layer is
+# called by the routes and routinely receives values that originated from a
+# request (filenames, query params) or from an untrusted external source
+# (printer FTP listings — the printer is part of the threat surface in the
+# compromised-printer model). Both layers need the strictest gate.
+_BACKEND_APP = Path(__file__).resolve().parents[2] / "app"
+SCAN_DIRS = [
+    _BACKEND_APP / "api" / "routes",
+    _BACKEND_APP / "services",
+]
+
+# Identifier substrings that suggest the LHS is a filesystem directory. Heuristic
+# but tuned to Bambuddy's conventions — every actual directory variable in the
+# routes hits one of these.
+_DIR_NAME_HINTS = (
+    "_dir",
+    "_path",
+    "dir_",
+    "path_",
+    "temp_path",
+    "library_dir",
+    "archive_dir",
+    "photos_dir",
+    "base_dir",
+    "ext_dir",
+    "attachments_dir",
+    "static_dir",
+    "log_dir",
+    "data_dir",
+    "folder_path",
+    "file_disk_path",
+    "photo_path",
+    "dest",
+    "output_path",
+)
+
+# Function calls whose return value is a Path under our control. Hits to these
+# don't need scrutiny — they're constructed by Bambuddy code, not by the request.
+_KNOWN_PATH_FACTORIES = (
+    "Path",
+    "get_library_dir",
+    "get_library_files_dir",
+    "get_archive_dir",
+    "get_project_attachments_dir",
+    "get_project_cover_dir",
+    "resolve",
+)
+
+_MARKER = "# SEC-PATH-OK:"
+
+
+def _looks_path_like(node: ast.AST) -> bool:
+    """Heuristic for whether *node* evaluates to a ``pathlib.Path``."""
+    if isinstance(node, ast.Call):
+        func = node.func
+        if isinstance(func, ast.Name) and func.id in _KNOWN_PATH_FACTORIES:
+            return True
+        return bool(isinstance(func, ast.Attribute) and func.attr in _KNOWN_PATH_FACTORIES)
+    if isinstance(node, ast.Name):
+        return any(hint in node.id for hint in _DIR_NAME_HINTS)
+    if isinstance(node, ast.Attribute):
+        # `settings.base_dir`, `cls.archive_dir`, etc.
+        return any(hint in node.attr for hint in _DIR_NAME_HINTS)
+    if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
+        # Chains like ``base_dir / "x" / variable`` — keep looking left.
+        return _looks_path_like(node.left)
+    return False
+
+
+def _is_constant_string(node: ast.AST) -> bool:
+    return isinstance(node, ast.Constant) and isinstance(node.value, str)
+
+
+def _rhs_is_attacker_shape(node: ast.AST) -> bool:
+    """The high-risk shape is ``path / Name`` — RHS is a bare variable that
+    came from somewhere outside this scope (a function parameter, request
+    body field, ZIP namelist entry).
+
+    Attribute (``lib_file.file_path``), Subscript (``photos[i]``), Call
+    (``str(vp_id)``), and JoinedStr (f-strings) all have *some* structure
+    that the audit can reason about — those are caught by the broader audit
+    sweep, not the regression backstop. This narrows the noise to the exact
+    shape that produced the path-traversal class so the backstop only fires
+    when something that *looks* like that bug appears.
+    """
+    return isinstance(node, ast.Name)
+
+
+_CONTINUATION_TOKENS = (")", "]", "}", ",")
+
+
+def _line_has_marker(source_lines: list[str], lineno: int, end_lineno: int | None) -> bool:
+    """Check whether a ``# SEC-PATH-OK:`` marker covers this join.
+
+    Looks at every line spanned by the BinOp itself, plus one line past
+    ``end_lineno`` IF that line begins with a continuation token (``)``,
+    ``]``, ``}``, ``,``). The peek captures the project's convention of
+    wrapping a BinOp in parens and placing the marker on the closing line:
+
+        file_path = (
+            library_dir / filename
+        )  # SEC-PATH-OK: filename validated above
+
+    The BinOp itself ends on the inner line, but the marker reads more
+    naturally on the closing line. Restricting the peek to continuation
+    lines prevents giving a free pass to a marker that happens to sit on
+    a wholly unrelated follow-on statement.
+    """
+    start = max(1, lineno)
+    end = max(start, end_lineno or lineno)
+    for i in range(start, end + 1):
+        if i - 1 >= len(source_lines):
+            continue
+        if _MARKER in source_lines[i - 1]:
+            return True
+    # Project convention: marker often sits on the closing-paren line, one
+    # line past the BinOp's `end_lineno`. Only accept it when the line is a
+    # continuation of the wrapping expression.
+    if end < len(source_lines):
+        trailing = source_lines[end].lstrip()
+        if trailing.startswith(_CONTINUATION_TOKENS) and _MARKER in source_lines[end]:
+            return True
+    return False
+
+
+def _enclosing_call_is_safe_join(stack: list[ast.AST]) -> bool:
+    """True if the BinOp is being passed directly into ``safe_join_under(...)``.
+
+    Tracking parent links keeps the test conservative — a ``base_dir / x``
+    expression that's already inside ``safe_join_under(base_dir / x, ...)``
+    is fine because the helper does its own containment check. This rarely
+    happens in practice but keeps the test from yelling about an idiomatic
+    arrangement.
+    """
+    for ancestor in reversed(stack):
+        if isinstance(ancestor, ast.Call):
+            func = ancestor.func
+            if isinstance(func, ast.Name) and func.id == "safe_join_under":
+                return True
+            if isinstance(func, ast.Attribute) and func.attr == "safe_join_under":
+                return True
+    return False
+
+
+def _scan_file(py_file: Path) -> list[str]:
+    source = py_file.read_text()
+    source_lines = source.splitlines()
+    tree = ast.parse(source, filename=str(py_file))
+    findings: list[str] = []
+
+    # Walk with parent stack so we can detect "inside safe_join_under" and
+    # skip such nodes.
+    stack: list[ast.AST] = []
+
+    def visit(node: ast.AST) -> None:
+        stack.append(node)
+        try:
+            if (
+                isinstance(node, ast.BinOp)
+                and isinstance(node.op, ast.Div)
+                and _looks_path_like(node.left)
+                and _rhs_is_attacker_shape(node.right)
+                and not _is_constant_string(node.right)
+                and not _enclosing_call_is_safe_join(stack)
+                and not _line_has_marker(source_lines, node.lineno, node.end_lineno)
+            ):
+                line = source_lines[node.lineno - 1].strip() if node.lineno - 1 < len(source_lines) else "<?>"
+                findings.append(f"{py_file.name}:{node.lineno}  {line}")
+            for child in ast.iter_child_nodes(node):
+                visit(child)
+        finally:
+            stack.pop()
+
+    visit(tree)
+    return findings
+
+
+def _scan_source(source: str, tmp_path: Path) -> list[str]:
+    """Drop ``source`` into a temp .py file and run ``_scan_file`` against it.
+    Returns the findings list."""
+    f = tmp_path / "candidate.py"
+    f.write_text(source)
+    return _scan_file(f)
+
+
+class TestMarkerDetection:
+    """Pins the contract for where a ``# SEC-PATH-OK:`` marker is accepted.
+
+    Markers must sit either (a) somewhere within the BinOp's own source
+    lines, or (b) on the immediately-following line when that line is a
+    continuation token (closing paren / bracket / brace / comma). The
+    second case is the project's convention of wrapping the BinOp in
+    parens and placing the marker on the closing-paren line.
+    """
+
+    def test_marker_on_binop_line_recognised(self, tmp_path):
+        source = (
+            "from pathlib import Path\nbase_dir = Path('.')\ndef f(x): return base_dir / x  # SEC-PATH-OK: trusted x\n"
+        )
+        assert _scan_source(source, tmp_path) == []
+
+    def test_marker_on_closing_paren_line_recognised(self, tmp_path):
+        # The exact convention used across api/routes/ and services/: the
+        # BinOp lives inside a parenthesised expression and the marker sits
+        # on the closing-paren line, one past the BinOp's `end_lineno`.
+        source = (
+            "from pathlib import Path\n"
+            "base_dir = Path('.')\n"
+            "def f(x):\n"
+            "    return (\n"
+            "        base_dir / x\n"
+            "    )  # SEC-PATH-OK: trusted x\n"
+        )
+        assert _scan_source(source, tmp_path) == []
+
+    def test_unrelated_marker_below_does_not_silence(self, tmp_path):
+        # A second join below carries a marker; the first does not. Only
+        # the first must be flagged — markers don't cross statement
+        # boundaries.
+        source = (
+            "from pathlib import Path\n"
+            "base_dir = Path('.')\n"
+            "def f(x, y):\n"
+            "    p = base_dir / x\n"
+            "    q = base_dir / y  # SEC-PATH-OK: trusted y\n"
+        )
+        findings = _scan_source(source, tmp_path)
+        assert len(findings) == 1
+        assert "base_dir / x" in findings[0]
+
+    def test_marker_on_non_continuation_line_does_not_silence(self, tmp_path):
+        # A SEC-PATH-OK comment on the line right after the BinOp counts
+        # only when that line is a continuation of the wrapping expression.
+        # An unrelated next statement's marker must not free-pass the join.
+        source = (
+            "from pathlib import Path\n"
+            "base_dir = Path('.')\n"
+            "def f(x):\n"
+            "    a = base_dir / x\n"
+            "    b = 1  # SEC-PATH-OK: unrelated, for a different statement\n"
+        )
+        findings = _scan_source(source, tmp_path)
+        assert len(findings) == 1
+        assert "base_dir / x" in findings[0]
+
+    def test_no_marker_anywhere_is_flagged(self, tmp_path):
+        # Pin the negative path so a future refactor can't accidentally turn
+        # marker detection into "always returns True".
+        source = "from pathlib import Path\nbase_dir = Path('.')\ndef f(x): return base_dir / x\n"
+        findings = _scan_source(source, tmp_path)
+        assert len(findings) == 1
+        assert "base_dir / x" in findings[0]
+
+
+def test_route_path_arithmetic_is_safe_joined_or_marked():
+    """Every ``<dir-like> / <non-constant>`` join in a route handler must
+    either route through ``safe_join_under(...)`` or carry a
+    ``# SEC-PATH-OK: <reason>`` marker on one of its source lines.
+
+    Adding ``# SEC-PATH-OK: <reason>`` is the escape hatch for sites where
+    the input has already been validated (e.g. a denylist + membership
+    check, a pre-sanitised alphanumeric filter, or an explicit resolve +
+    ``relative_to`` containment check inline). The marker MUST explain the
+    existing guard — silent suppression defeats the backstop's purpose.
+    """
+    findings: list[str] = []
+    for scan_dir in SCAN_DIRS:
+        for py_file in sorted(scan_dir.rglob("*.py")):
+            if py_file.name == "__init__.py":
+                continue
+            findings.extend(_scan_file(py_file))
+
+    if findings:
+        pytest.fail(
+            "Found Path-arithmetic sites in api/routes/ or services/ that "
+            "join a non-constant value to a directory-like parent without "
+            "using safe_join_under() or carrying a # SEC-PATH-OK: marker. "
+            "Each site must either be refactored to "
+            "safe_join_under(parent, *parts) or tagged with the marker "
+            "explaining why the existing guard is sufficient.\n\nFindings:\n" + "\n".join(findings)
+        )

+ 248 - 0
backend/tests/unit/test_reconcile_stale_active_prints.py

@@ -0,0 +1,248 @@
+"""Tests for the connected-edge reconciliation that recovers from missed
+PRINT COMPLETE events (#1542 follow-up).
+
+Background: the PRINT COMPLETE MQTT callback is purely reactive to a single
+state transition (RUNNING → IDLE / FINISH / FAILED). When the printer
+finishes during an MQTT disconnect window — typical on the A1 line with
+unstable MQTT keepalives — Bambuddy never observes the transition. If a
+smart plug then cuts power between completion and the next reconnect, the
+firmware auto-replays whatever's still on the SD card and produces a ghost
+print on next power-up. Reporter (#1542 second case) saw this hit 4 out of
+4 of his A1s.
+
+These tests cover:
+  * `_is_active_archive_stale` — the pure decision function for whether an
+    archive in `status="printing"` should be reconciled given the printer's
+    current state.
+  * `reconcile_stale_active_prints` — the orchestrator that queries the DB,
+    runs the decision function, and synthesises `on_print_complete` for
+    each stale archive.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.main import _is_active_archive_stale
+
+
+def _state(state: str, *, subtask_id: str = "", subtask_name: str = "", connected: bool = True) -> SimpleNamespace:
+    """Minimal PrinterState stub for the pure decision function."""
+    return SimpleNamespace(
+        state=state,
+        subtask_id=subtask_id,
+        subtask_name=subtask_name,
+        connected=connected,
+        raw_data={},
+    )
+
+
+def _archive(
+    subtask_id: str | None = "ABC123", filename: str = "ghost.3mf", print_name: str = "ghost"
+) -> SimpleNamespace:
+    """Minimal PrintArchive stub — only the fields the decision function reads."""
+    return SimpleNamespace(
+        id=42,
+        subtask_id=subtask_id,
+        filename=filename,
+        print_name=print_name,
+    )
+
+
+class TestIsActiveArchiveStale:
+    """Decision function — covers all three stale triggers + the
+    intentionally-conservative no-op cases."""
+
+    # Trigger 1: printer is in a terminal state.
+    @pytest.mark.parametrize("terminal_state", ["IDLE", "FINISH", "FAILED", "idle", "finish", "failed"])
+    def test_terminal_state_marks_stale(self, terminal_state):
+        archive = _archive(subtask_id="ABC123")
+        state = _state(terminal_state, subtask_id="ABC123", subtask_name="ghost")
+        is_stale, reason = _is_active_archive_stale(archive, state)
+        assert is_stale is True
+        assert terminal_state.upper() in reason
+
+    # Trigger 2: printer is running a different subtask_id.
+    def test_subtask_id_changed_marks_stale(self):
+        archive = _archive(subtask_id="OLD_ID")
+        state = _state("RUNNING", subtask_id="NEW_ID", subtask_name="something")
+        is_stale, reason = _is_active_archive_stale(archive, state)
+        assert is_stale is True
+        assert "subtask_id" in reason
+        assert "OLD_ID" in reason
+        assert "NEW_ID" in reason
+
+    # Trigger 3: printer is running but doesn't know what it's running.
+    def test_empty_subtask_name_marks_stale(self):
+        archive = _archive(subtask_id="ABC123")
+        state = _state("RUNNING", subtask_id="", subtask_name="")
+        is_stale, reason = _is_active_archive_stale(archive, state)
+        assert is_stale is True
+        assert "empty" in reason.lower() or "subtask_name" in reason
+
+    # Healthy case: same subtask_id, running.
+    def test_matching_running_print_not_stale(self):
+        archive = _archive(subtask_id="ABC123")
+        state = _state("RUNNING", subtask_id="ABC123", subtask_name="ghost")
+        is_stale, _ = _is_active_archive_stale(archive, state)
+        assert is_stale is False
+
+    # PAUSE is not a stale signal — the print is paused, not ended.
+    def test_paused_print_with_matching_subtask_not_stale(self):
+        archive = _archive(subtask_id="ABC123")
+        state = _state("PAUSE", subtask_id="ABC123", subtask_name="ghost")
+        is_stale, _ = _is_active_archive_stale(archive, state)
+        assert is_stale is False
+
+    # PREPARE / SLICING are not stale either — pre-print phases.
+    @pytest.mark.parametrize("pre_running_state", ["PREPARE", "SLICING"])
+    def test_pre_running_states_with_matching_subtask_not_stale(self, pre_running_state):
+        archive = _archive(subtask_id="ABC123")
+        state = _state(pre_running_state, subtask_id="ABC123", subtask_name="ghost")
+        is_stale, _ = _is_active_archive_stale(archive, state)
+        assert is_stale is False
+
+    # Missing subtask_id on the archive side: don't have evidence either
+    # way, fall through to the empty-subtask_name check.
+    def test_archive_with_no_subtask_id_falls_to_subtask_name_check(self):
+        archive = _archive(subtask_id=None)
+        state = _state("RUNNING", subtask_id="ANYTHING", subtask_name="something")
+        # Subtask_name is populated → not stale, no false positive.
+        is_stale, _ = _is_active_archive_stale(archive, state)
+        assert is_stale is False
+
+    # Missing subtask_id on both sides: still triggers the empty-subtask_name
+    # branch if the printer doesn't know what it's running.
+    def test_both_subtask_ids_missing_running_with_empty_name_stale(self):
+        archive = _archive(subtask_id=None)
+        state = _state("RUNNING", subtask_id="", subtask_name="")
+        is_stale, _ = _is_active_archive_stale(archive, state)
+        assert is_stale is True
+
+    # IDLE wins over PRINT-STATE checks — the terminal-state branch fires
+    # first regardless of what the subtask fields look like.
+    def test_idle_state_overrides_matching_subtask(self):
+        archive = _archive(subtask_id="ABC123")
+        state = _state("IDLE", subtask_id="ABC123", subtask_name="ghost")
+        is_stale, reason = _is_active_archive_stale(archive, state)
+        assert is_stale is True
+        assert "IDLE" in reason
+
+
+class TestReconcileStaleActivePrints:
+    """Orchestrator-level tests — mock the printer manager + DB session so
+    we can drive the decision flow end-to-end without standing up real
+    fixtures.
+
+    These cover:
+      * No printer status (disconnected) → no-op, no on_print_complete fired.
+      * No active archives → no-op.
+      * Stale archive → synthesised on_print_complete called with status
+        ``"aborted"`` and the `_reconciled: True` marker so downstream code
+        can distinguish synthetic from real completions.
+      * Non-stale archive → on_print_complete NOT called (no false positive
+        on a healthy in-flight print).
+      * Exception inside on_print_complete must NOT block reconciliation
+        for subsequent archives or crash the caller.
+    """
+
+    @pytest.mark.asyncio
+    async def test_no_status_skips_reconciliation(self):
+        from backend.app.main import reconcile_stale_active_prints
+
+        with patch("backend.app.main.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = None
+            count = await reconcile_stale_active_prints(printer_id=1)
+        assert count == 0
+
+    @pytest.mark.asyncio
+    async def test_disconnected_status_skips_reconciliation(self):
+        from backend.app.main import reconcile_stale_active_prints
+
+        with patch("backend.app.main.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = _state("RUNNING", connected=False)
+            count = await reconcile_stale_active_prints(printer_id=1)
+        # Disconnected state would be making decisions against cached state —
+        # the connected-edge handler in on_printer_status_change is the only
+        # place that should drive reconciliation.
+        assert count == 0
+
+    @pytest.mark.asyncio
+    async def test_no_active_archives_returns_zero(self):
+        from backend.app.main import reconcile_stale_active_prints
+
+        with patch("backend.app.main.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = _state("IDLE")
+            with patch("backend.app.main.async_session") as mock_session:
+                session_ctx = AsyncMock()
+                session_ctx.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [])))
+                mock_session.return_value.__aenter__.return_value = session_ctx
+                count = await reconcile_stale_active_prints(printer_id=1)
+        assert count == 0
+
+    @pytest.mark.asyncio
+    async def test_stale_archive_synthesises_aborted_completion(self):
+        from backend.app.main import reconcile_stale_active_prints
+
+        stale = _archive(subtask_id="OLD_ID", filename="ghost.3mf", print_name="ghost")
+        with patch("backend.app.main.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = _state("IDLE", subtask_id="", subtask_name="")
+            with patch("backend.app.main.async_session") as mock_session:
+                session_ctx = AsyncMock()
+                session_ctx.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [stale])))
+                mock_session.return_value.__aenter__.return_value = session_ctx
+                with patch("backend.app.main.on_print_complete", new=AsyncMock()) as mock_complete:
+                    count = await reconcile_stale_active_prints(printer_id=1)
+        assert count == 1
+        mock_complete.assert_awaited_once()
+        # Verify the synthesised payload shape.
+        args, kwargs = mock_complete.call_args
+        assert args[0] == 1
+        payload = args[1]
+        assert payload["status"] == "aborted"
+        assert payload["filename"] == "ghost.3mf"
+        assert payload["_reconciled"] is True
+
+    @pytest.mark.asyncio
+    async def test_non_stale_archive_does_not_synthesise(self):
+        from backend.app.main import reconcile_stale_active_prints
+
+        healthy = _archive(subtask_id="ABC123")
+        with patch("backend.app.main.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = _state("RUNNING", subtask_id="ABC123", subtask_name="ghost")
+            with patch("backend.app.main.async_session") as mock_session:
+                session_ctx = AsyncMock()
+                session_ctx.execute = AsyncMock(
+                    return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [healthy]))
+                )
+                mock_session.return_value.__aenter__.return_value = session_ctx
+                with patch("backend.app.main.on_print_complete", new=AsyncMock()) as mock_complete:
+                    count = await reconcile_stale_active_prints(printer_id=1)
+        assert count == 0
+        mock_complete.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_failure_does_not_block_rest(self):
+        """An exception during one archive's synthesis must not abort
+        reconciliation for the other archives — and must not propagate to
+        the caller (the connected-edge handler is a hot path)."""
+        from backend.app.main import reconcile_stale_active_prints
+
+        a1 = _archive(subtask_id="A", filename="a.3mf")
+        a1.id = 1
+        a2 = _archive(subtask_id="B", filename="b.3mf")
+        a2.id = 2
+        with patch("backend.app.main.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = _state("IDLE")
+            with patch("backend.app.main.async_session") as mock_session:
+                session_ctx = AsyncMock()
+                session_ctx.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [a1, a2])))
+                mock_session.return_value.__aenter__.return_value = session_ctx
+                # First call raises, second call must still happen.
+                mock_complete = AsyncMock(side_effect=[RuntimeError("boom"), None])
+                with patch("backend.app.main.on_print_complete", new=mock_complete):
+                    count = await reconcile_stale_active_prints(printer_id=1)
+        # Only the second archive is recorded as reconciled (first raised).
+        assert count == 1
+        assert mock_complete.await_count == 2

+ 226 - 0
backend/tests/unit/test_route_auth_coverage.py

@@ -0,0 +1,226 @@
+"""GHSA-gc24 + GHSA-r2qv backstop: every route has an explicit auth dep.
+
+The "second half" of GHSA-gc24-px2r-5qmf was that 77 endpoints out of 117
+responded to anonymous requests with full payloads. The fix at the time
+was retroactive — auth deps were added route by route. This test makes
+the requirement structural: every FastAPI route at the app-level (HTTP
+and WebSocket) is walked, and each one either has an auth dependency or
+is in the ``PUBLIC_ROUTES`` allowlist with a justification comment.
+
+Adding an unauthenticated route now requires touching the allowlist.
+The diff makes the intent visible in code review and the entry-with-
+reason format documents *why* this is safe (login itself, status
+heartbeat, etc.). Drift catches the same failure mode that surfaced
+the original advisory.
+
+The audit also covers WebSocket routes — the proactive sweep that
+surfaced finding C1 (`/api/v1/ws` was fully unauthenticated) showed
+that an APIRoute-only walk has a blind spot for the very route shape
+that produced the most severe disclosure.
+"""
+
+from __future__ import annotations
+
+import re
+
+import pytest
+from fastapi.routing import APIRoute, APIWebSocketRoute
+
+from backend.app.main import app
+
+# Substring patterns identifying auth-bearing callable qualnames in the
+# resolved Depends() tree. Inner functions returned by factories carry
+# the outer factory's name in their qualname (e.g.
+# ``require_permission.<locals>.permission_checker``), so a substring
+# check is enough; we don't have to enumerate the inner names.
+_AUTH_QUALNAME_PATTERNS: tuple[str, ...] = (
+    "require_",  # require_permission, require_permission_if_auth_enabled, require_role, require_admin_*, require_auth_*, require_any_*, require_ownership_*, require_camera_stream_token_*, require_energy_cost_update
+    "cloud_caller",  # cloud.py route-level dep
+    "_cloud_api_key_gate",  # cloud.py router-level dep
+    "resolve_api_key_cloud_owner",  # used by slicer routes that need the API key's owner
+    "get_current_user",  # JWT identity resolution
+    "get_current_active_user",  # JWT identity resolution
+    "get_api_key",  # webhook routes use this directly
+    "verify_websocket_token",  # WebSocket route inline check (GHSA-r2qv I-WS)
+)
+
+# Routes that are intentionally accessible without an auth dependency.
+# Each entry MUST be (method, path) tuple — the path is matched against
+# ``route.path`` literally. To add an entry: include a justification on
+# the line above explaining why anonymous access is safe.
+_PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
+    {
+        # ---- HTTP API: auth bootstrap (pre-credential or token-self-validated) ----
+        # First-run setup — runs before any user exists. Idempotent once setup_completed is true.
+        ("POST", "/api/v1/auth/setup"),
+        # Login itself — credentials in the request body ARE the auth.
+        ("POST", "/api/v1/auth/login"),
+        # Logout — clears server-side JTI revocation; degraded behaviour on bad token is acceptable.
+        ("POST", "/api/v1/auth/logout"),
+        # Status heartbeat — used by the login UI to decide whether to show login form.
+        ("GET", "/api/v1/auth/status"),
+        # Advanced-auth status (whether 2FA / OIDC / LDAP are configured) — read by login form.
+        ("GET", "/api/v1/auth/advanced-auth/status"),
+        # LDAP status (whether LDAP login is configured) — read by login form.
+        ("GET", "/api/v1/auth/ldap/status"),
+        # OIDC discovery — login form needs the list of providers + their icons before user picks one.
+        ("GET", "/api/v1/auth/oidc/providers"),
+        ("GET", "/api/v1/auth/oidc/providers/{provider_id}/icon"),
+        # OIDC authorize / callback / exchange — protocol-level handshakes that validate state nonces inline.
+        ("GET", "/api/v1/auth/oidc/authorize/{provider_id}"),
+        ("GET", "/api/v1/auth/oidc/callback"),
+        ("POST", "/api/v1/auth/oidc/exchange"),
+        # 2FA send + verify — issued after password check; pre-auth token in cookie is the auth.
+        ("POST", "/api/v1/auth/2fa/email/send"),
+        ("POST", "/api/v1/auth/2fa/verify"),
+        # Forgot-password (anonymous request) + confirm (signed token in the URL).
+        ("POST", "/api/v1/auth/forgot-password"),
+        ("POST", "/api/v1/auth/forgot-password/confirm"),
+        # ---- HTTP API: signed-URL routes (token in path is the auth) ----
+        # Signed download URLs — token in path validated by the handler.
+        ("GET", "/api/v1/archives/{archive_id}/dl/{token}/{filename}"),
+        ("GET", "/api/v1/archives/{archive_id}/source-dl/{token}/{filename}"),
+        ("GET", "/api/v1/library/files/{file_id}/dl/{token}/{filename}"),
+        # Obico cached frame — one-time nonce embedded in <img> tags.
+        ("GET", "/api/v1/obico/cached-frame/{nonce}"),
+        # MakerWorld thumbnail proxy — fetches external URL; no Bambuddy data exposed.
+        ("GET", "/api/v1/makerworld/thumbnail"),
+        # ---- HTTP API: operational + UI-bootstrap (no sensitive data) ----
+        # Operational liveness probe — minimal payload, used by container orchestrators.
+        ("GET", "/health"),
+        # Prometheus metrics — gated by its own bearer token check (constant-time post-I2).
+        ("GET", "/api/v1/metrics"),
+        # UI bootstrap — defaults for sidebar order and ui-preferences are public defaults that ship with the app.
+        ("GET", "/api/v1/settings/default-sidebar-order"),
+        ("GET", "/api/v1/settings/ui-preferences"),
+        # Slicer printer-models — static catalog, no user data.
+        ("GET", "/api/v1/slicer/printer-models"),
+        # Current Bambuddy version — public info (already visible in HTTP response headers + Docker tags).
+        ("GET", "/api/v1/updates/version"),
+        # Webhook routes — auth lives inside the handler via get_api_key() + check_permission(), not as a Depends.
+        # Once they all migrate to standard auth deps these entries come out; for now exempting the file.
+        ("GET", "/api/v1/webhook/printer/{printer_id}/status"),
+        ("GET", "/api/v1/webhook/queue"),
+        ("POST", "/api/v1/webhook/printer/{printer_id}/cancel"),
+        ("POST", "/api/v1/webhook/printer/{printer_id}/start"),
+        ("POST", "/api/v1/webhook/printer/{printer_id}/stop"),
+        ("POST", "/api/v1/webhook/queue/add"),
+        # ---- Static / SPA routes (not user data) ----
+        ("GET", "/"),
+        ("GET", "/manifest.json"),
+        ("GET", "/sw-register.js"),
+        ("GET", "/sw.js"),
+        ("GET", "/gcode-viewer/"),
+        ("GET", "/gcode-viewer/{file_path:path}"),
+        # SPA catch-all — serves index.html for client-side routing. No backend data path.
+        ("GET", "/{full_path:path}"),
+        # ---- WebSocket routes ----
+        # /ws performs an inline ``verify_websocket_token`` check before
+        # ``accept()`` (GHSA-r2qv WS fix). The qualname matches one of the
+        # auth-bearing patterns above, so this entry is informational — the
+        # walker recognises the inline check as auth.
+    }
+)
+
+
+def _walk_dependant_qualnames(dependant) -> list[str]:
+    """Flatten the dependant tree to a list of callable qualnames."""
+    names: list[str] = []
+    if dependant is None:
+        return names
+    if dependant.call:
+        names.append(getattr(dependant.call, "__qualname__", "?"))
+    for sub in dependant.dependencies:
+        names.extend(_walk_dependant_qualnames(sub))
+    return names
+
+
+def _has_auth_dep(dependant) -> bool:
+    """True if any callable in the dependant tree matches an auth pattern."""
+    return any(any(p in qn for p in _AUTH_QUALNAME_PATTERNS) for qn in _walk_dependant_qualnames(dependant))
+
+
+def _ws_endpoint_does_inline_token_check(route: APIWebSocketRoute) -> bool:
+    """True if the websocket endpoint reads its source uses ``verify_websocket_token``.
+
+    WebSocket routes don't pass auth via the standard Depends machinery
+    (the WebSocket handshake doesn't carry headers), so the auth check
+    lives inline in the endpoint body. We confirm by inspecting the
+    endpoint function's source text — looking for an actual call to
+    ``verify_websocket_token``. A docstring-only mention would NOT
+    satisfy this check (we look for a call-shaped pattern, not a
+    substring).
+    """
+    import inspect
+
+    try:
+        source = inspect.getsource(route.endpoint)
+    except (OSError, TypeError):
+        return False
+    return bool(re.search(r"\bverify_websocket_token\s*\(", source))
+
+
+@pytest.mark.unit
+def test_routes_have_explicit_auth_deps() -> None:
+    """SEC-AUTH-1 (SECURITY.md): every API route has an auth dep or is in the public allowlist.
+
+    Walks both ``APIRoute`` (HTTP) and ``APIWebSocketRoute`` (WS)
+    objects on the live FastAPI app. For each, asserts that at least
+    one of the resolved Depends in the dependant tree matches an auth-
+    bearing qualname, OR that the (method, path) pair is in the
+    explicit public-route allowlist, OR (for WebSocket routes) that
+    the endpoint performs an inline ``verify_websocket_token`` check.
+
+    Failure means a new route is reachable anonymously without being
+    documented as such — the GHSA-gc24 / GHSA-r2qv shape.
+    """
+    failures: list[str] = []
+
+    for route in app.routes:
+        if isinstance(route, APIRoute):
+            method = sorted(route.methods)[0] if route.methods else "GET"
+            if _has_auth_dep(route.dependant):
+                continue
+            if (method, route.path) in _PUBLIC_ROUTES:
+                continue
+            failures.append(f"  {method:7} {route.path}  → no auth dep, not in _PUBLIC_ROUTES allowlist")
+        elif isinstance(route, APIWebSocketRoute):
+            if _has_auth_dep(route.dependant):
+                continue
+            if _ws_endpoint_does_inline_token_check(route):
+                continue
+            if ("WS", route.path) in _PUBLIC_ROUTES:
+                continue
+            failures.append(f"  WS      {route.path}  → no auth dep, no inline token check, not in _PUBLIC_ROUTES")
+
+    assert not failures, (
+        "Routes without an auth dependency that aren't in the public allowlist. "
+        "Either add a ``Depends(require_*)`` to the route OR add the (method, path) "
+        "to ``_PUBLIC_ROUTES`` with a comment justifying why anonymous access is safe. "
+        "See SECURITY.md rule 1 'Allowlist over denylist' (route allowlist sub-section).\n\n" + "\n".join(failures)
+    )
+
+
+@pytest.mark.unit
+def test_public_routes_allowlist_matches_real_routes() -> None:
+    """Drift-detection: every (method, path) in ``_PUBLIC_ROUTES`` must exist on the app.
+
+    If a route is renamed or removed, the entry for it in the allowlist
+    becomes dead — a residual rubber-stamp that does nothing but leaves
+    the impression that the route still has anonymous access. This test
+    flags those.
+    """
+    real_routes: set[tuple[str, str]] = set()
+    for route in app.routes:
+        if isinstance(route, APIRoute):
+            method = sorted(route.methods)[0] if route.methods else "GET"
+            real_routes.add((method, route.path))
+        elif isinstance(route, APIWebSocketRoute):
+            real_routes.add(("WS", route.path))
+
+    stale = sorted(_PUBLIC_ROUTES - real_routes)
+    assert not stale, (
+        "_PUBLIC_ROUTES contains entries that no longer match any real route. "
+        "Remove these stale entries (the route was renamed, removed, or its method changed).\n\n"
+        + "\n".join(f"  {m:7} {p}" for m, p in stale)
+    )

+ 155 - 0
backend/tests/unit/test_runtime_tracking_pause.py

@@ -0,0 +1,155 @@
+"""Regression tests for the runtime-tracking task (#1521).
+
+The ``runtime_seconds`` counter on each printer feeds hours-based maintenance
+intervals (rod lubrication, belt checks, nozzle cleaning). It was accumulating
+elapsed time whenever ``state.state`` was ``RUNNING`` *or* ``PAUSE``, which
+meant a print paused for hours (e.g. overnight) inflated the maintenance
+clock without any actual mechanical wear. Fix excludes PAUSE; these tests
+pin the new contract.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+
+async def _build_db_with_printer(*, runtime_seconds: int, last_runtime_update: datetime | None):
+    """Spin up an in-memory DB with one active printer in the requested state."""
+    import backend.app.models  # noqa: F401  -- register all models on Base.metadata
+    from backend.app.core.database import Base
+    from backend.app.models.printer import Printer
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+    async with session_maker() as db:
+        db.add(
+            Printer(
+                id=1,
+                name="P1",
+                serial_number="S1",
+                ip_address="1.1.1.1",
+                access_code="x",
+                is_active=True,
+                runtime_seconds=runtime_seconds,
+                last_runtime_update=last_runtime_update,
+            )
+        )
+        await db.commit()
+    return engine, session_maker
+
+
+async def _run_one_iteration(session_maker, state_value: str):
+    """Run a single iteration of track_printer_runtime() against a mocked state.
+
+    Patches ``asyncio.sleep`` to skip the startup wait and cancel after the
+    first loop tick. Patches ``printer_manager.get_status`` to return a fake
+    state with the requested ``.state`` value. Points the module-level
+    ``async_session`` at the test DB so the loop's queries hit it.
+    """
+    from backend.app import main as app_main
+
+    sleep_calls = {"count": 0}
+    real_sleep = asyncio.sleep
+
+    async def fake_sleep(seconds, *args, **kwargs):
+        sleep_calls["count"] += 1
+        # First sleep = the 15s startup wait. Second sleep = end-of-iteration
+        # tick; raise here so the loop exits cleanly via its CancelledError
+        # handler after exactly one work cycle.
+        if sleep_calls["count"] >= 2:
+            raise asyncio.CancelledError()
+        # Yield control to keep the event loop healthy without blocking.
+        await real_sleep(0)
+
+    fake_state = SimpleNamespace(state=state_value, connected=True)
+
+    # The loop's tail-of-iteration sleep is OUTSIDE its try/except, so the
+    # CancelledError raised from fake_sleep propagates out of the function
+    # rather than triggering the inner break — catch it at the test boundary.
+    with (
+        patch.object(app_main, "async_session", session_maker),
+        patch.object(app_main.printer_manager, "get_status", return_value=fake_state),
+        patch.object(app_main.asyncio, "sleep", fake_sleep),
+        pytest.raises(asyncio.CancelledError),
+    ):
+        await app_main.track_printer_runtime()
+
+
+@pytest.mark.asyncio
+async def test_pause_state_does_not_accumulate_runtime():
+    """PAUSE must NOT add to runtime_seconds — paused = no motion = no wear (#1521)."""
+    seeded_runtime = 1000  # 1000s already accumulated
+    seeded_last_update = datetime.now(timezone.utc) - timedelta(seconds=300)  # 5min ago
+    engine, session_maker = await _build_db_with_printer(
+        runtime_seconds=seeded_runtime, last_runtime_update=seeded_last_update
+    )
+
+    await _run_one_iteration(session_maker, state_value="PAUSE")
+
+    from backend.app.models.printer import Printer
+
+    async with session_maker() as db:
+        row = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()
+        # Runtime counter unchanged — the 5 minutes paused contributed nothing.
+        assert row.runtime_seconds == seeded_runtime
+        # last_runtime_update cleared on the non-running branch so the next
+        # transition to RUNNING starts fresh and doesn't back-bill paused time.
+        assert row.last_runtime_update is None
+
+    await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_running_state_still_accumulates_runtime():
+    """RUNNING must continue to accumulate — the bug was scope, not the whole feature."""
+    seeded_runtime = 1000
+    seeded_last_update = datetime.now(timezone.utc) - timedelta(seconds=60)
+    engine, session_maker = await _build_db_with_printer(
+        runtime_seconds=seeded_runtime, last_runtime_update=seeded_last_update
+    )
+
+    await _run_one_iteration(session_maker, state_value="RUNNING")
+
+    from backend.app.models.printer import Printer
+
+    async with session_maker() as db:
+        row = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()
+        # Wall-clock elapsed since seeded_last_update should now be added.
+        # Allow a generous lower bound (≥30s) — actual elapsed depends on
+        # how fast the test runs, but it MUST have grown past the seed.
+        assert row.runtime_seconds > seeded_runtime
+        assert row.runtime_seconds >= seeded_runtime + 30
+        assert row.last_runtime_update is not None
+
+    await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_idle_state_clears_last_update_without_accumulating():
+    """A non-active state (FINISH/IDLE/PREPARE/etc.) must clear last_runtime_update
+    so a later RUNNING transition doesn't retroactively back-bill all the idle time."""
+    seeded_runtime = 1000
+    seeded_last_update = datetime.now(timezone.utc) - timedelta(seconds=3600)  # 1h ago
+    engine, session_maker = await _build_db_with_printer(
+        runtime_seconds=seeded_runtime, last_runtime_update=seeded_last_update
+    )
+
+    await _run_one_iteration(session_maker, state_value="FINISH")
+
+    from backend.app.models.printer import Printer
+
+    async with session_maker() as db:
+        row = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()
+        assert row.runtime_seconds == seeded_runtime  # no accumulation
+        assert row.last_runtime_update is None  # cleared, prevents back-bill
+    await engine.dispose()

+ 124 - 0
backend/tests/unit/test_safe_path.py

@@ -0,0 +1,124 @@
+"""Tests for ``backend.app.utils.safe_path.safe_join_under``.
+
+Cover every escape vector documented in the helper plus the legitimate
+nested-path use case so the helper's behaviour is locked in.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from fastapi import HTTPException
+
+from backend.app.utils.safe_path import (
+    PathTraversalError,
+    assert_under,
+    safe_join_under,
+)
+
+
+@pytest.fixture()
+def library(tmp_path: Path) -> Path:
+    """A real on-disk directory that mimics the "trusted parent" role."""
+    lib = tmp_path / "library"
+    lib.mkdir()
+    return lib
+
+
+class TestSafeJoinUnder:
+    def test_simple_filename_is_joined(self, library: Path):
+        result = safe_join_under(library, "model.3mf")
+        assert result == (library / "model.3mf").resolve()
+
+    def test_nested_path_components_are_joined(self, library: Path):
+        result = safe_join_under(library, "myfolder", "sub", "file.3mf")
+        assert result == (library / "myfolder" / "sub" / "file.3mf").resolve()
+
+    def test_absolute_path_rejected(self, library: Path):
+        # The exact shape that produced the original CVE — ``Path("/lib") / "/etc/passwd"``
+        # collapses to ``Path("/etc/passwd")`` in Python's pathlib.
+        with pytest.raises(HTTPException) as exc:
+            safe_join_under(library, "/etc/passwd")
+        assert exc.value.status_code == 400
+
+    def test_absolute_windows_path_rejected(self, library: Path):
+        with pytest.raises(HTTPException):
+            safe_join_under(library, "\\\\evil\\share\\x")
+
+    def test_parent_traversal_rejected(self, library: Path):
+        with pytest.raises(HTTPException):
+            safe_join_under(library, "..", "etc", "passwd")
+
+    def test_embedded_parent_traversal_rejected(self, library: Path):
+        # ``library/foo/../../etc/passwd`` resolves outside ``library``.
+        with pytest.raises(HTTPException):
+            safe_join_under(library, "foo", "..", "..", "etc", "passwd")
+
+    def test_null_byte_rejected(self, library: Path):
+        with pytest.raises(HTTPException):
+            safe_join_under(library, "evil\x00.3mf")
+
+    def test_empty_string_part_rejected(self, library: Path):
+        with pytest.raises(HTTPException):
+            safe_join_under(library, "")
+
+    def test_no_parts_rejected(self, library: Path):
+        with pytest.raises(HTTPException):
+            safe_join_under(library)
+
+    def test_non_string_part_rejected(self, library: Path):
+        with pytest.raises(HTTPException):
+            safe_join_under(library, 42)  # type: ignore[arg-type]
+
+    def test_http_false_raises_path_traversal_error(self, library: Path):
+        with pytest.raises(PathTraversalError):
+            safe_join_under(library, "/etc/passwd", http=False)
+
+    def test_http_false_allows_clean_join(self, library: Path):
+        result = safe_join_under(library, "ok.txt", http=False)
+        assert result == (library / "ok.txt").resolve()
+
+    def test_returned_path_is_resolved(self, library: Path):
+        # The helper returns a resolved path so callers don't need to do it
+        # themselves — every downstream is_relative_to/parent check assumes
+        # a canonical form.
+        result = safe_join_under(library, "x.txt")
+        assert result == result.resolve()
+
+
+class TestAssertUnder:
+    def test_inside_passes(self, library: Path):
+        candidate = library / "x" / "y" / "z.txt"
+        out = assert_under(library, candidate)
+        assert out == candidate.resolve()
+
+    def test_outside_rejects(self, library: Path, tmp_path: Path):
+        outside = tmp_path / "elsewhere" / "evil.txt"
+        with pytest.raises(HTTPException):
+            assert_under(library, outside)
+
+    def test_outside_raises_path_traversal_error_with_http_false(self, library: Path, tmp_path: Path):
+        outside = tmp_path / "elsewhere" / "evil.txt"
+        with pytest.raises(PathTraversalError):
+            assert_under(library, outside, http=False)
+
+
+class TestPocReproducer:
+    """The exact attacker payload from the advisory.
+
+    A directly attacker-controlled folder name pointing at a venv's
+    site-packages directory used to land a ``.pth`` file on disk. With the
+    helper in place the join now raises before any write.
+    """
+
+    def test_advisory_poc_target_dir_rejected(self, library: Path):
+        # Verbatim shape from the advisory POC.
+        target_dir = "BAMBUDDY_BASE_DIR/bambuddy/venv/lib/python3.14/site-packages"
+        # Leading slash → absolute → rejected up-front.
+        with pytest.raises(HTTPException):
+            safe_join_under(library, "/" + target_dir)
+        # No leading slash but with ``..`` traversal embedded in the
+        # follow-up file path — also rejected.
+        with pytest.raises(HTTPException):
+            safe_join_under(library, "innocent", "..", "..", "evil.pth")

+ 89 - 0
backend/tests/unit/test_slicer_presets.py

@@ -286,6 +286,60 @@ class TestFetchCloudPresets:
         assert first["printer"][0].name == "OldAccountX1C"
         assert second["printer"][0].name == "NewAccountX1C"
 
+    @pytest.mark.asyncio
+    async def test_refresh_bypasses_cloud_cache(self):
+        """``refresh=True`` must skip an otherwise-warm cache entry and hit
+        Bambu Cloud again — wiring for the SliceModal's Refresh button so a
+        user who deletes a cloud preset in Bambu Studio / Handy doesn't have
+        to wait for the 5-minute TTL to expire (#1581)."""
+        sp._cloud_cache.clear()
+        cloud_mock = MagicMock()
+        cloud_mock.set_token = MagicMock()
+        cloud_mock.get_slicer_settings = AsyncMock(
+            return_value={
+                "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
+                "print": {"private": [], "public": []},
+                "filament": {"private": [], "public": []},
+            }
+        )
+        cloud_mock.close = AsyncMock()
+        user = _user_with_cloud_auth(user_id=99)
+        with (
+            patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
+            patch.object(sp, "BambuCloudService", return_value=cloud_mock),
+        ):
+            await sp._fetch_cloud_presets(MagicMock(), user)
+            # Without refresh, the second call hits cache (covered by
+            # test_cache_hit_skips_cloud_call). With refresh=True it MUST
+            # re-fetch.
+            await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
+        assert cloud_mock.get_slicer_settings.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_refresh_writes_back_to_cache(self):
+        """A refresh call must still update the cache so a subsequent normal
+        call doesn't re-hit the cloud immediately afterwards."""
+        sp._cloud_cache.clear()
+        cloud_mock = MagicMock()
+        cloud_mock.set_token = MagicMock()
+        cloud_mock.get_slicer_settings = AsyncMock(
+            return_value={
+                "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
+                "print": {"private": [], "public": []},
+                "filament": {"private": [], "public": []},
+            }
+        )
+        cloud_mock.close = AsyncMock()
+        user = _user_with_cloud_auth(user_id=101)
+        with (
+            patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
+            patch.object(sp, "BambuCloudService", return_value=cloud_mock),
+        ):
+            await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
+            await sp._fetch_cloud_presets(MagicMock(), user)
+        # Two calls — first refresh, second a normal cache hit.
+        assert cloud_mock.get_slicer_settings.await_count == 1
+
 
 class TestFetchBundledPresets:
     """Standard tier reaches out to the slicer-api sidecar; tolerate the
@@ -356,6 +410,41 @@ class TestFetchBundledPresets:
             slots = await sp._fetch_bundled_presets(MagicMock())
         assert slots["printer"][0].name == "Cached"
 
+    @pytest.mark.asyncio
+    async def test_refresh_bypasses_bundled_cache(self):
+        """``refresh=True`` must re-hit the sidecar even when the in-process
+        cache is warm — paired with the cloud-cache refresh, this is what
+        powers the SliceModal's Refresh button (#1581)."""
+        sp._bundled_cache = (
+            time.monotonic(),
+            {
+                "printer": [UnifiedPreset(id="Stale", name="Stale", source="standard")],
+                "process": [],
+                "filament": [],
+            },
+        )
+        svc_mock = MagicMock()
+        svc_mock.list_bundled_profiles = AsyncMock(
+            return_value={
+                "printer": [{"name": "Fresh", "base_id": None}],
+                "process": [],
+                "filament": [],
+            }
+        )
+        svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
+        svc_mock.__aexit__ = AsyncMock(return_value=False)
+        with (
+            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
+            patch.object(sp, "SlicerApiService", return_value=svc_mock),
+        ):
+            slots = await sp._fetch_bundled_presets(MagicMock(), refresh=True)
+        svc_mock.list_bundled_profiles.assert_awaited_once()
+        assert [p.name for p in slots["printer"]] == ["Fresh"]
+        # The fresh result must also be written back to the cache so a
+        # subsequent normal (non-refresh) call doesn't re-hit the sidecar.
+        assert sp._bundled_cache is not None
+        assert [p.name for p in sp._bundled_cache[1]["printer"]] == ["Fresh"]
+
 
 class TestResolveSlicerApiUrl:
     """`_resolve_slicer_api_url` must respect the user's `preferred_slicer`

+ 152 - 0
backend/tests/unit/test_usage_tracker.py

@@ -1753,6 +1753,158 @@ class TestMqttMappingIntegration:
         assert results[0]["tray_id"] == 2  # From print_cmd mapping, not MQTT
 
 
+class TestPositionBasedFallbackEmptyAmsSlot:
+    """Position-based mapping fallback (#1607): when no explicit mapping is
+    available, the slicer's Nth filament must map to the Nth *loaded* AMS tray
+    (skipping empty slots), not the Nth physical slot position. BambuStudio /
+    OrcaSlicer compact their filament-assignment UI by hiding unloaded AMS
+    slots, so the 3MF slot list is dense even when the AMS itself has gaps."""
+
+    @pytest.mark.asyncio
+    async def test_external_routed_correctly_when_ams_has_empty_middle_slot(self):
+        """Reporter's scenario: AMS trays 0-2 loaded, tray 3 empty, external
+        loaded. Slicer emits 4 filaments — slot 4 = external. Without the fix
+        the position-based fallback maps slot 4 to the empty AMS tray 3
+        (since `available_trays = [0, 1, 2, 3, 254]`) and external usage is
+        silently dropped because no spool is assigned to AMS0-T3.
+        After the fix, empty AMS slots are filtered (tray_type is empty) so
+        `available_trays = [0, 1, 2, 254]` and slot 4 correctly resolves to
+        the external (global tray 254 → AMS255-T0)."""
+        # Spool fed via external (vt_tray 254 → AMS255-T0)
+        spool = _make_spool(spool_id=42, label_weight=1000)
+        assignment = _make_assignment(spool_id=42, ams_id=255, tray_id=0)
+        archive = _make_archive(archive_id=70)
+
+        # db: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # AMS reports 4 physical tray slots but slot 3 has no spool (empty
+        # tray_type); external spool is loaded in vt_tray.
+        # No `mapping` field on the state — forces fallback through path 5.
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA"},
+                            {"id": 1, "tray_type": "PETG"},
+                            {"id": 2, "tray_type": "ABS"},
+                            {"id": 3, "tray_type": ""},  # empty slot
+                        ],
+                    }
+                ],
+                "vt_tray": [{"id": 254, "tray_type": "PLA"}],
+            },
+            progress=100,
+            layer_num=50,
+            tray_now=254,
+            tray_change_log=[],
+        )
+
+        # 3MF has 4 dense filament slots — slot 4 is the external. Only slot 4
+        # has weight (other slots came from AMS spools handled separately).
+        filament_usage = [{"slot_id": 4, "used_g": 12.3, "type": "PLA", "color": "#00AABB"}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=70,
+                status="completed",
+                print_name="External + AMS print",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        # The external spool was charged, NOT the empty AMS slot.
+        assert results[0]["spool_id"] == 42
+        assert results[0]["ams_id"] == 255
+        assert results[0]["tray_id"] == 0
+        assert results[0]["weight_used"] == 12.3
+        assert (255, 0) in handled_trays
+        # Critical assertion: AMS0-T3 (the empty slot) was NOT charged.
+        assert (0, 3) not in handled_trays
+
+    @pytest.mark.asyncio
+    async def test_dense_ams_unchanged_no_empty_slots(self):
+        """Sanity check: when every AMS slot is loaded, the position-based
+        fallback still works for the slicer's external = last slot case."""
+        spool = _make_spool(spool_id=99, label_weight=1000)
+        assignment = _make_assignment(spool_id=99, ams_id=255, tray_id=0)
+        archive = _make_archive(archive_id=71)
+
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA"},
+                            {"id": 1, "tray_type": "PETG"},
+                            {"id": 2, "tray_type": "ABS"},
+                            {"id": 3, "tray_type": "TPU"},
+                        ],
+                    }
+                ],
+                "vt_tray": [{"id": 254, "tray_type": "PLA"}],
+            },
+            progress=100,
+            layer_num=50,
+            tray_now=254,
+            tray_change_log=[],
+        )
+
+        # 5 filaments, slot 5 = external. available_trays = [0,1,2,3,254] →
+        # slot_id=5 → available_trays[4] = 254.
+        filament_usage = [{"slot_id": 5, "used_g": 7.5, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=71,
+                status="completed",
+                print_name="Dense AMS + external",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 99
+        assert results[0]["ams_id"] == 255
+        assert results[0]["tray_id"] == 0
+
+
 class TestNotificationVariables:
     """Tests for filament_details formatting in notifications."""
 

+ 76 - 0
backend/tests/unit/test_vp_certificate_rotation.py

@@ -0,0 +1,76 @@
+"""Tests for CertificateService.ensure_certificates' CA-rotation guard.
+
+When the shared CA is regenerated (e.g. its expiry crossed
+``CA_EXPIRY_THRESHOLD_DAYS``), any per-VP printer certificate that was
+signed by the OLD CA becomes orphaned: it still exists on disk and the
+old fallback ``cert_path.exists()`` check would happily reuse it. A
+slicer that imported the NEW CA then fails the TLS handshake because
+the printer cert's issuer doesn't match anything in its trust store.
+
+``_cert_matches_current_ca`` is the guard. It compares the on-disk
+printer cert's issuer against the on-disk CA cert's subject; on
+mismatch ``ensure_certificates`` regenerates the per-VP cert under the
+current CA.
+"""
+
+from backend.app.services.virtual_printer.certificate import CertificateService
+
+
+def test_ensure_certificates_reuses_cert_when_issuer_matches_ca(tmp_path):
+    """Happy path: a freshly-generated CA + per-VP cert pair shares
+    issuer/subject. ``ensure_certificates`` reads them back without
+    regenerating."""
+    svc = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
+
+    # First call: generates the CA + per-VP cert from scratch.
+    first_cert, first_key = svc.ensure_certificates()
+    first_cert_bytes = first_cert.read_bytes()
+
+    # Second call: cert + CA exist and the issuer matches. Should reuse.
+    second_cert, _ = svc.ensure_certificates()
+    assert second_cert.read_bytes() == first_cert_bytes
+
+
+def test_ensure_certificates_regenerates_when_ca_rotated(tmp_path):
+    """CA rotation scenario: the CA file is replaced with a different one
+    (e.g. previous expired and was regenerated). The per-VP cert on disk
+    was signed by the old CA, so its issuer no longer matches the new CA's
+    subject. ``ensure_certificates`` must regenerate the per-VP cert."""
+    # Build the first pair.
+    svc1 = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
+    orig_cert_bytes = svc1.ensure_certificates()[0].read_bytes()
+    orig_ca_bytes = svc1.ca_cert_path.read_bytes()
+
+    # Simulate CA rotation: build a SECOND CA in a different dir, then
+    # swap that CA's files into the original CA path. The per-VP cert
+    # still on disk was signed by the original CA — issuer mismatch now.
+    rotated_dir = tmp_path / "rotated"
+    rotated_dir.mkdir()
+    svc_rotated = CertificateService(cert_dir=rotated_dir, serial="01P00A391800002")
+    svc_rotated.ensure_certificates()
+
+    # Overwrite the original CA on disk with the rotated one.
+    svc1.ca_cert_path.write_bytes(svc_rotated.ca_cert_path.read_bytes())
+    svc1.ca_key_path.write_bytes(svc_rotated.ca_key_path.read_bytes())
+    assert svc1.ca_cert_path.read_bytes() != orig_ca_bytes  # confirm rotation
+
+    # Build a fresh service against the rotated CA, then ensure_certificates
+    # should detect the mismatch and regenerate the per-VP cert.
+    svc2 = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
+    new_cert, _ = svc2.ensure_certificates()
+    new_cert_bytes = new_cert.read_bytes()
+
+    # New per-VP cert must differ from the old (signed by a different CA now).
+    assert new_cert_bytes != orig_cert_bytes
+
+
+def test_cert_matches_current_ca_returns_false_when_no_ca(tmp_path):
+    """If the CA file is missing entirely, the match check must return
+    False so ``ensure_certificates`` falls through to ``generate_certificates``
+    instead of returning a per-VP cert that nothing can validate."""
+    svc = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
+    # Write a per-VP cert without a CA.
+    svc.cert_path.write_bytes(b"fake cert content")
+    svc.key_path.write_bytes(b"fake key content")
+    # No bbl_ca.crt on disk → match fails safely.
+    assert svc._cert_matches_current_ca() is False

+ 137 - 0
backend/tests/unit/test_vp_delete_cleanup.py

@@ -0,0 +1,137 @@
+"""Tests for DELETE /virtual-printers/{vp_id} orphan cleanup.
+
+Before the fix, deleting a VP only stopped the running instance and
+removed the row. The on-disk ``base_dir/uploads/<vp_id>/`` directory
+lingered, and any ``PendingUpload`` rows that pointed into it remained
+in ``pending`` status — showing up as phantom entries in
+``/pending-uploads/``. The route now (a) marks those rows as
+``discarded`` and (b) ``shutil.rmtree``s the upload_dir after the DB
+commit succeeds.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.api.routes.virtual_printers import delete_virtual_printer
+
+
+@pytest.mark.asyncio
+async def test_delete_vp_marks_orphan_pending_uploads_discarded(tmp_path):
+    """A VP with PendingUpload rows pointing at its upload_dir: after
+    DELETE, those rows must be flipped to ``discarded`` and the on-disk
+    directory must be gone."""
+    vp_id = 77
+    upload_dir = tmp_path / "uploads" / str(vp_id)
+    upload_dir.mkdir(parents=True)
+    (upload_dir / "stale.3mf").write_bytes(b"orphaned content")
+
+    # Build PendingUpload-like mocks. The route mutates `.status`.
+    pending_a = MagicMock()
+    pending_a.file_path = str(upload_dir / "stale.3mf")
+    pending_a.status = "pending"
+    pending_b = MagicMock()
+    pending_b.file_path = str(upload_dir / "another.3mf")
+    pending_b.status = "pending"
+
+    # Unrelated PendingUpload that does NOT belong to this VP — must
+    # be left alone.
+    other_pending = MagicMock()
+    other_pending.file_path = str(tmp_path / "uploads" / "99" / "not-mine.3mf")
+    other_pending.status = "pending"
+
+    # Mock VP row.
+    vp_row = MagicMock()
+    vp_row.id = vp_id
+    vp_row.name = "DeleteMe"
+
+    # Mock DB session with the route's two .execute() calls + flush + commit.
+    select_calls = {"i": 0}
+
+    async def fake_execute(query):  # noqa: ARG001
+        """Return the VP row on the first call (vp lookup) and the
+        in-range PendingUpload rows on the second call (orphan query).
+        Third call is the DELETE which doesn't need a result."""
+        select_calls["i"] += 1
+        result = MagicMock()
+        if select_calls["i"] == 1:
+            result.scalar_one_or_none = MagicMock(return_value=vp_row)
+        elif select_calls["i"] == 2:
+            scalars = MagicMock()
+            scalars.all = MagicMock(return_value=[pending_a, pending_b])
+            result.scalars = MagicMock(return_value=scalars)
+        return result
+
+    db = AsyncMock()
+    db.execute = fake_execute
+    db.flush = AsyncMock()
+    db.commit = AsyncMock()
+
+    # Mock the manager: remove_instance, _base_dir, sync_from_db.
+    fake_manager = MagicMock()
+    fake_manager.remove_instance = AsyncMock()
+    fake_manager.sync_from_db = AsyncMock()
+    fake_manager._base_dir = tmp_path
+
+    with patch(
+        "backend.app.services.virtual_printer.virtual_printer_manager",
+        fake_manager,
+    ):
+        await delete_virtual_printer(vp_id=vp_id, db=db, _=None)
+
+    # Both in-range PendingUpload rows must be flipped to "discarded".
+    assert pending_a.status == "discarded"
+    assert pending_b.status == "discarded"
+    # The unrelated row was never returned from the query — left alone.
+    assert other_pending.status == "pending"
+
+    # The on-disk upload_dir is gone.
+    assert not upload_dir.exists()
+
+    # The running instance was stopped before the row was removed.
+    fake_manager.remove_instance.assert_awaited_once_with(vp_id)
+
+
+@pytest.mark.asyncio
+async def test_delete_vp_with_no_orphan_uploads_still_succeeds(tmp_path):
+    """A VP with no PendingUpload rows and no upload_dir on disk: the
+    cleanup path must be a clean no-op, not raise."""
+    vp_id = 88
+
+    vp_row = MagicMock()
+    vp_row.id = vp_id
+    vp_row.name = "EmptyDelete"
+
+    select_calls = {"i": 0}
+
+    async def fake_execute(query):  # noqa: ARG001
+        select_calls["i"] += 1
+        result = MagicMock()
+        if select_calls["i"] == 1:
+            result.scalar_one_or_none = MagicMock(return_value=vp_row)
+        elif select_calls["i"] == 2:
+            # No PendingUpload rows match.
+            scalars = MagicMock()
+            scalars.all = MagicMock(return_value=[])
+            result.scalars = MagicMock(return_value=scalars)
+        return result
+
+    db = AsyncMock()
+    db.execute = fake_execute
+    db.flush = AsyncMock()
+    db.commit = AsyncMock()
+
+    fake_manager = MagicMock()
+    fake_manager.remove_instance = AsyncMock()
+    fake_manager.sync_from_db = AsyncMock()
+    fake_manager._base_dir = tmp_path  # no uploads/<vp_id> exists
+
+    with patch(
+        "backend.app.services.virtual_printer.virtual_printer_manager",
+        fake_manager,
+    ):
+        await delete_virtual_printer(vp_id=vp_id, db=db, _=None)
+
+    fake_manager.remove_instance.assert_awaited_once_with(vp_id)
+    # No directory to remove — and we didn't crash trying to.
+    assert not (tmp_path / "uploads" / str(vp_id)).exists()

+ 143 - 0
backend/tests/unit/test_vp_ftp_stor.py

@@ -0,0 +1,143 @@
+"""Tests for the FTPSession.cmd_STOR streaming + size-cap behaviour.
+
+The original cmd_STOR buffered the entire upload in a ``list[bytes]`` and
+called ``write_bytes`` at the end. For multi-GB ``.gcode.3mf`` files this
+peaked at ~2× the file size in RSS (chunks held + the ``b''.join`` of
+them) and could OOM low-memory hosts. The streaming rewrite writes each
+chunk to disk inline (memory bounded at one chunk) and enforces
+``MAX_UPLOAD_BYTES``. These tests pin both behaviours without standing
+up a real TLS/FTP server.
+"""
+
+import asyncio
+import ssl
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from backend.app.services.virtual_printer.ftp_server import MAX_UPLOAD_BYTES, FTPSession
+
+
+def _make_session(tmp_path, *, data_chunks: list[bytes]) -> FTPSession:
+    """Build an FTPSession primed with a pre-fed StreamReader so cmd_STOR
+    can iterate through the chunks without a real TCP connection.
+    """
+    control_writer = MagicMock()
+    control_writer.write = MagicMock()
+    control_writer.drain = AsyncMock()
+    control_writer.get_extra_info = MagicMock(return_value=("192.168.1.99", 12345))
+
+    upload_dir = tmp_path / "uploads"
+    upload_dir.mkdir(parents=True, exist_ok=True)
+
+    session = FTPSession(
+        reader=asyncio.StreamReader(),
+        writer=control_writer,
+        upload_dir=upload_dir,
+        access_code="deadbeef",
+        ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER),
+        on_file_received=None,
+        bind_address="127.0.0.1",
+        vp_name="stor-test",
+    )
+    session.authenticated = True
+
+    data_reader = asyncio.StreamReader()
+    for chunk in data_chunks:
+        data_reader.feed_data(chunk)
+    data_reader.feed_eof()
+    session._data_reader = data_reader
+
+    data_writer = MagicMock()
+    data_writer.close = MagicMock()
+    data_writer.wait_closed = AsyncMock()
+    session._data_writer = data_writer
+
+    session._data_connected.set()
+    session.data_server = None
+
+    return session
+
+
+@pytest.mark.asyncio
+async def test_stor_writes_payload_to_disk(tmp_path):
+    """Happy path: chunks fed to the data reader land in the upload_dir
+    with the right content + the slicer gets 226."""
+    payload = b"X" * (3 * 64 * 1024 + 123)  # 3 chunks + a partial one
+    chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)]
+    session = _make_session(tmp_path, data_chunks=chunks)
+    session.send = AsyncMock()
+
+    await session.cmd_STOR("Untitled.gcode.3mf")
+
+    saved = session.upload_dir / "Untitled.gcode.3mf"
+    assert saved.exists()
+    assert saved.stat().st_size == len(payload)
+    assert saved.read_bytes() == payload
+
+    sent_codes = [args[0][0] for args in session.send.call_args_list]
+    assert 150 in sent_codes  # "Opening data connection"
+    assert 226 in sent_codes  # "Transfer complete"
+
+
+@pytest.mark.asyncio
+async def test_stor_rejects_upload_over_max_upload_bytes(tmp_path, monkeypatch):
+    """A single chunk taking us over the cap must abort with 426 and
+    drop the partially-written file so it doesn't masquerade as a
+    successful upload."""
+    # Lower the cap to 100 KiB so the test doesn't need to allocate
+    # 4 GiB to trigger it. The same logic governs the production cap.
+    monkeypatch.setattr(
+        "backend.app.services.virtual_printer.ftp_server.MAX_UPLOAD_BYTES",
+        100 * 1024,
+    )
+
+    over_cap = b"X" * (200 * 1024)  # 200 KiB > 100 KiB cap
+    session = _make_session(tmp_path, data_chunks=[over_cap])
+    session.send = AsyncMock()
+
+    await session.cmd_STOR("toobig.gcode.3mf")
+
+    # Partial file must be unlinked.
+    assert not (session.upload_dir / "toobig.gcode.3mf").exists()
+    # 426 (transfer failed) sent — not 226.
+    sent_codes = [args[0][0] for args in session.send.call_args_list]
+    assert 426 in sent_codes
+    assert 226 not in sent_codes
+
+
+@pytest.mark.asyncio
+async def test_stor_cleans_up_partial_file_on_read_error(tmp_path):
+    """If the data channel raises mid-transfer (slicer RST, TLS error,
+    timeout, …), the partial file on disk must be removed so the next
+    upload of the same name starts clean and the user doesn't see a
+    truncated file in the upload_dir."""
+    payload = b"X" * 65536  # one full chunk
+    session = _make_session(tmp_path, data_chunks=[payload])
+    session.send = AsyncMock()
+
+    # Inject an OSError on the NEXT read after the first chunk.
+    orig_read = session._data_reader.read
+    state = {"calls": 0}
+
+    async def read_then_error(n):
+        state["calls"] += 1
+        if state["calls"] == 1:
+            return await orig_read(n)
+        raise OSError("simulated connection reset")
+
+    session._data_reader.read = read_then_error  # type: ignore[assignment]
+
+    await session.cmd_STOR("aborted.gcode.3mf")
+
+    # Partial file removed.
+    assert not (session.upload_dir / "aborted.gcode.3mf").exists()
+    sent_codes = [args[0][0] for args in session.send.call_args_list]
+    assert 426 in sent_codes
+
+
+def test_max_upload_bytes_is_at_least_4_gib():
+    """The cap exists to prevent OOM, but should be high enough that
+    legitimate multi-plate .gcode.3mf uploads (~hundreds of MB) succeed
+    without bumping up against it. 4 GiB is the documented floor."""
+    assert MAX_UPLOAD_BYTES >= 4 * 1024 * 1024 * 1024

+ 151 - 0
backend/tests/unit/test_vp_mode_rename_migration.py

@@ -0,0 +1,151 @@
+"""Regression test for the VP mode wire-value rename migration (#1429 follow-up).
+
+The UI buttons "Archive" and "Queue" had always saved the wire values
+`immediate` and `print_queue` — confusing in every support bundle. The
+rename migration in ``run_migrations`` rewrites existing rows to the
+canonical names. This test verifies it on both fresh and legacy schemas
+and confirms it's idempotent so reruns are safe (boot-on-boot).
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch regardless of test env settings."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+    """run_migrations touches multiple tables; the full schema must exist."""
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def engine():
+    from backend.app.core.database import Base
+
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield eng
+    await eng.dispose()
+
+
+@pytest.mark.asyncio
+async def test_legacy_mode_rows_get_canonical_names(engine):
+    """Existing rows with `immediate` / `print_queue` get rewritten to
+    `archive` / `queue` while canonical values and unrelated modes pass
+    through untouched."""
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO virtual_printers (id, name, enabled, mode, serial_suffix, position) VALUES "
+                "(1, 'A', 0, 'immediate', '391800001', 1),"
+                "(2, 'B', 0, 'print_queue', '391800002', 2),"
+                "(3, 'C', 0, 'review', '391800003', 3),"
+                "(4, 'D', 0, 'proxy', '391800004', 4),"
+                "(5, 'E', 0, 'archive', '391800005', 5),"
+                "(6, 'F', 0, 'queue', '391800006', 6)"
+            )
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        result = await conn.execute(text("SELECT id, mode FROM virtual_printers ORDER BY id"))
+        rows = dict(result.fetchall())
+
+    assert rows[1] == "archive"  # immediate → archive
+    assert rows[2] == "queue"  # print_queue → queue
+    assert rows[3] == "review"  # untouched
+    assert rows[4] == "proxy"  # untouched
+    assert rows[5] == "archive"  # already canonical
+    assert rows[6] == "queue"  # already canonical
+
+
+@pytest.mark.asyncio
+async def test_legacy_settings_row_gets_canonical_name(engine):
+    """The legacy single-VP `virtual_printer_mode` setting also gets renamed
+    so the GET response (which feeds the support bundle and the settings
+    page) reads the canonical name."""
+    async with engine.begin() as conn:
+        await conn.execute(text("INSERT INTO settings (key, value) VALUES ('virtual_printer_mode', 'immediate')"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
+        value = result.scalar()
+
+    assert value == "archive"
+
+
+@pytest.mark.asyncio
+async def test_migration_is_idempotent(engine):
+    """Running the migration twice must be a no-op on canonical values —
+    every boot re-runs the migration set."""
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO virtual_printers (id, name, enabled, mode, serial_suffix, position) "
+                "VALUES (1, 'A', 0, 'immediate', '391800001', 1)"
+            )
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+    # Second run on already-canonical values.
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        result = await conn.execute(text("SELECT mode FROM virtual_printers WHERE id = 1"))
+        assert result.scalar() == "archive"

+ 235 - 1
backend/tests/unit/test_vp_mqtt_bridge.py

@@ -7,7 +7,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 
-from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge, _ip_to_uint32_le
+from backend.app.services.virtual_printer.mqtt_bridge import (
+    MQTTBridge,
+    _ip_to_uint32_le,
+    _resolve_host_interface_for_target,
+)
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 
 H2D_SERIAL = "0948BB540200427"
@@ -220,6 +224,95 @@ class TestPushStatusCache:
 
         await bridge.stop()
 
+    @pytest.mark.asyncio
+    async def test_net_info_ip_rewritten_for_unknown_secondary_interface(self):
+        """Regression for #1429: real printers (X1C / H2D Pro) report multiple
+        active interfaces (WiFi + Ethernet) — only ONE matches the IP Bambuddy
+        tracks. The rewrite must catch every non-zero entry, not just the one
+        whose IP equals `_target_ip_uint32_le`, or the slicer's FTP fallback
+        path leaks straight to the real printer."""
+        server = _make_server(bind_address=VP_IP)
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        h2d_le = _ip_to_uint32_le(H2D_IP)
+        # A second IP Bambuddy never saw (e.g. printer's ethernet interface
+        # while Bambuddy talks over wifi).
+        other_le = _ip_to_uint32_le("192.168.99.42")
+        vp_le = _ip_to_uint32_le(VP_IP)
+        payload = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "net": {
+                        "info": [
+                            {"ip": h2d_le, "mask": 0xFFFFFF},
+                            {"ip": other_le, "mask": 0xFFFFFF},
+                            {"ip": 0, "mask": 0},
+                        ]
+                    },
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        assert cached["net"]["info"][0]["ip"] == vp_le
+        assert cached["net"]["info"][1]["ip"] == vp_le  # secondary interface also rewritten
+        assert cached["net"]["info"][2]["ip"] == 0  # placeholder untouched
+
+        await bridge.stop()
+
+    @pytest.mark.asyncio
+    async def test_late_arriving_printer_ip_rewrites_existing_cache(self):
+        """Regression for #1429: if the printer's `ip_address` is empty at
+        first bind (DB row stale, or the client object exists before the
+        first SSDP refresh fills it in), the rewrite stays disabled and the
+        first cached push poisons the cache with the real-printer IP.
+        Once `ip_address` becomes valid, the next refresh tick must (a) arm
+        the encoding and (b) sweep the cached `net.info[].ip` so the slicer
+        sees the rewritten value on its next pull. Without the sweep the
+        sticky-key preservation keeps the poisoned value alive across
+        every subsequent incremental push."""
+        server = _make_server(bind_address=VP_IP)
+        # Bind to a client whose ip_address is empty at start — simulates the
+        # late-arrival path.
+        target = _make_paho_client(ip="")
+        bridge = _make_bridge(server, target)
+        await bridge.start()
+        assert bridge._target_ip_uint32_le is None  # not yet armed
+
+        h2d_le = _ip_to_uint32_le(H2D_IP)
+        vp_le = _ip_to_uint32_le(VP_IP)
+        payload = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}]},
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
+        await asyncio.sleep(0.01)
+
+        # First push landed before encoding was armed → cache holds real IP.
+        cached = bridge.get_latest_print_state()
+        assert cached["net"]["info"][0]["ip"] == h2d_le
+
+        # Printer's IP becomes known. Next refresh tick must self-heal.
+        target.ip_address = H2D_IP
+        bridge._resolve_client()
+
+        cached = bridge.get_latest_print_state()
+        assert cached["net"]["info"][0]["ip"] == vp_le, (
+            "cache must be swept once encoding becomes valid; sticky-key "
+            "preservation would otherwise keep the poisoned IP forever"
+        )
+        assert bridge._target_ip_uint32_le == h2d_le
+
+        await bridge.stop()
+
     @pytest.mark.asyncio
     async def test_request_topic_message_is_ignored(self):
         server = _make_server()
@@ -774,6 +867,48 @@ class TestStatusReportCachedAsBase:
         assert payload["print"]["gcode_state"] == "PREPARE"
         assert payload["print"]["gcode_file"] == "foo.3mf"
 
+    @pytest.mark.asyncio
+    async def test_live_progress_fields_zeroed_in_cached_branch(self):
+        """#1558: when the real target printer is mid-print, the cached
+        push_status carries live values for mc_percent / stg_cur / layer_num /
+        etc. BambuStudio's Send pre-flight reads any of these as "VP busy"
+        even when gcode_state above is forced to IDLE — blocking Send while
+        the target prints. The cached branch must override these to the same
+        idle values the synthetic stub uses.
+        """
+        server = _make_server()
+        bridge = MagicMock()
+        # Real printer mid-print state: gcode_state may be RUNNING upstream,
+        # but the VP's own _gcode_state is IDLE (Send is requesting a
+        # new upload, the VP isn't running anything).
+        bridge.get_latest_print_state.return_value = {
+            "command": "push_status",
+            "msg": 0,
+            "gcode_state": "RUNNING",
+            "mc_print_stage": "2",
+            "mc_percent": 47,
+            "mc_remaining_time": 3600,
+            "stg": [1, 2, 3],
+            "stg_cur": 14,
+            "layer_num": 120,
+            "total_layer_num": 250,
+            "print_error": 0,
+        }
+        server.set_bridge(bridge)
+        published = self._capture_published(server)
+
+        await server._send_status_report(MagicMock())
+        _serial, payload = published[0]
+        # Every live-progress field must reflect "idle / VP isn't busy".
+        assert payload["print"]["mc_print_stage"] == ""
+        assert payload["print"]["mc_percent"] == 0
+        assert payload["print"]["mc_remaining_time"] == 0
+        assert payload["print"]["stg"] == []
+        assert payload["print"]["stg_cur"] == 0
+        assert payload["print"]["layer_num"] == 0
+        assert payload["print"]["total_layer_num"] == 0
+        assert payload["print"]["print_error"] == 0
+
 
 # ---------------------------------------------------------------------------
 # Wire format
@@ -950,3 +1085,102 @@ class TestIpEncoding:
     def test_invalid_ip_raises(self):
         with pytest.raises(ValueError):
             _ip_to_uint32_le("not.an.ip.actually")
+
+
+# ---------------------------------------------------------------------------
+# Auto-resolve fallback for default-config (bind_address = "0.0.0.0")
+# ---------------------------------------------------------------------------
+
+
+class TestBindAddressAutoResolve:
+    """#1429 residual: VPs created without a dedicated bind IP run on
+    `bind_address=0.0.0.0`. The original fix's `_refresh_ip_encoding`
+    early-returned on 0.0.0.0, so the rewrite never armed and `net.info[].ip`
+    kept leaking the real printer IP. Now the bridge auto-resolves a host
+    interface in the printer's subnet and uses that as the VP IP."""
+
+    @pytest.mark.asyncio
+    async def test_rewrite_arms_via_auto_resolved_host_ip(self):
+        """When bind_address is 0.0.0.0, fall back to the host interface in
+        the target printer's subnet and rewrite to that IP."""
+        server = _make_server(bind_address="0.0.0.0")  # nosec B104
+        bridge = _make_bridge(server)
+        with patch(
+            "backend.app.services.virtual_printer.mqtt_bridge._resolve_host_interface_for_target",
+            return_value=VP_IP,
+        ):
+            await bridge.start()
+
+            h2d_le = _ip_to_uint32_le(H2D_IP)
+            vp_le = _ip_to_uint32_le(VP_IP)
+            payload = json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}]},
+                    }
+                }
+            ).encode()
+            bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
+            await asyncio.sleep(0.01)
+
+            cached = bridge.get_latest_print_state()
+            assert cached["net"]["info"][0]["ip"] == vp_le
+            assert bridge._vp_ip_uint32_le == vp_le
+
+            await bridge.stop()
+
+    @pytest.mark.asyncio
+    async def test_rewrite_disabled_when_no_matching_host_interface(self):
+        """If no host interface shares a subnet with the printer, the bridge
+        cannot pick a sensible VP IP — leave encoding unarmed and let the
+        push through unrewritten (no crash, no wrong rewrite)."""
+        server = _make_server(bind_address="")
+        bridge = _make_bridge(server)
+        with patch(
+            "backend.app.services.virtual_printer.mqtt_bridge._resolve_host_interface_for_target",
+            return_value=None,
+        ):
+            await bridge.start()
+
+            h2d_le = _ip_to_uint32_le(H2D_IP)
+            payload = json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}]},
+                    }
+                }
+            ).encode()
+            bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
+            await asyncio.sleep(0.01)
+
+            assert bridge._vp_ip_uint32_le is None
+            assert bridge._target_ip_uint32_le is None
+
+            await bridge.stop()
+
+    @pytest.mark.asyncio
+    async def test_explicit_bind_ip_takes_precedence_over_auto_resolve(self):
+        """Auto-resolve only kicks in when bind_address is empty/0.0.0.0; an
+        explicitly-set bind IP must be used verbatim even if there's also a
+        same-subnet host interface."""
+        server = _make_server(bind_address=VP_IP)
+        bridge = _make_bridge(server)
+        # Auto-resolver would have returned a DIFFERENT IP — we must not use it.
+        with patch(
+            "backend.app.services.virtual_printer.mqtt_bridge._resolve_host_interface_for_target",
+            return_value="10.99.99.99",
+        ):
+            await bridge.start()
+            assert bridge._vp_ip_uint32_le == _ip_to_uint32_le(VP_IP)
+            await bridge.stop()
+
+    def test_resolve_helper_returns_none_for_unreachable_target(self):
+        """The helper itself must be defensive — if `find_interface_for_ip`
+        raises or returns None, we get None (no crash)."""
+        with patch(
+            "backend.app.services.network_utils.find_interface_for_ip",
+            return_value=None,
+        ):
+            assert _resolve_host_interface_for_target("203.0.113.1") is None

+ 372 - 0
backend/tests/unit/test_vp_mqtt_server.py

@@ -186,3 +186,375 @@ class TestClientSerialLifecycle:
         # stop() is async but we only need to cover the clear() path; run a minimal version
         asyncio.run(server.stop())
         assert server._client_serials == {}
+
+
+def _build_connect_payload(
+    keep_alive: int,
+    access_code: str = "deadbeef",
+    username: str = "bblp",
+    client_id: str = "orca",
+) -> bytes:
+    """Build an MQTT CONNECT variable-header + payload (without the fixed header).
+
+    Layout matches the parser in `_handle_connect`:
+    proto_name_len(2) + "MQTT"(4) + level(1) + flags(1) + keepalive(2) +
+    client_id_len(2) + client_id + username_len(2) + username +
+    password_len(2) + password.
+    """
+    proto = b"MQTT"
+    parts = bytearray()
+    parts += len(proto).to_bytes(2, "big") + proto
+    parts += bytes([0x04, 0xC2])  # protocol level 4 (MQTT 3.1.1), flags: user+pass+clean
+    parts += keep_alive.to_bytes(2, "big")
+    cid = client_id.encode("utf-8")
+    parts += len(cid).to_bytes(2, "big") + cid
+    user = username.encode("utf-8")
+    parts += len(user).to_bytes(2, "big") + user
+    pw = access_code.encode("utf-8")
+    parts += len(pw).to_bytes(2, "big") + pw
+    return bytes(parts)
+
+
+class TestHandleConnectKeepalive:
+    """`_handle_connect` must return the negotiated keepalive (#1548).
+
+    Pre-fix, the parser ignored this field and the read loop fell back to
+    a hardcoded 60 s timeout, closing OrcaSlicer's idle MQTT connection
+    after exactly 60 s instead of waiting 1.5× the client-negotiated
+    keepalive as MQTT spec §4.4 requires.
+    """
+
+    def test_returns_negotiated_keepalive_on_auth_success(self):
+        server = _make_server()
+        writer = MagicMock()
+        writer.write = MagicMock()
+        writer.drain = AsyncMock()
+        # Also stub status-report writes triggered post-auth
+        payload = _build_connect_payload(keep_alive=120)
+
+        result = asyncio.run(server._handle_connect(payload, writer))
+
+        assert result == (True, 120)
+
+    def test_returns_zero_keepalive_for_no_keepalive_clients(self):
+        """`keep_alive == 0` in CONNECT means the client opted out per spec
+        §3.1.2.10 — server must report it back so the read loop can drop
+        the timeout entirely."""
+        server = _make_server()
+        writer = MagicMock()
+        writer.write = MagicMock()
+        writer.drain = AsyncMock()
+        payload = _build_connect_payload(keep_alive=0)
+
+        result = asyncio.run(server._handle_connect(payload, writer))
+
+        assert result == (True, 0)
+
+    def test_returns_false_with_zero_keepalive_on_auth_failure(self):
+        """Bad password path still returns the tuple shape so the caller's
+        unpack doesn't break."""
+        server = _make_server()
+        writer = MagicMock()
+        writer.write = MagicMock()
+        writer.drain = AsyncMock()
+        payload = _build_connect_payload(keep_alive=60, access_code="wrong")
+
+        result = asyncio.run(server._handle_connect(payload, writer))
+
+        assert result == (False, 0)
+
+    def test_returns_false_with_zero_keepalive_on_parse_error(self):
+        """Malformed CONNECT (e.g. truncated) must not crash and must
+        still hand a tuple back to the caller."""
+        server = _make_server()
+        writer = MagicMock()
+        writer.write = MagicMock()
+        writer.drain = AsyncMock()
+        # 3 bytes is far shorter than even the protocol-name prefix needs.
+        result = asyncio.run(server._handle_connect(b"\x00\x04MQ", writer))
+
+        assert result == (False, 0)
+
+
+class TestHandleClientHonoursKeepalive:
+    """`_handle_client` must use the client-negotiated keepalive for its
+    read-loop timeout, not the hardcoded 60 s default (#1548)."""
+
+    @pytest.mark.asyncio
+    async def test_idle_client_kept_alive_beyond_60s_when_keepalive_is_long(self):
+        """The literal #1548 repro: a client negotiates keepalive=180 and
+        then sits idle. Pre-fix the read loop closed the connection after
+        60 s (hardcoded). Post-fix the timeout is 1.5×180=270 s — so the
+        connection is still open after the original 60 s boundary."""
+        server = _make_server()
+        server._running = True
+
+        reader = asyncio.StreamReader()
+        # Feed CONNECT (with fixed header byte 0x10 + remaining length)
+        connect_payload = _build_connect_payload(keep_alive=180)
+        rl = len(connect_payload)
+        # MQTT remaining-length encoding for values <128 is a single byte.
+        assert rl < 128
+        reader.feed_data(bytes([0x10, rl]) + connect_payload)
+        # No further data — client goes idle.
+
+        writer = MagicMock()
+        writer.write = MagicMock()
+        writer.drain = AsyncMock()
+        writer.close = MagicMock()
+        writer.wait_closed = AsyncMock()
+        writer.get_extra_info = MagicMock(return_value=("1.2.3.4", 12345))
+
+        # Patch the post-auth status-report send so the handler doesn't
+        # depend on a real serial/payload path.
+        server._send_status_report = AsyncMock()
+
+        task = asyncio.create_task(server._handle_client(reader, writer))
+
+        # Wait past the old hardcoded 60 s threshold by a margin. Real-time
+        # 60 s would be far too slow for a unit test — drive simulated time
+        # by yielding repeatedly. asyncio.wait_for with a real wall-clock
+        # delay would actually consume 60 s of test time, so instead we
+        # patch the timeout to a small value and assert the timeout chosen
+        # by the loop matches our expectation.
+        # Approach: let the task progress past the CONNECT, then cancel.
+        await asyncio.sleep(0.1)  # give the loop a chance to process CONNECT
+        # The post-auth read should now be waiting on reader with the
+        # negotiated keepalive. We can't observe the timeout directly, so
+        # we just verify the connection wasn't closed by inspecting close().
+        assert not writer.close.called, "connection should still be open after CONNECT"
+        # Cancel cleanly
+        task.cancel()
+        try:
+            await task
+        except asyncio.CancelledError:
+            pass
+
+    @pytest.mark.asyncio
+    async def test_idle_client_closed_after_one_and_a_half_times_keepalive(self):
+        """Tight verification: keepalive=2 must close the connection in
+        ~3 s (1.5×) of idle, well above the noise floor for an async test."""
+        server = _make_server()
+        server._running = True
+
+        reader = asyncio.StreamReader()
+        connect_payload = _build_connect_payload(keep_alive=2)
+        rl = len(connect_payload)
+        assert rl < 128
+        reader.feed_data(bytes([0x10, rl]) + connect_payload)
+
+        writer = MagicMock()
+        writer.write = MagicMock()
+        writer.drain = AsyncMock()
+        writer.close = MagicMock()
+        writer.wait_closed = AsyncMock()
+        writer.get_extra_info = MagicMock(return_value=("1.2.3.4", 12345))
+        server._send_status_report = AsyncMock()
+
+        start = asyncio.get_event_loop().time()
+        await server._handle_client(reader, writer)
+        elapsed = asyncio.get_event_loop().time() - start
+
+        # 1.5×2s = 3s expected. Allow ±1s slop for the read of CONNECT
+        # itself + scheduler jitter on a loaded CI box.
+        assert 2.0 < elapsed < 4.5, f"expected ~3s timeout, got {elapsed:.2f}s"
+
+    @pytest.mark.asyncio
+    async def test_pingreq_resets_idle_timeout(self):
+        """A PINGREQ within the keepalive window must keep the connection
+        open — the per-packet read timeout is restarted on every byte
+        delivered, so the next idle window is measured from the PINGREQ."""
+        server = _make_server()
+        server._running = True
+
+        reader = asyncio.StreamReader()
+        connect_payload = _build_connect_payload(keep_alive=2)
+        rl = len(connect_payload)
+        assert rl < 128
+        reader.feed_data(bytes([0x10, rl]) + connect_payload)
+
+        writer = MagicMock()
+        writer.write = MagicMock()
+        writer.drain = AsyncMock()
+        writer.close = MagicMock()
+        writer.wait_closed = AsyncMock()
+        writer.get_extra_info = MagicMock(return_value=("1.2.3.4", 12345))
+        server._send_status_report = AsyncMock()
+
+        async def _drive():
+            # Feed a PINGREQ (0xC0 0x00 — type 12 with zero remaining length)
+            # at 2s, which is 1s *before* the would-be timeout, and a
+            # DISCONNECT at 2.5s so the test exits deterministically.
+            await asyncio.sleep(2.0)
+            reader.feed_data(bytes([0xC0, 0x00]))
+            await asyncio.sleep(0.5)
+            reader.feed_data(bytes([0xE0, 0x00]))  # DISCONNECT
+
+        driver = asyncio.create_task(_drive())
+        start = asyncio.get_event_loop().time()
+        await server._handle_client(reader, writer)
+        elapsed = asyncio.get_event_loop().time() - start
+        await driver  # ensure no orphan task
+
+        # Exit was via DISCONNECT at ~2.5s, NOT a 3s keepalive timeout.
+        # Allow generous slop.
+        assert 2.0 < elapsed < 3.0, f"expected exit on DISCONNECT near 2.5s, got {elapsed:.2f}s"
+
+
+class TestAuthRateLimit:
+    """Per-IP rate-limiting of MQTT CONNECT auth attempts.
+
+    Bambuddy's VP exposes an 8-char access code via the slicer-facing MQTT
+    server. Without a rate-limit the code is brute-forceable by anyone who
+    can reach the VP's bind IP (LAN or VPN). The limiter records each
+    failed auth attempt per source IP and rejects further CONNECTs from
+    that IP once the per-window threshold is crossed, then auto-recovers
+    when the window expires. Verified here against the production
+    constants imported from the module.
+    """
+
+    @pytest.fixture
+    def server(self):
+        from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
+
+        return _make_server(serial="01P00A391800002")
+
+    def test_under_limit_attempts_are_allowed(self, server):
+        from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS
+
+        ip = "192.168.1.50"
+        # Record (max-1) failures and verify the next attempt is still allowed.
+        for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS - 1):
+            server._record_auth_failure(ip)
+        assert server._is_auth_rate_limited(ip) is False
+
+    def test_exactly_max_attempts_triggers_rate_limit(self, server):
+        from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS
+
+        ip = "192.168.1.50"
+        for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS):
+            server._record_auth_failure(ip)
+        # At exactly the cap, further attempts must be rejected.
+        assert server._is_auth_rate_limited(ip) is True
+
+    def test_window_recovery_clears_old_failures(self, server):
+        """A burst of failures older than the window must NOT count
+        against the IP — the limiter is sliding, not cumulative."""
+        import time as _time
+
+        from backend.app.services.virtual_printer.mqtt_server import (
+            _AUTH_RATE_LIMIT_MAX_ATTEMPTS,
+            _AUTH_RATE_LIMIT_WINDOW_SECONDS,
+        )
+
+        ip = "192.168.1.50"
+        # Inject stale timestamps directly — older than the window means the
+        # limiter should drop them on the next probe.
+        stale = _time.monotonic() - _AUTH_RATE_LIMIT_WINDOW_SECONDS - 1.0
+        server._auth_failures[ip] = [stale] * _AUTH_RATE_LIMIT_MAX_ATTEMPTS
+        # All recorded failures are outside the window — IP is no longer rate-limited.
+        assert server._is_auth_rate_limited(ip) is False
+        # And the dict entry was pruned (empty) instead of leaking forever.
+        assert ip not in server._auth_failures
+
+    def test_multiple_ips_tracked_independently(self, server):
+        from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS
+
+        # One IP exhausts the budget; another IP must still be allowed.
+        for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS):
+            server._record_auth_failure("10.0.0.1")
+        assert server._is_auth_rate_limited("10.0.0.1") is True
+        assert server._is_auth_rate_limited("10.0.0.2") is False
+
+    def test_successful_auth_clears_failure_history(self, server):
+        """A successful auth must wipe the IP's prior-failures stash so the
+        user isn't penalised for typos that they ultimately corrected."""
+        from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS
+
+        ip = "192.168.1.50"
+        # Build up failures one short of the cap.
+        for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS - 1):
+            server._record_auth_failure(ip)
+        # Successful auth must clear them.
+        server._clear_auth_failures(ip)
+        # Now a subsequent failure starts the count over at 1 (well under cap).
+        server._record_auth_failure(ip)
+        assert server._is_auth_rate_limited(ip) is False
+
+
+class TestPendingRequestRouting:
+    """`push_raw_to_clients` routes the printer's response back only to the
+    slicer that originated the request, not to every connected slicer.
+
+    The bridge calls `push_raw_to_clients(topic, payload)` for every
+    response it sees from the real printer. Before the fix, this fanned
+    out to every connected slicer — leaking slicer A's
+    `extrusion_cali_get` response into slicer B's command stream. The
+    fix records `sequence_id → client_id` on the way out and looks it
+    back up on the way in.
+    """
+
+    @pytest.fixture
+    def server(self):
+        return _make_server(serial="01P00A391800003")
+
+    def test_single_slicer_routes_to_that_slicer(self, server):
+        """Sanity check: when one slicer is connected, the response goes
+        to it regardless of whether the seq_id was recorded."""
+        # No recorded request, no slicer seen → returns None (broadcast).
+        assert server._lookup_pending_request_client(b'{"print": {"sequence_id": "999"}}') is None
+
+    def test_record_pending_request_walks_nested_blocks(self, server):
+        """The slicer wraps its sequence_id under whichever subsystem the
+        command targets (`print`, `info`, `system`, …). The helper must
+        find it regardless of which key it's nested under."""
+        server._record_pending_request(
+            {"print": {"command": "extrusion_cali_get", "sequence_id": "42"}},
+            "clientA",
+        )
+        assert server._pending_requests.get("42") == "clientA"
+
+        server._record_pending_request(
+            {"info": {"command": "get_version", "sequence_id": "43"}},
+            "clientB",
+        )
+        assert server._pending_requests.get("43") == "clientB"
+
+    def test_lookup_pops_entry_so_each_response_routes_once(self, server):
+        """Once a response is matched, the pending entry is consumed so
+        a later coincidental sequence_id from a printer-initiated push
+        doesn't mis-route to the original client."""
+        server._record_pending_request({"print": {"sequence_id": "100"}}, "clientA")
+        # First lookup finds it…
+        assert server._lookup_pending_request_client(b'{"print": {"sequence_id": "100"}}') == "clientA"
+        # …and removes it. Second lookup with the same seq returns None
+        # (treated as printer-initiated → broadcast fallback).
+        assert server._lookup_pending_request_client(b'{"print": {"sequence_id": "100"}}') is None
+
+    def test_fifo_eviction_when_cache_fills(self, server):
+        """If a slicer sends many commands without responses (or the
+        responses never arrive), the oldest entries age out so the dict
+        can't grow unbounded."""
+        from backend.app.services.virtual_printer.mqtt_server import _PENDING_REQUEST_MAX_ENTRIES
+
+        # Fill the dict to one over the cap.
+        for i in range(_PENDING_REQUEST_MAX_ENTRIES + 1):
+            server._record_pending_request({"print": {"sequence_id": str(i)}}, "clientA")
+        # The dict is capped — the oldest entry ("0") is gone, the newest is in.
+        assert len(server._pending_requests) <= _PENDING_REQUEST_MAX_ENTRIES
+        assert "0" not in server._pending_requests
+        assert str(_PENDING_REQUEST_MAX_ENTRIES) in server._pending_requests
+
+    def test_response_without_recorded_seq_returns_none_for_broadcast(self, server):
+        """Printer-initiated pushes (push_status etc.) have a sequence_id
+        the bridge never saw recorded. ``_lookup_pending_request_client``
+        must return None so ``push_raw_to_clients`` falls back to fan-out
+        — every slicer expects to receive these unsolicited messages."""
+        # No record for this seq id.
+        assert server._lookup_pending_request_client(b'{"print": {"sequence_id": "777"}}') is None
+
+    def test_malformed_payload_falls_through_to_broadcast(self, server):
+        """A non-JSON / non-dict payload must NOT crash the routing path —
+        return None so the response broadcasts."""
+        assert server._lookup_pending_request_client(b"not valid json") is None
+        assert server._lookup_pending_request_client(b'"a string, not a dict"') is None

+ 205 - 0
backend/tests/unit/test_vp_tls_ciphers.py

@@ -0,0 +1,205 @@
+"""Regression tests for #1610: every slicer-facing VP TLS context must
+explicitly include the plain-RSA AES-GCM cipher suites that real Bambu
+printers (and therefore the BambuStudio / OrcaSlicer client paths) expect.
+
+Real Bambu printers offer only ``AES256-GCM-SHA384`` / ``AES128-GCM-SHA256``
+(plain RSA key exchange) on their TLS endpoints. Slicers built against
+that surface assume the server side will accept those suites. On
+distributions whose OpenSSL ``DEFAULT`` cipher list has been narrowed by a
+system crypto policy (Fedora / RHEL ``update-crypto-policies``, hardened
+Alpine builds), Python's stock ``SSLContext`` ends up offering only
+ECDHE/DHE — no overlap with the slicer's ClientHello, the handshake
+aborts, and the slicer reports a generic ``code=-1`` connect error.
+
+The #620 patch fixed this for the printer-facing CLIENT context in
+``tcp_proxy.py::_create_client_ssl_context``. #1610 audited the remaining
+slicer-facing surface and applied the same explicit cipher pin to every
+context that accepts a slicer connection:
+
+* ``bind_server.py::_create_tls_context``      — port 3002 (bind/detect)
+* ``mqtt_server.py`` (inline in ``start``)     — port 8883 (MQTT-over-TLS)
+* ``tcp_proxy.py::_create_server_ssl_context`` — proxy-mode 3002
+* ``ftp_server.py`` (inline in ``start``)      — port 990 (FTPS)
+
+If any of these regress to a context that no longer offers
+``AES256-GCM-SHA384`` / ``AES128-GCM-SHA256``, users on hardened distros
+will hit the #1610 / #620 cipher-mismatch failure mode.
+"""
+
+import ssl
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.services.virtual_printer.bind_server import BindServer
+from backend.app.services.virtual_printer.certificate import CertificateService
+from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
+from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
+from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+REQUIRED_CIPHERS = ("AES256-GCM-SHA384", "AES128-GCM-SHA256")
+
+
+def _cert_pair(tmp_path: Path) -> tuple[Path, Path]:
+    """Generate a real self-signed CA + per-VP cert pair via the production
+    CertificateService. Returns ``(cert_path, key_path)`` suitable for
+    ``load_cert_chain`` calls inside the VP services under test."""
+    svc = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
+    return svc.ensure_certificates()
+
+
+def _assert_required_ciphers(ctx: ssl.SSLContext, where: str) -> None:
+    """Fail with a useful diagnostic if either required cipher is missing."""
+    offered = {c["name"] for c in ctx.get_ciphers()}
+    missing = [c for c in REQUIRED_CIPHERS if c not in offered]
+    assert not missing, (
+        f"{where}: missing plain-RSA AES-GCM cipher(s) {missing}. "
+        f"Real Bambu printers / slicers require these on the slicer-facing "
+        f"TLS surface — see #1610. Offered ciphers: {sorted(offered)}"
+    )
+
+
+class TestBindServerTlsCiphers:
+    def test_create_tls_context_offers_plain_rsa_aes_gcm(self, tmp_path):
+        cert_path, key_path = _cert_pair(tmp_path)
+        server = BindServer(
+            serial="01P00A391800001",
+            model="C12",
+            name="vp",
+            version="01.07.00.00",
+            bind_address="127.0.0.1",
+            cert_path=cert_path,
+            key_path=key_path,
+        )
+        ctx = server._create_tls_context()
+        assert ctx is not None
+        _assert_required_ciphers(ctx, "bind_server._create_tls_context")
+
+
+class TestTlsProxyServerCiphers:
+    """Slicer-facing side of proxy mode — was not patched by the #620 fix."""
+
+    def test_create_server_ssl_context_offers_plain_rsa_aes_gcm(self, tmp_path):
+        cert_path, key_path = _cert_pair(tmp_path)
+        proxy = TLSProxy(
+            name="Bind-TLS",
+            listen_port=3002,
+            target_host="127.0.0.1",
+            target_port=3002,
+            server_cert_path=str(cert_path),
+            server_key_path=str(key_path),
+            on_connect=lambda cid: None,
+            on_disconnect=lambda cid: None,
+            bind_address="127.0.0.1",
+        )
+        ctx = proxy._create_server_ssl_context()
+        _assert_required_ciphers(ctx, "tcp_proxy._create_server_ssl_context")
+
+    def test_create_client_ssl_context_still_offers_plain_rsa_aes_gcm(self, tmp_path):
+        """The original #620 fix must remain in place."""
+        cert_path, key_path = _cert_pair(tmp_path)
+        proxy = TLSProxy(
+            name="Bind-TLS",
+            listen_port=3002,
+            target_host="127.0.0.1",
+            target_port=3002,
+            server_cert_path=str(cert_path),
+            server_key_path=str(key_path),
+            on_connect=lambda cid: None,
+            on_disconnect=lambda cid: None,
+            bind_address="127.0.0.1",
+        )
+        ctx = proxy._create_client_ssl_context()
+        _assert_required_ciphers(ctx, "tcp_proxy._create_client_ssl_context")
+
+
+class TestMqttServerTlsCiphers:
+    """MQTT server builds its SSLContext inline in start(); intercept the
+    ``asyncio.start_server`` call so the test doesn't actually bind a port."""
+
+    @pytest.mark.asyncio
+    async def test_start_configures_plain_rsa_aes_gcm(self, tmp_path):
+        cert_path, key_path = _cert_pair(tmp_path)
+        server = SimpleMQTTServer(
+            serial="01P00A391800001",
+            access_code="deadbeef",
+            cert_path=cert_path,
+            key_path=key_path,
+            model="C12",
+            bind_address="127.0.0.1",
+        )
+
+        captured: dict[str, ssl.SSLContext] = {}
+
+        async def _capture(*_args, ssl=None, **_kwargs):
+            captured["ctx"] = ssl
+
+            class _FakeServer:
+                sockets = []
+
+                def close(self):
+                    pass
+
+                async def wait_closed(self):
+                    pass
+
+                def is_serving(self):
+                    return False
+
+            return _FakeServer()
+
+        with patch("asyncio.start_server", _capture):
+            await server.start()
+        try:
+            assert "ctx" in captured, "asyncio.start_server was not invoked"
+            _assert_required_ciphers(captured["ctx"], "mqtt_server.start")
+        finally:
+            await server.stop()
+
+
+class TestFtpServerTlsCiphers:
+    """FTP server builds its SSLContext inline in start()."""
+
+    @pytest.mark.asyncio
+    async def test_start_configures_plain_rsa_aes_gcm(self, tmp_path):
+        cert_path, key_path = _cert_pair(tmp_path)
+        upload_dir = tmp_path / "uploads"
+        upload_dir.mkdir()
+        server = VirtualPrinterFTPServer(
+            upload_dir=upload_dir,
+            access_code="deadbeef",
+            cert_path=cert_path,
+            key_path=key_path,
+            bind_address="127.0.0.1",
+        )
+
+        captured: dict[str, ssl.SSLContext] = {}
+
+        async def _capture(*_args, ssl=None, **_kwargs):
+            captured["ctx"] = ssl
+
+            class _FakeServer:
+                sockets = []
+
+                def close(self):
+                    pass
+
+                async def wait_closed(self):
+                    pass
+
+                def is_serving(self):
+                    return False
+
+                async def serve_forever(self):
+                    pass
+
+            return _FakeServer()
+
+        with patch("asyncio.start_server", _capture):
+            await server.start()
+        try:
+            assert "ctx" in captured, "asyncio.start_server was not invoked"
+            _assert_required_ciphers(captured["ctx"], "ftp_server.start")
+        finally:
+            await server.stop()

+ 19 - 1
docker-compose.yml

@@ -34,7 +34,7 @@ services:
     #  - "6000:6000"                  # Virtual printer file transfer tunnel
     #  - "322:322"                    # Virtual printer RTSP camera (X1/H2/P2; proxy mode + non-proxy modes with a target printer)
     #  - "2024-2026:2024-2026"        # Virtual printer proprietary ports (A1/P1S)
-    #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data
+    #  - "50000-51000:50000-51000"    # Virtual printer FTP passive data (widened from 50000-50100 for multi-VP headroom)
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
@@ -69,6 +69,14 @@ services:
       # Enable the system trust store with the USE_SYSTEM_TRUST_STORE env var to
       # have Bambuddy trust the certificate.
       # - /path/to/certs:/usr/local/share/ca-certificates
+      #
+      # External library folders. Mount the host paths the operator wants
+      # users to be able to register as external folders. The in-container
+      # paths chosen here MUST appear in BAMBUDDY_EXTERNAL_ROOTS below.
+      # Read-only (:ro) is recommended unless you want users uploading
+      # files back to the host share.
+      #- /mnt/nas/3d-prints:/external/nas:ro
+      #- /srv/library:/external/projects:ro
     environment:
       - TZ=${TZ:-Europe/Berlin}
       # User/group the container drops to after the entrypoint normalises
@@ -101,6 +109,16 @@ services:
       # to manage the key out-of-band (e.g. via a secret manager).
       #- MFA_ENCRYPTION_KEY=
       #
+      # External library folders (GHSA-r2qv follow-up). Empty default
+      # disables the "Add external folder" feature; set to one or more
+      # colon-separated absolute paths INSIDE THE CONTAINER to opt in.
+      # The paths must also be bind-mounted from the host — uncomment
+      # the matching volume snippet below.
+      # Example for a single NAS mount:
+      #- BAMBUDDY_EXTERNAL_ROOTS=/external/nas
+      # Example for two roots:
+      #- BAMBUDDY_EXTERNAL_ROOTS=/external/nas:/external/projects
+      #
       # Enable System Trust Store for certificate validation (e.g. for local Home Assistant)
       # You also need to mount your certificates to the container (see volumes section above).
       # - USE_SYSTEM_TRUST_STORE=true

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 109 - 524
frontend/package-lock.json


+ 2 - 2
frontend/package.json

@@ -56,7 +56,7 @@
     "@types/react": "^19.2.5",
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^5.1.1",
-    "@vitest/coverage-v8": "^3.2.4",
+    "@vitest/coverage-v8": "^4.1.8",
     "autoprefixer": "^10.4.22",
     "baseline-browser-mapping": "^2.9.19",
     "eslint": "^9.39.1",
@@ -70,6 +70,6 @@
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.46.4",
     "vite": "^7.3.2",
-    "vitest": "^3.2.4"
+    "vitest": "^4.1.8"
   }
 }

Vissa filer visades inte eftersom för många filer har ändrats